In the first part of the series we setup a workspace, configured AWS cloud resources in a shared library, added Authentication to protect private data, generated typings for the GraphQL API, wrote a mutation helper wrapper function and discussed various topics around this.
Getting started with a Angular/NX workspace backed by an AWS Amplify GraphQL API - Part 1
Michael Gustmann ・ Mar 26 '19
We were busy, but until this point, we still haven't done much towards our UI. Let's create some components using the Angular CLI in this second part!
I will omit some styles for brevity reasons, but you can find them on GitHub.
Angular with NX multi-app setup powered by an AWS Amplify GraphQL Backend
Getting started
To deploy this project, hit this button ...
... or go through the following steps:
- Clone this repo
git clone https://github.com/BeaveArony/amplify-angular-nx-todo.git
- Change into the directory & install the dependencies
npm install
# or
yarn
- Initialize the Amplify project
amplify init
- Create the resources in your account
amplify push
- Start the app
npm start
Building the UI
- The todo-page that wraps the whole UI. The component, that will be imported by app-component and is going to be the only container/smart component of this lib
- The form to create new todos
- The filter component to switch visibility on the completed property
- The todo-list component with its todo-item components
- A component to show error messages
ng g c --project=todo-ui --export todo-page
ng g c --project=todo-ui -c=OnPush create-form
ng g c --project=todo-ui -c=OnPush todo-list
ng g c --project=todo-ui -c=OnPush todo-item
ng g c --project=todo-ui -c=OnPush filter-bar
ng g c --project=todo-ui -c=OnPush error-alert
I would like to style the app like the AWS authenticator component. To help with the styling, replace the content of apps/todo-app/src/styles.css
with this:
/* You can add global styles to this file, and also import other style files */
@import '~aws-amplify-angular/theme.css';
html,
body {
height: 100%;
}
body {
margin: 0;
background-color: var(--color-accent-brown);
}
/* Amplify UI fixes */
.amplify-form-input {
width: calc(100% - 1em);
box-sizing: border-box;
}
.amplify-alert {
margin-top: 2em;
}
.amplify-greeting > * {
white-space: nowrap;
}
.amplify-greeting-flex-spacer {
width: 100%;
}
.card,
.amplify-authenticator {
width: var(--component-width-desktop);
margin: 1em auto;
border-radius: 6px;
background-color: var(--color-white);
box-shadow: var(--box-shadow);
}
.card-container {
width: 400px;
margin: 0 auto;
padding: 1em;
}
@media (min-width: 320px) and (max-width: 480px) {
.card,
.amplify-authenticator {
width: var(--component-width-mobile);
border-radius: 0;
margin: 0.5em auto;
}
.card-container {
width: auto;
}
.amplify-alert {
left: 4%;
}
}
We are setting up html / body and card classes that look very similar to the Amplify Components styles. We are reusing the CSS variables imported earlier. The amplify-authenticator has rounded corners in the mobile view, so we are changing this in the media-query as well.
We are also fixing the styling of the html input tags, because they are extending too far to the right in mobile.
There are still some styling issues with the amplify angular authenticator component, but I think it's good enough for a simple drop in UI component to support the general sign-up/sign-in procedure.
Todo-page
The todo-page component is the starting point of the todo-ui module. It is the only component exported, leaving all other components internal to this lib. We could introduce routing at a later stage and even lazy load this module for a snappier start of our app.
This component is also going to be the only container component handling the API communication and providing data to the other presentational components in this module. Since we haven't written any code in our components yet, we compose our view with what we got so far. All components were generated by the ng generate
commands and can be referenced in our HTML markup.
Change libs/todo-ui/src/lib/todo-page/todo-page.component.html
to this
<div class="card">
<div class="card-container">
<my-create-form></my-create-form>
<my-filter-bar></my-filter-bar>
</div>
</div>
<my-error-alert></my-error-alert>
<div class="card">
<div class="card-container"><my-todo-list></my-todo-list></div>
</div>
and import the todo-ui module in our root app module apps/todo-app/src/app/app.module.ts
like this
// ... other imports
import { TodoUiModule } from '@my/todo-ui';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, AppsyncModule, TodoUiModule],
bootstrap: [AppComponent]
})
export class AppModule {}
To display the todo-page in the app change apps/todo-app/src/app/app.component.html
to this
<my-todo-page></my-todo-page>
So far this is just the starting point for our UI. You can run the app with npm start
and glance on a basic skeleton.
AWS Authenticator UI-Component
AWS Amplify provides several drop-in UI components for Angular/Ionic, React and Vue. We can choose between
- Authenticator
- Photo Picker
- Album
- Chatbot
To add the AWS Authenticator to our app change apps/todo-app/src/app/app.component.html
to both include it and the todo-page component.
<amplify-authenticator></amplify-authenticator>
<my-todo-page *ngIf="hydrated && isLoggedIn"></my-todo-page>
And the content of apps/todo-app/src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { AppSyncClient } from '@my/appsync';
import { AmplifyService } from 'aws-amplify-angular';
@Component({
selector: 'my-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
hydrated = false;
isLoggedIn = false;
constructor(private amplifyService: AmplifyService) {}
ngOnInit(): void {
AppSyncClient.hydrated().then(client => (this.hydrated = !!client));
this.amplifyService.authStateChange$.subscribe(authState => {
if (authState.state === 'signedOut' && this.isLoggedIn) {
AppSyncClient.clearStore().then(() =>
console.log('User signed out, store cleared!')
);
}
this.isLoggedIn =
authState.state === 'signedIn' || authState.state === 'confirmSignIn';
});
}
}
The AppSyncClient persists its redux store, so we should render the todo-ui only when the store is rehydrated. AppSyncClient offers a AppSyncClient.hydrated()
function we can use for this.
The AmplifyService offers an Observable authStateChange$
we can subscribe to and only show the todo-ui when a user is logged In. To not leave any cached items around when a user signs out we clear the store on that event. The <my-todo-page>
component is hidden while the client is not rehydrated or while no user is signed in with the *ngIf
directive. The <amplify-authenticator>
is showing a greeting message by default and a link to log out when a user is logged in, so we don't need to hide it.
We can configure this component by passing in a config-object
<amplify-authenticator [signUpConfig]="signUpConfig"></amplify-authenticator>
or change the framework to Ionic and hide certain parts, like the SignOut link
<amplify-authenticator
framework="ionic"
[hide]="['SignOut']"
></amplify-authenticator>
Creating the presentational UI
We start with the presentational components, that do not use any API and get their data only through @Input()
and emit events through @Output()
using Angular's EventEmitter
. Later we
connect them in the todo-page component.
model
Create a file libs/todo-ui/src/lib/model.ts
to keep types specific to this UI-Library. In our case we only type our Todo's visibility states.
export type TodoFilterType = 'SHOW_ALL' | 'SHOW_ACTIVE' | 'SHOW_COMPLETED';
create-form component
This create-form component emits a new todo name when the reactive form is submitted. We also disable the submit button, when the required text field is empty. Once we emitted a new todo, we clear the input field. Using the form tag together with the submit button gives us the advantage to just hit 'Enter' after typing our todo text.
libs/todo-ui/src/lib/create-form/create-form.component.html
<form [formGroup]="form" (ngSubmit)="createTodo()">
<input type="text" formControlName="todoName" required />
<button type="submit" [disabled]="!form.valid">+</button>
</form>
libs/todo-ui/src/lib/create-form/create-form.component.ts
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Output
} from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
@Component({
selector: 'my-create-form',
templateUrl: './create-form.component.html',
styleUrls: ['./create-form.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CreateFormComponent {
@Output() created = new EventEmitter<string>();
form = new FormGroup({ todoName: new FormControl('') });
createTodo() {
const todoName = this.form.value.todoName.trim();
if (todoName) {
this.created.emit(todoName);
this.form.setValue({ todoName: '' });
}
}
}
filter-bar component
The filter-bar component holds a bar of toggle buttons to change the visibility of the todo-list. The selected button will be disabled and styled so that it looks active. We can use CSS variables from the Amplify Angular Theme for that.
libs/todo-ui/src/lib/filter-bar/filter-bar.component.html
<nav>
<button
*ngFor="let btn of buttons"
(click)="filter(btn.value)"
[disabled]="currentFilter === btn.value"
>
{{ btn.name }}
</button>
</nav>
libs/todo-ui/src/lib/filter-bar/filter-bar.component.css
nav {
margin-top: 0.5em;
display: flex;
justify-content: space-between;
}
button {
border: 1px solid var(--color-blue);
height: var(--button-height);
width: 128px;
color: var(--color-blue);
text-transform: uppercase;
cursor: pointer;
}
button:hover {
background-color: var(--color-accent-blue);
}
button:disabled {
background-color: var(--color-blue);
color: white;
border: none;
cursor: auto;
}
libs/todo-ui/src/lib/filter-bar/filter-bar.component.ts
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Output
} from '@angular/core';
import { TodoFilterType } from '../model';
@Component({
selector: 'my-filter-bar',
templateUrl: './filter-bar.component.html',
styleUrls: ['./filter-bar.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterBarComponent {
@Output() filtered = new EventEmitter<TodoFilterType>();
currentFilter: TodoFilterType = 'SHOW_ALL';
buttons: { value: TodoFilterType; name: string }[] = [
{ value: 'SHOW_ALL', name: 'All' },
{ value: 'SHOW_ACTIVE', name: 'Active' },
{ value: 'SHOW_COMPLETED', name: 'Completed' }
];
filter(query: TodoFilterType) {
this.currentFilter = query;
this.filtered.emit(this.currentFilter);
}
}
We emit a filter query string depending on the selected button. The parent todo-page component will listen to this and pass the query to its child todo-list component as Input, so it can filter the list on the completed property.
todo-item component
The todo-list renders a list of todo-item components. These items are responsible to display the provided input values. We use HTML entities as icons. When clicking on the delete icon we emit the deleted
event and when clicked on the rest of this item a toggled
event is emitted to change the completed property of this todo item.
libs/todo-ui/src/lib/todo-item/todo-item.component.html
<div class="item" *ngIf="todo">
<div
class="checkmark"
[class.checked]="todo.completed"
(click)="toggled.emit()"
>
{{ todo.completed ? '✔' : '🗸' }}
</div>
<div
class="content"
[ngStyle]="{ textDecoration: todo.completed ? 'line-through' : 'none' }"
(click)="toggled.emit()"
>
{{ todo.name }}
</div>
<div class="delete" (click)="deleted.emit()">✗</div>
</div>
libs/todo-ui/src/lib/todo-item/todo-item.component.ts
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output
} from '@angular/core';
import { ListTodos_listTodos_items } from '@my/appsync';
@Component({
selector: 'my-todo-item',
templateUrl: './todo-item.component.html',
styleUrls: ['./todo-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoItemComponent {
@Input() todo: ListTodos_listTodos_items;
@Output() toggled = new EventEmitter<boolean>();
@Output() deleted = new EventEmitter<boolean>();
}
todo-list component
The todo-list component renders each todo item through the previously created todo-item component. To react on the Inputs to filter the todos we use getters and setters. Component life cycle hook ngOnChanges
would also work here.
The visibility filter is the only client state in our app and is managed in our components. Apollo Client, which is used by AWSAppSyncClient under the hoods, keeps the data in a normalized redux store.
We have many choices here, when we think about our architecture:
- Manage the state in an Angular provider using a Behavior Subject
- Write actions and reducers to extend the underlying redux store
- Use a separate state management library for our client state
- Extend Apollo Client to use apollo-link-state, which offers querying and mutating our local state with GraphQL operations
- Keep it in our components
- ...
Extending the underlying redux store is a little bit risky, since the AWSAppSyncClient can change the implementation internals at any time. We would get stuck with a certain version or need to refactor our client state.
For this simple app using a Behavior Subject is probably the easiest. For non-trivial apps I would use a state management library. Using apollo-link-state feels like the cleanest solution, having local and remote state in one place.
<!-- libs/todo-ui/src/lib/todo-list/todo-list.component.html -->
<my-todo-item
*ngFor="let todo of visibleTodos"
[todo]="todo"
(toggled)="toggled.emit(todo)"
(deleted)="deleted.emit(todo)"
></my-todo-item>
<div *ngIf="!visibleTodos || visibleTodos?.length === 0">No todos</div>
// libs/todo-ui/src/lib/todo-list/todo-list.component.ts
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output
} from '@angular/core';
import { ListTodos_listTodos_items } from '@my/appsync';
import { TodoFilterType } from '../model';
@Component({
selector: 'my-todo-list',
templateUrl: './todo-list.component.html',
styleUrls: ['./todo-list.component.css'],
changeDetection: ChangeDetectionStrategy.Default
})
export class TodoListComponent {
@Output() toggled = new EventEmitter<ListTodos_listTodos_items>();
@Output() deleted = new EventEmitter<ListTodos_listTodos_items>();
private _filter: TodoFilterType;
private _todos: ListTodos_listTodos_items[];
@Input()
public get filter(): TodoFilterType {
return this._filter;
}
public set filter(value: TodoFilterType) {
this._filter = value;
this.visibleTodos = this.getVisibleTodos(this.todos, value);
}
@Input()
public get todos(): ListTodos_listTodos_items[] {
return this._todos;
}
public set todos(value: ListTodos_listTodos_items[]) {
this._todos = value;
this.visibleTodos = this.getVisibleTodos(value, this.filter);
}
visibleTodos: ListTodos_listTodos_items[];
private getVisibleTodos(
todos: ListTodos_listTodos_items[],
filter: TodoFilterType
) {
switch (filter) {
case 'SHOW_ALL':
return todos;
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed);
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed);
default:
return todos;
}
}
}
Whenever either the filter query or the provided todos change, we calculate the visible todos and store them in visibleTodos.
error-alert component
This reusable component receives an error message and emits an event if the close button is clicked.
<div class="card" *ngIf="errorMessage">
<div class="card-container">
<div class="flex-row">
<span class="icon">⚠</span>
<div class="msg">{{ errorMessage }}</div>
<a class="close" (click)="closed.emit()">×</a>
</div>
</div>
</div>
.flex-row {
display: flex;
justify-content: space-between;
}
.close {
color: var(--color-gray);
font-size: 32px;
cursor: pointer;
}
.msg {
width: 100%;
margin: 10px 2em;
}
.icon {
color: var(--red);
font-size: 32px;
width: 38px;
}
import {
ChangeDetectionStrategy,
Component,
Input,
Output,
EventEmitter
} from '@angular/core';
@Component({
selector: 'my-error-alert',
templateUrl: './error-alert.component.html',
styleUrls: ['./error-alert.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ErrorAlertComponent {
@Input() errorMessage: string | null = null;
@Output() closed = new EventEmitter();
}
Watching todos
Now that we have build our presentational components, we can come back to our wrapping component, that handles all the events, interacts with our API and displays possible errors.
// imports ...
@Component({
selector: 'my-todo-page',
templateUrl: './todo-page.component.html',
styleUrls: ['./todo-page.component.css'],
changeDetection: ChangeDetectionStrategy.Default
})
export class TodoPageComponent implements OnInit {
todos: ListTodos_listTodos_items[];
errorMessage: string | null = null;
ngOnInit() {
AppSyncClient.watchQuery<ListTodos, ListTodosVariables>({
query: LIST_TODOS
}).subscribe(todoList => {
this.todos = todoList.data
? todoList.data.listTodos.items.sort((a, b) =>
a.createdOnClientAt.localeCompare(b.createdOnClientAt)
)
: [];
}, this.handleError);
}
handleError = (err: ApolloError) => {
this.errorMessage = err.message;
};
}
We listen to any local changes of our todos in ngOnInit life cycle hook. Using watchQuery() for this, informs our component of local operations like adding or removing items. To listen to remote changes, we would need to set up a GraphQL subscription. Fortunately watchQuery() is generic so we can specify the previously generated query and variables types. This offers us complete IntelliSense in the subscribe block! If we receive data, we sort them on the client on the createdOnClientAt property.
watchQuery() returns a zen-observable, not one from rxjs! So, we can't use the async pipe here. We could write a converter or simply use apollo-angular to do this for us.
We have to think about change detection here. If this component would use the OnPush ChangeDetectionStragy, our UI would not react to changes coming from this observable. We can either change to the Default strategy like above or inject ChangeDetectorRef to invoke markForCheck() in the subscribe block.
Something like this, focussing on the changes:
import { ChangeDetectorRef } from '@angular/core';
// ...
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoPageComponent {
constructor(private cd: ChangeDetectorRef) {}
ngOnInit() {
AppSyncClient.watchQuery<ListTodos, ListTodosVariables>({
query: LIST_TODOS
}).subscribe(todoList => {
// ...
this.cd.markForCheck();
});
}
}
Angular's async pipe would handle this automatically for us!
Change one line in the html file to pass our todos to the todo-list component:
<my-todo-list [todos]="todos"></my-todo-list>
Interact with the error-alert component by passing the error message and setting it to null when the closed event emits
<my-error-alert
(closed)="errorMessage = null"
[errorMessage]="errorMessage"
></my-error-alert>
Creating todos
To have items to display in our list, we need to be able to create a new todo. So, let's implement a handler in our component class and pass it the event payload of the created event of the create-form component.
<my-create-form (created)="create($event)"></my-create-form>
// ...
export class TodoPageComponent {
// ...
create(name: string) {
executeMutation<CreateTodoVariables, CreateTodo_createTodo>({
mutation: CREATE_TODO,
variablesInfo: {
input: {
name,
completed: false,
createdOnClientAt: new Date().toISOString()
}
},
cacheUpdateQuery: [LIST_TODOS],
typename: 'Todo'
}).catch(this.handleError);
}
}
Here happens quite a lot in a single statement. Let's break it down!
We invoke our generic helper function executeMutation() and pass it an object. This object needs the following properties:
- mutation: The graphql mutation operation
- variablesInfo: The input object we want to send to our API
- cacheUpdateQuery: A list of queries we would like to be updated in our cache
- typename: The value of the __typename property
Since we add a new item to our list, we need to update the LIST_TODOS query in the cache. It's as simple as adding it to the cacheUpdateQuery's array. Doing this will invoke the watchQuery observable twice. First with an optimistic response using a temporary ID, like we would expect our API to respond if all goes well and finally with the actual result from the server.
If we would receive an error when trying to create the todo, the second call would not contain this new todo anymore, negating the optimistic UI update. Additionally, we are handling the error in the catch part of the returned promise and displaying it with our error-alert component. This way the user gets a hint of what just happened, even though the error message is quite technical.
Note, everything is type safe, even the typename string!
Updating a todo
To update the completed property of a todo, we listen to the toggled event of the todo-list component. We pass the payload to the toggle() function, destructuring it in the process.
<my-todo-list [todos]="todos" (toggled)="toggle($event)"></my-todo-list>
// ...
export class TodoPageComponent {
// ...
toggle({
id,
completed,
createdOnClientAt,
name
}: ListTodos_listTodos_items) {
executeMutation<UpdateTodoVariables, UpdateTodo_updateTodo>({
mutation: UPDATE_TODO,
variablesInfo: {
input: {
completed: !completed,
id,
createdOnClientAt,
name
}
},
cacheUpdateQuery: null,
typename: 'Todo'
}).catch(this.handleError);
}
}
This looks very similar to the create mutation. We provide the updated item and this time we don't need to take care of updating the cache. Apollo Client is handling this automatically, so we can pass null or an empty array to cacheUpdateQuery.
ListTodos_listTodos_items looks pretty weird as an input type. It's the type of one item of the list, but I guess that's the price we have to pay when this gets generated automatically!
Deleting a todo
To delete a todo, we listen to the deleted event of the todo-list component. We pass the payload to the toggle() function, destructuring only the id.
<my-todo-list
[todos]="todos"
(toggled)="toggle($event)"
(deleted)="delete($event)"
></my-todo-list>
// ...
export class TodoPageComponent {
// ...
delete({ id }: ListTodos_listTodos_items) {
executeMutation<DeleteTodoVariables, DeleteTodo_deleteTodo>({
mutation: DELETE_TODO,
variablesInfo: {
input: { id }
},
cacheUpdateQuery: [LIST_TODOS],
typename: 'Todo'
}).catch(this.handleError);
}
}
Deleting an item from a list requires us to take care of the cache again.
Handling the filter
The only thing left is listening to the filter-bar component and passing the value to the todo-list component. We start with 'SHOW_ALL', but don't do anything else with the filter variable.
<my-filter-bar (filtered)="filter = $event"> </my-filter-bar>
<!-- ... -->
<my-todo-list
[todos]="todos"
[filter]="filter"
(toggled)="toggle($event)"
(deleted)="delete($event)"
></my-todo-list>
filter: TodoFilterType = 'SHOW_ALL';
That's it. Please take a look at this repo to see the complete source code.
Taking it for a spin
Let's run npm start
to get a chance to admire our work. Here are a few things for you to play around with:
- Create at least two users to see how each one has its own todos
- Add new todos
- Try out the responsive UI
- Open the dev-tools and tick the offline checkbox, do a few operations and finally go online again.
- Open the redux dev-tools (if installed) and inspect the outbox of redux-offline, the normalized entities of apollo client's InMemoryCache and the rehydration actions of redux-persist
- Take a look at things stored in IndexedDB and the JWT in LocalStorage
- Set a breakpoint in watchQuery() and observe the optimistic response in action
- Change the name variable to null in create() and enjoy the Undo taking place in the list paired with the splendid error message displayed
- Reload the app to appreciate the persisted cache
- Login to the AWS management console and analyze the DynamoDB Table entries created
Recap
We created our UI with Angular components. The wrapping component orchestrates everything by connecting the presentational components, listening to changes in the cache and talking to the API using helpers we set up in Part 1.
Next
In Part 3 we are going to explore how to add a CI/CD pipeline the easy way using the amplify console and even look at how to add a second frontend app.
Top comments (1)
Connect to Amplify Console please:)