Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Real-World Angular Series, Part 5a: Animation and Template-Driven Forms

DZone's Guide to

Real-World Angular Series, Part 5a: Animation and Template-Driven Forms

Learn how to add and update data to your web application using Typescript, JavaScript, and HTML in the Angular framework.

· Web Dev Zone ·
Free Resource

Deploying code to production can be filled with uncertainty. Reduce the risks, and deploy earlier and more often. Download this free guide to learn more. Brought to you in partnership with Rollbar.

The fourth part of this tutorial (Part 4a and Part 4b) covered access management with Angular, displaying admin data, and setting up detail pages with tabs.

The fifth installment in the series covers simple animation and using a template-driven form to add and update data.

Angular: RSVP Component

Let's pick up right where we left off last time. We'll add some basic functionality to our RSVP component to display existing RSVPs. Shortly, we'll create the RSVP form, which will be responsible for adding and updating RSVPs. At that time, we'll add quite a bit more logic to this component.

Add Display Utilities to Service

Before we implement our RSVP component, let's add a few more utility methods to UtilsService. Open the utils.service.ts file and add the following:

// src/app/core/utils.service.ts
...
  displayCount(guests: number): string {
    // Example usage:
    //  attending this event
    const persons = guests === 1 ? ' person' : ' people';
    return guests + persons;
  }

  showPlusOnes(guests: number): string {
    // If bringing additional guest(s), show as "+n"
    if (guests) {
      return `+${guests}`;
    }
  }

  booleanToText(bool: boolean): string {
    // Change a boolean to 'Yes' or 'No' string
    return bool ? 'Yes' : 'No';
  }
  ...

These are very simple helper utilities to enhance the display of data. The displayCount() method returns the number of people attending using the appropriate noun ("person" or "people"). The showPlusOnes() method returns a + with the number of guests if guests exist. Finally, booleanToText() converts a true or false value to a "Yes" or "No" string.

Add Filter to Filter/Sort Service

We're also going to need to add a method to our FilterSortService. Open the filter-sort.service.ts file and add the following:

// src/app/core/filter-sort.service.ts
  ...
  filter(array: any[], property: string, value: any) {
    // Return only items with specific key/value pair
    if (!property || value === undefined || !this._objArrayCheck(array)) {
      return array;
    }
    const filteredArray = array.filter(item => {
      for (const key in item) {
        if (item.hasOwnProperty(key)) {
          if (key === property && item[key] === value) {
            return true;
          }
        }
      }
    });
    return filteredArray;
  }
  ...

This is a straightforward filter for objects in arrays. It accepts an array, a property name, and a value. It then uses the filter() array method to return a new array with only objects that contain the specified key/value pair.

We'll use this method in our RSVP component to separate the RSVPs into those who are attending and those who declined to attend.

RSVP Component Class

Let's start our RSVP component by displaying RSVP information. In 'API: Fetching Events' (Part 3a and Part 3b of this series), we established an endpoint to retrieve RSVPs from MongoDB by passing an event ID. An HTTP observable was added to our ApiServicecalled getRsvpsByEventId$().

