The purpose of this open exercise is to let you put together all you have learnt during the training in order to build a super simple and naive booking application from scratch.
You will use Angular and Otter schematics to:
Note that the example of application that we created while writing this codelab is hosted at: https://dev.azure.com/AmadeusDigitalAirline/Otter%20Training/_git/create-app-training
Feel free to have a look if you're in doubt, but try not to rely on it too much for the sake of the exercise ;)
We first need to setup our environment. If not done already, you should have the following softwares installed on your computer:
Also, you should install the following plugins in Visual Studio Code:
Some IDE like visual studio code can be smart enough to propose you the locations you can import your functions/classes/interface from in order to ease your life.
While this is super useful, we recommend you to be careful especially with VSCode because sometimes the import paths are wrong depending on how the TypeScript extension is configured.
Here's what's recommended: 
If your code doesn't compile because of an import not found, double check the import statement and more precisely the path in case it wasn't correct.
Example of such an error:
import { PaymentFormContModule } from 'src/components/payment-form';
import { PaymentFormContModule } from '../../../components/payment-form';
Some things can go wrong during the following steps because of your network, firewall or some security programs at Amadeus.
In the event that you can't execute the following steps on your machine, we've prepared a branch on the example repository that's in the state you'd be at the end of this section.
Don't hesitate to start from there if you feel you're wasting time.
But please don't forget to report your problems so we can fix or document them to improve this codelab's experience
In case you are doing this exercise as part of a half-day session, we recommend that you start from the seed branch in order to have more time to code.
Clone the example repository:
git clone https://dev.azure.com/AmadeusDigitalAirline/Otter%20Training/_git/create-app-training
And checkout the seed branch then run yarn to install the dependencies:
git checkout seed
yarn install
Then skip to the next stage of the codelab.
If you have trouble cloning because of your windows credentials manager, go to the repository URL:
https://dev.azure.com/AmadeusDigitalAirline/Otter%20Training/_git/create-app-training
Click Clone (top right), then inside the popup click Generate Git Credentials and run (insert the generated Username and Password in the command):
git clone https://<Username>:<Password>@dev.azure.com/AmadeusDigitalAirline/Otter%20Training/_git/create-app-training
First of all, you need to install the Angular CLI globally.
We're going for the latest released for version 15.2 because that's the version the Otter framework we'll be using relies on.
yarn global add @angular/cli@15
Now let's check that you have the right version installed:
ng version
You should see version 15 in the result:
Angular CLI: 15.x.y
Negative : If you have a version other than 15 displayed it could be because you installed angular with npm too, and this is taking precedence over the global yarn installation.
It can be easily checked by running where ng on Windows or which ng on Unix.
Check if the result contains npm or yarn in the path.
If it points to NPM:
npm install -g @angular/cli@15 to modify your active version of the ng cliseed branch of the example repository (details in the Seed branch section above)Next thing you want to do is create a new Angular application by running ng new.
ng new
Cheat sheet:
? What name would you like to use for the new workspace and initial project? create-app-training
? Would you like to add Angular routing? Yes
? Which stylesheet format would you like to use? SCSS
Now you can open your new project in an IDE.
All the following tasks will be executed in the project folder, so don't forget to
cd ./create-app-training
We also want to set yarn as the default package manager that angular CLI will use.
In order to do that, just run:
ng config cli.packageManager yarn
Let's add otter dependencies to the application.
Note: At this step we need only otter public packages, on next stages we will add the private packages. But that's for a bit later.
To be able to run the otter schematics we need to add the angular schematics package as a dependency.
yarn add @schematics/angular
Now simply run yarn ng add @o3r/core@~8.1.0 and answer to the options as below:
yarn ng add @o3r/core@~8.1.0
The package @o3r/core@8.1.1 will be installed and executed.
Would you like to proceed? Yes
✔ Packages successfully installed.
? Activate prefetch builder to generate prefetcher JavaScript file ? No
? Activate playwright as E2E test system ? Yes
? Add Otter customization mechanism to override presenter and injectable for external application No
? Activate Otter analytics ? No
? Add otter packages ('@o3r/{localization,styling,components,configuration}') to make the aplication CMS compatible ? No
? Add Otter styling setup? Yes
? Add Otter configuration setup ? Yes
? Add Otter rules-engine setup ? No
? Add Otter localization setup ? Yes
? Add storybook setup ? No
? Add APIs manager setup providing the SDK via injectable ? Yes
? Set Otter as default ngCLI generator ? Yes
? Generate the Azure Pipeline for the new project? No
? Which testing framework would you like to use? jest
This operation may take a while since it will add a bunch of dependencies to your package.json and install them.
Otter framewrok requires angular material so you can install the package by running yarn ng add @angular/material@15.
yarn ng add @angular/material@15
The package @angular/material@15.2.8 will be installed and executed.
Would you like to proceed? Yes
✔ Packages successfully installed.
? Choose a prebuilt theme name, or "custom" for a custom theme: Indigo/Pink [ Preview: https://material.angular.io?theme=indigo-pink ]
? Set up global Angular Material typography styles? No
Easy as pie, simply run yarn start to launch the dev build of your application.
Then access it at http://localhost:4200.
Explore the project in your IDE, especially the src/app folder where you can see:
app.module.ts with an initial setup including some Otter dependencies such as ApiManagerModule and LocalizationModule. We'll come back a bit later to the ApiManager part.app-routing.module.ts the file that will contain our route definitions. Currently empty, we will come back to it later.app.component.ts the root component of our application, not doing much right now.app.component.html the template of our root component.Positive : Open the style.scss file in the src folder and uncomment the lines related to angular material to apply the default theme.
This file has been generated with some pointer to angular official documentation, obviously we don't want that anymore.
We want the template to only contain:
<router-outlet></router-outlet>
Before diving into pages and routing, let's create a simple header component as a warm up.
Positive : From now on, all ng commands will be executed from yarn, like: yarn ng generate.
We do that to force using the local installation of the ng-cli in this project in case you have a conflicting version installed globally.
Of course, we're not doing this from scratch, let's use Otter's schematics again!
yarn ng generate @o3r/core:component
that will ask some questions. We recommend going for a container + presenter (full) approach since we may want our header to do some smart things in the future, like changing the language of the application or some user log-in.
$ yarn ng generate @o3r/core:component
? Your component name? header
? Specify the structure of the component you want to generate full
? Do you want to generate component fixtures for tests? Yes
? Generate component with Otter configuration? Yes
? Specify your component description Header component
? Skip linter process on generated files? Yes
? Generate component with localization? Yes
? Generate dummy I/O, localization and analytics events No
Optional
To avoid prompting the Skip linter question each time we use the generator, we may want to add the value of skipLinter property directly in angular.json file of our app, under schematics property for each generator entry (component, page, store, service ...).
// angular.json file
"schematics": {
"@o3r/core:component": {
...
"skipLinter": true
},
...
Advanced
The component generator is composed on 2 specific parts: component-container and component-presenter plus a common part.
So if we want to add default values for other questions, we have to identify which part the questions are coming from. To do this we'll have to look in schema.json files associated to each generator (ex: for component - ./node_modules/@o3r/core/schematics/component/schema.json).
For skipLinter it was easy because it is coming from common part.
Then go ahead and add the generated container to the top of the app.component's template.
Negative : Do not forget to import your component's module in app.component's module
Headers usually take the full width, so you could use CSS display: flex to do something nice, but don't spend too much time there.
Once you're satisfied, let's move to create the first page in our application!
Example:
This is the first big item on our TODO list as well as the entry point of our application. Here we'll start to touch a bit the business logic with the help of otter library packages (the private packages).
We will also create a search-form component of type full that will:
AirBoundsSearchStore when it's submittedYou're probably used to it by now, we're going to use a schematic to generate a page:
yarn ng generate @o3r/core:page
? Page name? search
? Page scope (e.g. booking, servicing, ssci, etc.)? booking
? Application routing module path? ./src/app/app-routing.module.ts
? Skip linter process on generated files? Yes
? Generate your page with Otter theming architecture? Yes
? Generate your page with Otter configuration? Yes
? Generate your page with localization? Yes
This will:
src/app/booking/search./search to this new component.Since this will be the entry point to our application, we recommend you to set up this route as the default route in your application routing.
We encourage you to look at the official documentation about how to set up redirects.
You should then be able to access your new page at http://localhost:4200/search.
Negative : You may have to stop and re-launch yarn start in order to access your new page.
You know the drill, generate a search-form component with:
yarn ng generate @o3r/core:component
Import the container's module in your search page's module, and use the component in its template.
The search form presenter is where you will code the Angular form. To keep it simple, we want this component to:
@Output, notify its container when the form has been submitted and with which values.To start simple, let's hardcode our fields for now and focus on the last two points.
What we want to do first is create an interface defining how the container and presenter should talk to each other.
In that case we want an interface we can call SearchData that would contain the 3 properties we want to convey.
Usually we put these kinds of interfaces in a contracts folder at the same level as the container and presenter ones.
For example:
export interface SearchData {
departureAirportCode: string;
destinationAirportCode: string;
departureDate: Date;
}
SearchDataSince this is our first Angular output here's an example of how you can do it:
@Output() submitSearch = new EventEmitter<SearchData>();
We let you figure out the typescript imports.
Since we're using Material we recommend you to use their components as much as possible.
Don't hesitate to look at their documentation, it is very exhaustive: https://v15.material.angular.io/components/categories
Add a button and bind its click event to some function we'll define just after. The result could be something like:
<button mat-button class="submit" (click)="onSubmit()">Submit</button>
Note that this is a native button tag to which we apply a directive from Material.
Negative : Don't forget that you'll have to import the relevant modules in your component, in that case MatButtonModule.
Let's now define the onSubmit function in our component class.
It could be something like:
onSubmit() {
this.submitSearch.emit({
departureAirportCode: 'CDG',
destinationAirportCode: 'LHR',
departureDate: new Date()
});
}
Ok, now that our presenter notifies its container of the submission of the form, we want to do something with it.
Our plan is the following:
AirBoundsSearchStore store with the data we receive from the presenter (you'll need to hardcode some fields)@Output() that the form has been submittedAirBoundSearchStore is the first item linked to the business logic. This is provided by @otter/store package (comming from the inner source library) that you'll need to install. It contains models from Digital Commerce API,therefore @dapi/sdk package should be installed too.
Private packages setup
Negative : To be able to install packages from the private otter library you need to tell yarn from where to get the packages.
Add a .npmrc file to the root of the project with the following content.
Next, run:
yarn add @otter/store@~8.1.0
and
yarn add @dapi/sdk@~2.57.0
submitSearchThe first thing to do is to bind a function to the submitSearch output of our presenter.
You're on your own this time.
Positive : Unlike the previous click event, this time we want to forward the value emitted to our function.
As usual you can find the answer in the Angular documentation
AirBoundsSearchStore store and inject it in our containerThings become interesting here since we want to inject a store from the Otter library to accelerate our development.
This is a three steps process:
AirBoundsSearchStoreModule in your container component's moduleStore instance in the container class's constructorSince it's our first time, here's a code snippet of the constructor and submit function: selectedBoundId air-bound-input to specify a selected outbound flight when looking for the available return flights. As we will only perform one-way search, you can ignore it.
constructor(private store: Store<AirBoundsSearchStore>, ...)
onSubmitSearch(searchData: SearchData) {
this.store.dispatch(addAndSelectAirBoundSearch({
commercialFareFamilies: ['DEMOALL'],
itineraries: [{
originLocationCode: searchData.departureAirportCode,
destinationLocationCode: searchData.destinationAirportCode,
departureDateTime: new utils.DateTime(searchData.departureDate),
isRequestedBound: true
}],
travelers: new Array(searchData.numberOfAdults).fill({
passengerTypeCode: 'ADT'
})
}));
}
To check that everything works, open your Redux DevTools and click the Submit button.
You should see an action that updates the store!
[AirBoundsSearch] add and select air bound search
A simple event that doesn't send any data is enough (EventEmitter).
We will use it later to trigger a navigation from our search page to the next one.
Let's apply two small changes to our app component that will make our current and all future pages look a bit better:
in a div with two classes: mat-card and app-contentapp-content class to make it so it doesn't take the full width and is centeredExample of CSS:
.app-content {
max-width: unquote("min(1200px, 80%)"); // https://github.com/sass/libsass/issues/2701
padding: 15px;
margin: auto;
}
Example of component's template:
<app-header-cont></app-header-cont>
<div class="mat-card app-content">
<router-outlet></router-outlet>
</div>
Positive : You can come back to it later on if you prefer to create a few more pages and manipulate more stores before focusing on the presentation.
However this can be a good opportunity to learn about Angular forms.
What we've built so far works and can be used as a basis to continue our booking flow.
Still, we could make it a lot better without much effort thank to Angular material, so let's take advantage of it to create not-so-bad-looking components.
Here are our objectives:
mat-form-field and mat-input for airport codesmat-datepicker for the dateSearchData using template-driven forms (you can opt for reactive forms if you prefer, but they're a bit more complex)Here's an example of component template, we let you figure out the rest!
<form #form="ngForm">
<mat-form-field class="example-full-width">
<input required matInput placeholder="Departure airport" [(ngModel)]="searchForm.departureAirportCode" name="departure">
</mat-form-field>
<mat-form-field class="example-full-width">
<input required matInput placeholder="Destination airport" [(ngModel)]="searchForm.destinationAirportCode" name="destination">
</mat-form-field>
<mat-form-field>
<input required matInput [matDatepicker]="picker" placeholder="Departure date" [(ngModel)]="searchForm.departureDate" name="date">
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
<button [disabled]="!form.valid" mat-button class="submit" (click)="onSubmit()">Submit</button>
</form>
This template will need several new modules from Angular and Material to be imported:
FormsModule from @angular/forms to handle template-driven forms. ReactiveFormsModule if you opted for the reactive solution.MatFormFieldModule from @angular/material/form-field to have access to the mat-form-field component.MatInputModule from @angular/material/input to have access to the matInput directive.MatDatepickerModule from @angular/material/datepicker to have access to the mat-datepicker components.And don't forget to update the submit function to emit the values of your new form!
Negative : You'll see and error when importing the Material module related to datepicker. To solve it you will have to do what's suggested in the error: import MatNativeDateModule in your application module
Feel free to enhance it as you see fit and add any kind of validation.
Here is an example of result:
When you're ready, move on to the next page!
Now that we got a landing search page, we want to:
The following steps needs to be done in order to be able to access Digital Commerce API endpoints:
apiManagerFactory function, with the following code snippet, in the application's module (src/app/app.module.ts):export function apiManagerFactory(): ApiManager {
const clientFactsPlugin = new ClientFactsRequestPlugin({
initialGlobalFacts: {
corporateCodes: '000001'
}
});
const gatewayPlugin = createAmadeusGatewayTokenRequest({
gatewayUrl: 'https://test.airlines.api.amadeus.com/v1/security/oauth2',
gatewayClientId: 'CQZddrc7RFTBmG6Hbl7pQTyiPtHUuOAq',
gatewayClientPrivate: 'NaeP7dKAgV2emuvi'
});
const apiConfig: ApiClient = new ApiFetchClient(
{
basePath: 'https://test.airlines.api.amadeus.com/v2',
requestPlugins: [
gatewayPlugin,
clientFactsPlugin,
new SessionIdRequest()
]
}
);
return new ApiManager(apiConfig);
}
yarn add @dapi/amadeus-gateway-sdk.createAmadeusGatewayTokenRequest helper from the new installed package. For the other missing imports we let you figure it out.That's it. Now we can use the Digital Commerce API endpoints. Let's continue with the app development.
Note: Here you can find detailed information about how to use the ApiManagerModule to access an endpoint.
As we did before we want first to generate a page and a container/presenter couple to handle our upsell form.
You can name:
upsellupsell-formWe want to go to our new upsell page when we've submitted a search.
To do that we'll have to instruct the Angular router to navigate to upsell from search when something has been submitted.
This is what it looks like:
onSubmit() {
this.router.navigate(['upsell']);
}
Positive : Do not forget to inject Angular's router in your component class constructor: constructor(private router: Router, ...)
Negative : You may have to stop and re-run yarn start again since you added a page.
upsell-form containerThis component's container will be a bit more complex since we want it to:
This time we won't inject the store directly (yet) because what we want to do involves a bit of logic:
AirBound store with that promiseNote that you might need to adapt some models between AirBoundsSearchRequestModel and the airBoundsInput. Indeed the model contains extra properties that will not be used by DxAPI. Don't hesitate to create a new selector to adapt the model to match your needs.
Positive : You want to inject Store in your component, and its AirBoundsSearchStoreModule in your module.
Now as to where to run our logic in the container, remember we want to run it as soon as the component is instantiated, which can be translated in Angular lifecycle terminology as... ngOnInit! Since the search criteria store will never be updated on this page, we will only consider the first non-null value of the search stream. This way the subscription will be completed after this one value. This will prevent performance issues.
Note that your store will return a model with more data than what is expected by DxAPI, you will need to make sure to strip it to the bare minimum.
public ngOnInit() {
this.store.pipe(
select(selectCurrentAirBoundsSearch),
filter((search) => !!search),
take(1)
).subscribe((search) =>
this.callFlexPremium(search!));
}
private callFlexPremium(airBoundsInputs: AirBoundsSearchRequestModel) {
if (airBoundsInputs.travelers?.length && airBoundsInputs.itineraries?.length) {
const call = this.apiFactoryService.getApi(AirBoundApi).airBoundsShopping({airBoundsInputs: {
travelers: airBoundsInputs.travelers,
itineraries: airBoundsInputs.itineraries,
commercialFareFamilies: airBoundsInputs.commercialFareFamilies
}
});
this.store.dispatch(upsertAirBoundEntryFromApi({call}));
}
}
public ngOnDestroy() {
// clean the subscriptions
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
}
That's it!
Now go back to your search page, submit it and look at your network tab + Redux Devtools to see your air-bounds populating the store!
AirBoundGroup listNow we want to have something to send to our presenter, and that something is going to be an array of AirBoundGroup that we have to get from the store.
What we need to do:
AirBoundsPositive : You want to inject Store in your component, and its AirBoundsStoreModule in your module.
How do we select a value from a store?
We will use the ngrx operator select, giving it the appropriate selector function exported by the Otter library.
Here's an example of result:
this.airBounds$ = this.store.pipe(
select(selectCurrentAirBoundGroups)
);
Now where do we run this code?
We probably need it at the time the component is instantiated too.
Positive : TypeScript will complain if you declared airBounds$: Observable, because no initial value is assigned at declaration or in the constructor.
This will be a common usecase in Angular what variables are initialized from ngOnInit, and to fix that there are two possibilities:
upsell-form presenterLet's leave the submission for later and focus on the presenter.
Here's our TODO list:
AirBoundGroup from the containerThat one's straightforward in the presenter.
For the container though, what we have is an observable but what we want to send to the presenter is the latest value emitted at any point in time.
Any idea how we could easily do that?
Positive : Check Angular pipes documentation for something to handle asynchronous situations
So it doesn't look too awful we can play with mat-list, mat-list-item and mat-divider.
For now, simply display the item ID and the departure date of the first flight.
We can come back later with a better approach: creation an bound-details component that takes an AirBoundGroup in input and handles a better display.
Example:
<mat-list>
<ng-container *ngFor="let boundGroup of bounds">
<mat-list-item *ngFor="let bound of boundGroup.airBounds">
<mat-radio-button [value]="bound.airBoundId">
{{bound.airBoundId}} - {{boundGroup.boundDetails.segments[0].flight.departure.dateTime | date:'HH:mm'}} -
{{getBoundTotalPrice(bound) | currency : bound.prices?.totalPrices![0].currencyCode }}
</mat-radio-button>
</mat-list-item>
<mat-divider></mat-divider>
</ng-container>
</mat-list>
public getBoundTotalPrice(bound?: AirBoundItem) {
return bound ? getFormattedTotal(bound.prices!.totalPrices[0]) : 0;
}
As you can see, we iterate over both the bound-groups in the store and each bound-groups' air-bounds. In your page you shall see different items with the same departure date time. They all belong to the same group.
Negative : In this example, the getBoundTotalPrice method is used as input of the template. This is actually a bad practice. When rendering the template of this component, Angular is not able to identify if this method is pure or not and will compute it even if the inputs did not change. This pitfall can be easily avoid with a pure Pipe which will be re-evaluated only if the input changed. As a bonus exercise, try to move the getBoundTotalPriceLogic into a new pipe.
Example:
<mat-list>
<ng-container *ngFor="let boundGroup of bounds">
<mat-list-item *ngFor="let bound of boundGroup.airBounds">
<mat-radio-button [value]="bound.airBoundId">
{{bound.airBoundId}} - {{boundGroup.boundDetails.segments[0].flight.departure.dateTime | date:'HH:mm'}} -
{{bound.prices?.totalPrices![0] | formattedTotal | currency : bound.prices?.totalPrices![0].currencyCode }}
</mat-radio-button>
</mat-list-item>
</ng-container>
</mat-list>
import {Pipe, PipeTransform} from '@angular/core';
import {Price} from '@dapi/sdk';
import {getFormattedTotal} from '@dapi/sdk/helpers';
@Pipe({
name: 'formattedTotal',
pure: true
})
export class FormattedTotalPipe implements PipeTransform {
public transform(value?: Price) {
return value ? getFormattedTotal(value) : 0
}
}
import {NgModule} from '@angular/core';
import {FormattedTotalPipe} from './formatted-total.pipe';
@NgModule({
declarations: [FormattedTotalPipe],
exports: [FormattedTotalPipe]
})
export class FormattedTotalModule {
}
Do not forget to include your new module in your component.
Let's make it simple by using mat-radio-group and mat-radio.
Example:
<form #form="ngForm">
<mat-radio-group required name="selected-offer-id" [(ngModel)]="selectedAirBoundId" (ngModelChange)="onSelectAirBoundItem($event)">
<mat-list>
<ng-container *ngFor="let boundGroup of bounds">
<mat-list-item *ngFor="let bound of boundGroup.airBounds">
<mat-radio-button [value]="bound.airBoundId">
{{bound.airBoundId}} - {{boundGroup.boundDetails.segments[0].flight.departure.dateTime | date:'HH:mm'}} -
{{bound.prices?.totalPrices![0].total | currency : bound.prices?.totalPrices![0].currencyCode}}
</mat-radio-button>
</mat-list-item>
<mat-divider></mat-divider>
</ng-container>
</mat-list>
</mat-radio-group>
<button [disabled]="!form.valid" mat-button class="submit" (click)="onSubmit()">Submit</button>
</form>
Notice the two event binds to onSelectAirBoundItem($event) and onSubmit()? This is because we'd like to update the store every time the user selects an offer to save it, and of course we want to know when the form is submitted to call the API.
This means we'll need two outputs in our presenters: one that emits the airBoundId that was just selected and another that can emit nothing, we'll see why later.
We have two things left to do in our container before calling it a day:
AirBounds store's selected air-bound-item ID on the appropriate presenter eventThat one is quite simple: the store is already injected, we just need to find which action does what we want.
It must have something to do with selecting and AirBound.
Let's do things differently here to play with API services.
Here's what we are going to do:
CartStoreModule, and add the CartStore model to the store instance injected in our component.ApiFactoryService in our component constructor. (no module to import, this service is provided by the ApiManagerModule imported in our AppModule. documentationupsertAndSelectCartEntityFromApisubmit event for the page to handle any routing following the submissionIt may be confusing to know which Action to dispatch or which selector to use depending on the situation.
Usually typing the name of your store and hitting you IDE auto-completion key shows you what's available, but another good solution is to go look at the definition files of the store or service in question.
You can do it by control-clicking their module.
For instance if I click on CartStoreModule in my container module it brings me to: 
Remember the store files structure?
cart.actions.d.ts gives you all the available actions, with commentscart.selectors.d.ts all the available selectors, with commentsIf all went well, you should see your new action dispatched in the Redux Devtools.
Additionally, you should see a new API call in your network tab to create the cart.
Example of those two functions implementation:
public onSelectBound(boundId: string) {
this.store.dispatch(selectAirBoundItem({entryIndex: 0, itemId: boundId}));
}
public onSubmit() {
this.store.pipe(
select(selectSelectedAirBoundIds),
take(1)
).subscribe((selectedAirBoundIds: string[]) => {
this.store.dispatch(upsertAndSelectCartEntityFromApi({
call: this.apiFactoryService.getApi(CartApi).createCart({
postCartBody: {
airBoundIds: selectedAirBoundIds
}
})
}));
this.submit.emit();
});
}
Aren't you tired of having to go through the search screen every time you refresh the page or save a new code change?
Otter stores are exported with meta-reducers that you can use to synchronize them with the browser's local or session storage in order to persist some data.
While it's probably not a good idea to persist offer prices since they can change frequently, search criteria would be a good candidate for that.
The good news is that it's not even that hard!
Everything happens in your application module, you want to find where we define localStorageStates and change it to something like:
const localStorageStates: Record<string, Serializer<any>>[] = [
{[AIR_BOUNDS_SEARCH_STORE_NAME]: airBoundsSearchStorageSync}
];
If you ever want to synchronize another Otter store, simple add a new entry to the localStoragedStates list.
Launch a search, and press F5 from the upsell page.
Wait a bit and you should see offer popping!
We can also do some advance routing to make our application behave even more smoothly.
Guards are function that are executed:
CanActivateCanDeactivateMore info in the official documentation
Our goal would be to add a CanActivate guard to the upsell route in order to:
AirBoundSearchRequestsearch page. Else let the page display.Positive : A guard is an @Injectable that has to be provided in the component's module, as well as being defined in its route definition
Example of Guard (in a new file upsell.guards.ts):
@Injectable()
export class UpsellCanActivate implements CanActivate {
constructor(private store: Store<AirSearchCriteriaStore>, private router: Router) {}
canActivate(): Observable<boolean | UrlTree> {
return this.store.pipe(
select(selectCurrentAirBoundsSearch),
map((search) => !!search || this.router.parseUrl('/search'))
);
}
}
Here's what the upsell.module.ts would look like:
@NgModule({
imports: [
RouterModule.forChild([{
path: '',
component: UpsellComponent,
canActivate: [UpsellCanActivate],
resolve: {queryParam: UpsellResolver}
}]),
CommonModule,
UpsellFormContModule,
AirBoundsSearchStoreModule
],
declarations: [UpsellComponent],
exports: [UpsellComponent],
providers: [UpsellResolver, UpsellCanActivate]
})
export class UpsellModule {
}
While loading air-bounds or creating a cart it's a bit awkward that the user has no feedback that something's happening.
Let's try the following in the app.component:
isLoading$ that is true if any of the AirBoundsStore or CartStore is loading.router-outlet component in a div, and use the value of this observable to hide that div with CSSmat-progress-spinner componentNegative : This time we do not want to import the store modules in our app.component.
Why you may ask? Because by doing so we'd make our main application bundle bigger for no reason other than managing our spinner. We will cope without importing them here, and the only consequence is that the corresponding state will be undefined until this store has been imported by a lazy loaded route.
Hint: Pay attention to specify the following input to mat-progress-spinner or it will be invisible: [mode]="'indeterminate'".
Hint2: You will need to combine two selections of the store.
You may wonder why do we use CSS to hide our page content instead of a simple *ngIf.
The reason is that *ngIf would destroy all the components in our page, execute their ngOnDestroy functions and create them again when the spinner ends, which is not exactly what we want here.
When done, reload your application and perform a search and submit an offer selection.
Not too shabby, isn't it?
app.component.ts
this.isLoading$ = combineLatest([
this.store.pipe(
select(selectAirBoundsState),
map((airBoundState) => {
const currentAirBoundEntry = (airBoundState && airBoundState.entries) ? airBoundState.entries[0] : undefined
return !!currentAirBoundEntry && currentAirBoundEntry.isPending;
}),
),
this.store.pipe(
select(selectCartsState),
map((cartsState) => !!cartsState && !!cartsState.isPending)
)
]).pipe(
map(([airBoundIsPending, cartIsPending]) => airBoundIsPending || cartIsPending),
share()
);
app.component.html
<app-header-cont></app-header-cont>
<div class="app-content mat-card" [ngClass]="{ hidden: (isLoading$ | async) }">
<router-outlet></router-outlet>
</div>
<mat-progress-spinner *ngIf="isLoading$ | async" [mode]="'indeterminate'"></mat-progress-spinner>
We could even include routing event in the isLoading$ computation to make it so that we show a spinner when navigating from a screen to another.
Here's what it could look like:
And remember that we can change the presenter however we want, all the rest is already in place and working!
Yes you probably noticed that we're not going to be innovative here, but we will stick to the usual booking flow.
The next step in our journey will then be to ask the user to enter its passenger and contact information!
In order to simplify our lives a bit, here are our requirements:
How does it translate into a developer's TODO list?
traveler-detailstraveler-details-formTravelerDetail with the data you think meaningfulTravelerDetail corresponding to what's in the store (because you will have empty travelers at first, and may come back to modify them later)TravelerDetail with, send them to the container on form submissionCart store with the relevant API call to modify the travelers in the cart.This time we won't guide you through all the steps, but instead give you a couple of hints and let you figure out the rest.
Don't forget to navigate from the upsell page to the new traveler-details when it's submitted.
The first important thing is to give some thoughts about what data our container/presenter couple will be exchanging.
We want to collect (or update) the title, firstName, lastName and email of our travelers.
Since at that point they already exist in our shopping cart as anonymous travelers, they already have an ID that we need to keep track of.
That's because we need to be able to associate the changes requested by the presenter to a traveler ID in order to call the API.
So here's a proposal of such a contract:
export interface TravelerDetail {
/**
* The ID from DxAPI that we will use to PATCH the traveler
*/
id: string;
title?: string;
firstName?: string;
lastName?: string;
email?: string;
}
Your presenter will receive a TravelerDetail[] as input.
What you'll want to do is iterate on those, and for each of them display the appropriate fields with the appropriate two-way binding.
Here again we recommend template forms for simplicity but feel free to try and implement the same with reactive ones.
Warning: If you use template forms, think twice when binding any [(ngModel)] to your component's @Input() properties. This will mutate the input itself, which kind of breaks angular's contract. More importantly, if the input was taken from the store directly (ex: Traveler[] from your cart), you'd be modifying the current state of your store !
Fortunately for us it's not the case here since we compute our contract in our container, but it's still a good practice to copy the input so that we do not mutate it.
You have two choices here (that you can see in the CartApi of the SDK):
updateCart operation.patchTravelersInCart and patchContactsInCart.Warning: updateCart expects the full list of Traveler and Contact, even those that haven't changed.
This one's a bit more fun.
Provided the TravelerDetail[] emitted by the presenter, you'll need to:
TravelerDetail, copy their corresponding Traveler object from the Cart (because we don't want to mutate them), and patch this copy with the new information from the TravelerDetailTravelerDetail, create an Email (with hardcoded purpose and category) if the TravelerDetail contains an email. This Email should be associated it to a travelerId.The good thing about DxAPI is that you can go back to modify some information.
So to make our logic more robust we could:
Email associated to our traveleId. Otherwise we'd rather update the existing one.As a bonus, we could come back to our search-form components to add a new field to enter a number of travelers.
It could be a simple mat-select with options from 1 to 9, 1 being the default value.
Of course, you'll also need to modify the contract and container's logic to include them in the AirBoundsSearchRequest.
As of now we spin when:
AirBounds store is globally loadingCart store is globally loadingBut we don't when submitting the travel details form because only our Cart entity is set to isPending, not the global state (remember, CartStore is an entity store).
So you can try to add a new condition under which isLoading$ would be true, that is when our current cart is isPending.
Yet another form!
That one is more straightforward though since we're not trying to patch something that exists in the Cart.
Here are our requirements:
payment page displayed once the user has filled the traveler detailsCart into an Order using the card information when the form is submitted.This time we let you figure out your developer's plan by practicing all that you have already done for other pages.
To get a result that can be easily compared with the sample repository though you can go with the following naming:
payment for your pagepayment-form for your container/presenter couple@dapi/sdk instead. (to find it, look what the createOrder function of the OrderApi expects as parameter)Hint: The card vendor can be a mat-select with common vendors such as VI, CA etc...
To test a payment you can use the following card:
number: 4012999999999999
vendor: VI
holder: any
expiry: any
cvv: any
Now that we're dealing with a new store, you can enrich your isLoading$ observable one more time, this time adding the information if the Order store isPending.
As usual here's an example of what your payment page could be like:
That's it!
It's the end of our simplistic booking flow.
This last page will be easy as pie:
For the sake of practicing, let's stick with our page + container/presenter pattern and call them both confirmation.
There are multiple ways to achieve it, but since we haven't played with CanDeactivate guards yet that's a good opportunity to do so.
This kind of guard can decide whether the page allows for a navigation out of it to happen or not.
They have to be @Injectable and to implement the interface CanDeactivate.
They also have to be in the declarations and in the route properties of your component's module.
Here's the solution:
@Injectable()
export class PaymentCanDeactivate implements CanDeactivate<ConfirmationComponent> {
constructor(private store: Store<OrderStore>) {}
canDeactivate(): Observable<boolean> {
return this.store.pipe(
select(selectOrderState),
filter((orderState) => !orderState.isPending),
take(1),
map((orderState) => !orderState.isFailure)
);
}
}
You should be able to figure the rest out by yourself, you can pass the full Order to your presenter and display only its ID if you want (or only pass its ID).
You've done it!
You've coded a functional booking flow from nothing.
Of course, it's far from perfect:
Still, you've implemented some not so basic things and that in a pretty timely manner.
The good thing of using stores and everything is that this application could serve as a skeleton while people work on pages separately without impacting each other.