Creating Angular UI components and connecting them to the GraphQL AppSync API - Part 2
Michael Gustmann
Posted on March 27, 2019
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.
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:1emauto;border-radius:6px;background-color:var(--color-white);box-shadow:var(--box-shadow);}.card-container{width:400px;margin:0auto;padding:1em;}@media(min-width:320px)and(max-width:480px){.card,.amplify-authenticator{width:var(--component-width-mobile);border-radius:0;margin:0.5emauto;}.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
and import the todo-ui module in our root app module apps/todo-app/src/app/app.module.ts like this
// ... other importsimport{TodoUiModule}from'@my/todo-ui';@NgModule({declarations:[AppComponent],imports:[BrowserModule,AppsyncModule,TodoUiModule],bootstrap:[AppComponent]})exportclassAppModule{}
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.
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']})exportclassAppComponentimplementsOnInit{hydrated=false;isLoggedIn=false;constructor(privateamplifyService: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 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.
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.
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.
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.
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>
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.
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.
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.
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.
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.
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.
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.