Right now, we'll implement the following features in our RSVP component:

  • A notice indicating whether the event is over.
  • The user's existing RSVP information, if they have responded to the event already.
  • A collapsible list of everyone who has RSVPed to the event, whether they are attending or not, and how many guests they're bringing.
  • If the user is an admin, comments should also be shown.
  • Display total number of attending guests (including additional people they're bringing).
  • Display the total number of responses that are not attending.

As we implement this, we'll keep in mind the next set of features. Some of the logic we'll put in place will be future-facing to accommodate handling the form, which we'll build shortly as a separate child component.

  • A template-driven RSVP form that can be canceled.
  • If the user has already responded, they can edit their response.
  • If they have not responded yet, they can submit a new RSVP.
  • The RSVP list should update in response to new RSVPs or edits.

Let's implement the first set of logic. Open the rsvp.component.ts file:

// src/app/pages/event/rsvp/rsvp.component.ts
import { Component, OnInit, Input, OnDestroy } from '@angular/core';
import { AuthService } from './../../../auth/auth.service';
import { ApiService } from './../../../core/api.service';
import { UtilsService } from './../../../core/utils.service';
import { FilterSortService } from './../../../core/filter-sort.service';
import { RsvpModel } from './../../../core/models/rsvp.model';
import { Subscription } from 'rxjs/Subscription';

@Component({
  selector: 'app-rsvp',
  templateUrl: './rsvp.component.html',
  styleUrls: ['./rsvp.component.scss']
})
export class RsvpComponent implements OnInit, OnDestroy {
  @Input() eventId: string;
  @Input() eventPast: boolean;
  rsvpsSub: Subscription;
  rsvps: RsvpModel[];
  loading: boolean;
  error: boolean;
  userRsvp: RsvpModel;
  totalAttending: number;
  footerTense: string;
  showAllRsvps = false;
  showRsvpsText = 'View All RSVPs';

  constructor(
    public auth: AuthService,
    private api: ApiService,
    public utils: UtilsService,
    public fs: FilterSortService) { }

  ngOnInit() {
    this.footerTense = !this.eventPast ? 'plan to attend this event.' : 'attended this event.';
    this._getRSVPs();
  }

  private _getRSVPs() {
    this.loading = true;
    // Get RSVPs by event ID
    this.rsvpsSub = this.api
      .getRsvpsByEventId$(this.eventId)
      .subscribe(
        res => {
          this.rsvps = res;
          this._updateRsvpState();
          this.loading = false;
        },
        err => {
          console.error(err);
          this.loading = false;
          this.error = true;
        }
      );
  }

  toggleShowRsvps() {
    this.showAllRsvps = !this.showAllRsvps;
    this.showRsvpsText = this.showAllRsvps ? 'Hide RSVPs' : 'Show All RSVPs';
  }

  private _updateRsvpState() {
    // @TODO: We will add more functionality here later
    this._setUserRsvpGetAttending();
  }

  private _setUserRsvpGetAttending() {
    // Iterate over RSVPs to get/set user's RSVP
    // and get total number of attending guests
    let guests = 0;
    const rsvpArr = this.rsvps.map(rsvp => {
      // If user has an existing RSVP
      if (rsvp.userId === this.auth.userProfile.sub) {
        this.userRsvp = rsvp;
      }
      // Count total number of attendees
      // + additional guests
      if (rsvp.attending) {
        guests++;
        if (rsvp.guests) {
          guests += rsvp.guests;
        }
      }
      return rsvp;
    });
    this.rsvps = rsvpArr;
    this.totalAttending = guests;
  }

  ngOnDestroy() {
    this.rsvpsSub.unsubscribe();
  }

}

For the RSVP tab, we don't need to know anything about the event except its ID and if it's in the past or not. We already get these inputs from the parent Event component. We'll set up the necessary properties to manage a subscription to RSVPs, store the user's RSVP (if they have one), keep track of the total number of planned attendees, present or past tense language in the footer, and a toggle for showing or hiding the list of all RSVPs.

The constructor will take arguments for the AuthService so we can conditionally display RSVP comments for admins, the ApiService, the UtilsService to access the utility methods we created above, and the FilterSortService to organize the RSVP list by guests who are attending versus not attending.

In our ngOnInit() method, we'll set the footerTense property to present or past tense based on whether the event is over or not. Then we'll _getRSVPs()from the API by the event ID, which was passed into the RSVP component as an input. The success method in the RSVPs subscription will call a private method called _updateRsvpState(). We'll discuss this below.

The next function we'll create toggles our list of all people who have RSVPed so far. The toggleShowRsvps() method toggles a boolean and sets the button text appropriately based on the state of the toggle.

The private _updateRsvpState() method just calls a _setUserRsvpGetAttending() method right now. Later, when we've implemented the RSVP form, we'll update _updateRsvpState() to specifically handle changes from form submissions. The purpose of this function is to respond to any changes in RSVP data. Once we have a form in place, this could happen three ways: on initial load, when a new RSVP is added, or when a user updates their existing RSVP.

The private _setUserRsvpGetAttending() method will, as the name implies, set the userRsvp and totalAttending properties based on the RSVP data currently available. We'll use the map() array method to return a new RSVPs array. We're taking this approach because when we implement the form, this will update the RSVP data. We'll check to see if the user's ID matches the userIdproperty of any of the retrieved RSVPs. We'll also count the total number of people attending, including their additional guests.

Finally, we have our ngOnDestroy() where we'll unsubscribe from our RSVP API observable.

RSVP Component Template

Let's write the markup for our RSVP component. Open rsvp.component.html:

<!-- src/app/pages/event/rsvp/rsvp.component.html -->
<div class="card-block">
  <h2 class="card-title text-center">RSVP</h2>
  <app-loading *ngIf="loading"></app-loading>
</div>

<ng-template [ngIf]="utils.isLoaded(loading)">
  <!-- Event is over -->
  <p *ngIf="eventPast" class="card-block lead">
    You cannot RSVP to an event that has already ended.
  </p>

  <ng-template [ngIf]="!eventPast && rsvps">
    <!-- User has RSVPed -->
    <ng-template [ngIf]="userRsvp">
      <p class="card-block lead">You responded to this event with the following information:</p>

      <ul class="list-group list-group-flush">
        <li class="list-group-item">
          <strong>Name:</strong>{{userRsvp.name}}
        </li>
        <li class="list-group-item">
          <strong>Attending:</strong>{{utils.booleanToText(userRsvp.attending)}}
        </li>
        <li *ngIf="userRsvp.attending && userRsvp.guests" class="list-group-item">
          <strong>Additional Guests:</strong>{{userRsvp.guests}}
        </li>
        <li *ngIf="userRsvp.comments" class="list-group-item">
          <strong>Comments:</strong><span [innerHTML]="userRsvp.comments"></span>
        </li>
      </ul>
      <!-- @TODO: Toggle RSVP form (update existing) will go here -->
    </ng-template>

    <!-- No RSVP yet -->
    <div *ngIf="!userRsvp" class="card-block">
      <p class="lead">Fill out the form below to respond:</p>
      <!-- @TODO: RSVP form (add new RSVP) will go here -->
    </div>
  </ng-template>

  <!-- All RSVPs -->
  <div class="card-block text-right">
    <button (click)="toggleShowRsvps()" class="btn btn-link btn-sm">{{showRsvpsText}}</button>
  </div>

  <section class="allRsvps" *ngIf="showAllRsvps">
    <div class="card-block">
      <h3 class="card-title text-center">All RSVPs</h3>
      <p *ngIf="!rsvps.length" class="lead">There are currently no RSVPs for this event.</p>
    </div>

    <ul *ngIf="rsvps.length" class="list-group list-group-flush">
      <li class="list-group-item list-group-item-success justify-content-between">
        <strong>Attending</strong>
        <span class="badge badge-success badge-pill">{{totalAttending}}</span>
      </li>
      <li
        *ngFor="let rsvp of fs.filter(rsvps, 'attending', true)"
        class="list-group-item small">
        {{rsvp.name}} {{utils.showPlusOnes(rsvp.guests)}}
        <p *ngIf="auth.isAdmin && rsvp.comments" class="d-flex w-100">
          <em [innerHTML]="rsvp.comments"></em>
        </p>
      </li>
      <li class="list-group-item list-group-item-danger justify-content-between">
        <strong>Not Attending</strong>
        <span class="badge badge-danger badge-pill">{{fs.filter(rsvps, 'attending', false).length}}</span>
      </li>
      <li
        *ngFor="let rsvp of fs.filter(rsvps, 'attending', false)"
        class="list-group-item small">
        {{rsvp.name}}
        <p *ngIf="auth.isAdmin && rsvp.comments" class="d-flex w-100">
          <em [innerHTML]="rsvp.comments"></em>
        </p>
      </li>
    </ul>
  </section>

  <!-- Error loading RSVPs -->
  <div *ngIf="error" class="card-block">
    <p class="alert alert-danger">
      <strong>Oops!</strong> There was an error retrieving RSVPs for this event. 
    </p>
  </div>
</ng-template>

<!-- Footer showing # of total attending guests -->
<div class="card-footer text-right">
  <small *ngIf="totalAttending >= 0" class="text-muted">{{utils.displayCount(totalAttending)}} {{footerTense}}</small>
</div>

This is a lot of code, but it's a straightforward implementation of the features we talked about before. If the event is over, we want to display an alert informing the user that they won't be able to RSVP. Once events have loaded, we'll display actions for the user. If they've RSVPed, we'll display their RSVP information. If they haven't RSVPed yet, we'll show them a message.

Note: We'll add the RSVP form to both these sections shortly, after we create it.

Next, we'll display a button to show or hide the list of all people who have RSVPed so far. This button uses the toggleShowRsvps() method and showRsvpsText property we created earlier.

If there are no RSVPs yet, we'll show a message saying so. If there are RSVPs, we'll show a list separated into sections for guests who are attending and guests who are not attending. We'll use our FilterSortService's filter() method with the ngFor directive to show these separately. We can then show totalAttending guests. For the count of declined guests, we don't need to do any additional math since this number is equivalent to the number of not-attending RSVPs. Comments will only show here if the user is an administrator.

If there was an error loading RSVPs, we'll show an alert like usual. Finally, the footer will display the total number of attendees (with a check to ensure undefined never shows) formatted into a present or past tense sentence based on if the event is over or not.

Our RSVP tab component should now look like this in the browser:

Angular MEAN app RSVP component

Angular: Animation

This is looking pretty good, but our list of all RSVPs shows and hides quite abruptly. What if we want to animate that section so that it opens and closes more elegantly?

Install Dependencies

Let's implement a simple animation. We'll start by adding the Angular animations package in the root of our project folder:

$ npm install @angular/animations --save

We need to include the BrowserAnimationsModule in our app.module.ts file like so:

// src/app/app.module.ts
...
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

@NgModule({
  ...
  imports: [
    ...,
    BrowserAnimationsModule
  ],
  ...
})
...

Import the module and then add it to the imports array in the NgModule.

Angular animations use the native Web Animations API. To accommodate browsers that don't support this API yet, we'll also want the web animations polyfill.

Note: You can test your browser's support for the WAAPI by visiting this Codepen link.

Let's add this to our project via CDN in the index.html file like so:

<!-- src/index.html -->
...
<head>
  ...
  <script src="https://cdnjs.cloudflare.com/ajax/libs/web-animations/2.2.5/web-animations.min.js"></script>
</head>
...

Now we're ready to use Angular animations in our app!

Create Expand/Collapse Animation

Animations in Angular are quite powerful, but we'll start with a basic standby: an expand/collapse sliding animation triggered by NgIf. We'll implement this in a way that allows us to reuse it across components if we wish.

Create a new blank file in the src/app/core folder called expand-collapse.animation.ts. This will be an animation factory that we'll export and be able to import into any component that needs it.

Note: There are actually a few ways we could author this animation. It's entirely up to you to craft the animation in whatever way makes the most sense to you. Two options will be presented and both achieve the same thing:

// src/app/core/expand-collapse.animation.ts
import { trigger, transition, style, animate, state } from '@angular/animations';

// OPTION 1:
export const expandCollapse = trigger('expandCollapse', [
  state('*', style({
    'overflow-y': 'hidden',
    'height': '*'
  })),
  state('void', style({
    'height': '0',
    'overflow-y': 'hidden'
  })),
  transition('* => void', animate('250ms ease-out')),
  transition('void => *', animate('250ms ease-in'))
]);

Alternatively, this could also be written like so:

// src/app/core/expand-collapse.animation.ts
import { trigger, transition, style, animate, state } from '@angular/animations';

// OPTION 2:
export const expandCollapse = trigger('expandCollapse', [
  state('*', style({'overflow-y': 'hidden'})),
  state('void', style({'overflow-y': 'hidden'})),
  transition('* => void', [
    style({height: '*'}),
    animate('250ms ease-out', style({height: 0}))
  ]),
  transition('void => *', [
    style({height: 0}),
    animate('250ms ease-in', style({height: '*'}))
  ])
]);

This syntax is a departure from AngularJS's use of CSS classes for animation, but the strengths here are easily understood once we know what we're looking at.

First, we need to import trigger, transition, style, animate, and state. The trigger() animation method accepts a name for the animation trigger and an array. The name will be how we'll apply this animation in our templates (i.e., <div *ngIf="state" [@triggerName]>).

Angular Animation Methods

Let's talk briefly about the purpose of each of these methods:

  • trigger(): accepts a name for the animation trigger and an array of state and transition methods to configure the animation.
  • state(): accepts the name of the state of the animation, such as 'active'or 'inactive', and styles that should be applied conditionally when in that state.
  • style(): sets CSS styles and can be passed in to configure a state, transition, or animation.
  • transition(): accepts a string explaining which states are being transitioned and which direction the transition is going (i.e., 'active => inactive'), and any styles or animations to configure the transition.
  • animate(): accepts a numeric duration in milliseconds, or a CSS string specifying both the duration and easing (i.e., 250 or '250ms ease-in').

Note: Style, transition, and animation methods passed as arguments can be singular or grouped in an array.

Once we have an understanding of the above methods, hopefully, it's clear that animations can be constructed in a variety of ways depending purely on how we prefer to compose them. As long as we understand the purpose of each method, we can easily interpret animations composed in different ways.

Animating NgIf

Now that we know the methods used in the animation trigger, both approaches above should be understandably equivalent. However, there are a few special things to note in our expandCollapse animation: */void and height: '*'.

When animating structural directives (such as NgIf), Angular provides the state and transition names for us.

Note: This should feel somewhat familiar if you remember .ng-enter and .ng-leave in AngularJS.

  • State *: element is present.
  • State void: element is removed.
  • Transition void => *: element is being added (alias: :enter).
  • Transition * => void: element is being removed (alias: :leave).

Angular animation also now supports automatic property calculation! Because Angular animations are now backed by JavaScript with the Web Animations API, we no longer have to animate to an arbitrary max-height to accommodate animation of elements with dynamic dimensions. If an asterisk is used as a CSS property value, the value is computed at runtime and plugged into the animation automatically.

Notes on Animation

I personally prefer Option 1 for animations because I find it easier to read the two complete states that are being transitioned. However, Option 2 allows you to see exactly which CSS properties are changing between states and what they're transitioning to and from. Pick an approach that makes the most sense to you.

In both approaches, note that overflow-y: 'hidden' is included in both states. This style is needed by the animation, so it's included in the animation JS rather than in CSS. Needing to remember to apply it with CSS whenever we add the animation somewhere would prove cumbersome and not very future-proof. It's safer to include all animation-supporting styles with the animation itself.

Hopefully, you can see how animations like this can be expanded to support much more complexity. Check out the Angular Animation docs for more examples and in-depth documentation.

Implement Animation in RSVP Component

Now that we have our animation, we need to implement it in a component. We exported it in a factory, so we can easily import it wherever necessary. Let's add it to our RSVP component.

Open rsvp.component.ts and make the following additions:

// src/app/pages/event/rsvp/rsvp.component.ts
...
import { expandCollapse } from './../../../core/expand-collapse.animation';

@Component({
  ...,
  animations: [expandCollapse]
})
...

Next, open the rsvp.component.html template and add the animation trigger attribute:

<!-- src/app/pages/event/rsvp/rsvp.component.html -->
...
  <!-- All RSVPs -->
  ...
  <section class="allRsvps" *ngIf="showAllRsvps" [@expandCollapse]>
    ...

Since we already have the toggle set up to show and hide the All RSVPs list, all we need to do here is add the [@expandCollapse] trigger to the hiding/showing element.

We should now see our animation in the browser when we click the button to toggle the RSVP list. Try it out!

Side Note: Don't forget to run ng lint periodically to ensure that your code is error-free.

API: Create and Update RSVPs

It's time to provide a way for users to add and update RSVPs. The first thing we'll need to do is create endpoints in our Node API.

POST New RSVP

Open the server api.js file and add the following /api/rsvp/new endpoint:

// server/api.js
...
/*
 |--------------------------------------
 | API Routes
 |--------------------------------------
 */
  ...
  // POST a new RSVP
  app.post('/api/rsvp/new', jwtCheck, (req, res) => {
    Rsvp.findOne({eventId: req.body.eventId, userId: req.body.userId}, (err, existingRsvp) => {
      if (err) {
        return res.status(500).send({message: err.message});
      }
      if (existingRsvp) {
        return res.status(409).send({message: 'You have already RSVPed to this event.'});
      }
      const rsvp = new Rsvp({
        userId: req.body.userId,
        name: req.body.name,
        eventId: req.body.eventId,
        attending: req.body.attending,
        guests: req.body.guests,
        comments: req.body.comments
      });
      rsvp.save((err) => {
        if (err) {
          return res.status(500).send({message: err.message});
        }
        res.send(rsvp);
      });
    });
  });

  ...

This POST endpoint requires authentication. We'll check to see if an RSVP already exists for the specified event and user. If so, it will output an error stating the user has already RSVPed. If not, we'll create a new Rsvp object with the data passed to the POST request in the body. We can then save() the new RSVP to MongoDB and send it to the front-end in the JSON response.

PUT (edit) Existing RSVP

Next, add the following /api/rsvp/:id endpoint to edit existing RSVPs:

// server/api.js
...
  // PUT (edit) an existing RSVP
  app.put('/api/rsvp/:id', jwtCheck, (req, res) => {
    Rsvp.findById(req.params.id, (err, rsvp) => {
      if (err) {
        return res.status(500).send({message: err.message});
      }
      if (!rsvp) {
        return res.status(400).send({message: 'RSVP not found.'});
      }
      if (rsvp.userId !== req.user.sub) {
        return res.status(401).send({message: 'You cannot edit someone else\'s RSVP.'});
      }
      rsvp.name = req.body.name;
      rsvp.attending = req.body.attending;
      rsvp.guests = req.body.guests;
      rsvp.comments = req.body.comments;

      rsvp.save(err => {
        if (err) {
          return res.status(500).send({message: err.message});
        }
        res.send(rsvp);
      });
    });
  });

  ...

This PUT endpoint also requires authentication. It uses findById() to get the existing RSVP from the database so it can be updated, accounting for errors if the RSVP cannot be found or if the userId in the RSVP doesn't match the authenticated user.

Note: When adding entries to MongoDB through our API endpoints, you may notice __v properties appearing in your collection documents in MongoBooster or mLab. This is a versionKey automatically set by mongoose.

We'll then update this RSVP's editable properties with data sent with the PUTrequest. These include name, attending status, the number of additional guests, and comments. An existing RSVP's userId and eventId should not be modified. After updating, we'll save() our changes and handle any errors, sending the updated RSVP data back in the response.

Angular: Add RSVP Endpoints to API Service

We'll now add the corresponding methods to our ApiService to call the new endpoints we just added.

Open the api.service.ts file and add these two methods:

// src/app/core/api.service.ts
...
  // POST new RSVP (login required)
  postRsvp$(rsvp: RsvpModel): Observable<RsvpModel> {
    return this.authHttp
      .post(`${ENV.BASE_API}rsvp/new`, rsvp)
      .map(this._handleSuccess)
      .catch(this._handleError);
  }

  // PUT existing RSVP (login required)
  editRsvp$(id: string, rsvp: RsvpModel): Observable<RsvpModel> {
    return this.authHttp
      .put(`${ENV.BASE_API}rsvp/${id}`, rsvp)
      .map(this._handleSuccess)
      .catch(this._handleError);
  }

  ...

The postRsvp$() method takes an object of type RsvpModel as the new RSVP to add to the database. The editRsvp$() method takes the ID of the RSVP being edited and an object of type RsvpModel with the updated data.

We're now ready to add and edit RSVPs. In the next part of this aticle, we'll create an RSVP form component to implement this.

Deploying code to production can be filled with uncertainty. Reduce the risks, and deploy earlier and more often. Download this free guide to learn more. Brought to you in partnership with Rollbar.

Topics:
web dev ,angular ,web application development

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}