During this last part of the hands-on, we'll be creating a component named flight-rating, which is composed of two parts:
The goal is to display the rating of a flight, and allow the user to express his opinion and vote by giving a grade between 1 and 5.
Our container should have:
@Input() named flightId,upsertRatingsEntityFromApi to the store, with a call to addRatingToFlight API.The presenter should have:
@Input() of type Rating,@Output() named vote (EventEmitter), used by the container to dispatch the action.Based on the criteria expressed in the previous step, use the generator to create your component by running the following command (still at the root folder: library):
yarn ng generate @o3r/core:component
Cheat Sheet:
? Your component name: flightRating
? Specify the structure of the component you want to generate: full
? Do you want to generate component fixtures for tests? true
? Do you want to generate your component with Otter theming architecture? true
? Generate component with Otter configuration? true
? Specify your component description: My awesome component
? Skip linter process on generated files? false
? Do you want to generate your component with Otter analytics architecture? false
? Generate component with localization? true
? Generate dummy I/O, localization and analytics events? false
Positive : The component generator should have updated the barrel file (index.ts) exporting the newly created component.
(apps/@otter/demo-app/src/demo-components/index.ts)
export * from './flight-rating/index';
export * from './components';
export * from './layouts';
Your component is now ready to be used.
First, we'll integrate the component as-is, without any modification after it has been generated.
As for the store, we want our FlightRatingComponent to be part of the Upsell page and to be displayed at the bottom of upsell row.
Upsell page component is composed of multiple subcomponents. To have the flight rating component in the desired location, we have to identify which subcomponent to modify. Upsell -> Upsell Bound Cont -> Upsell Bound Pres -> Upsell Row Pres -> Flight Details Pres.
Positive : We will modify the template for the FlightDetailsPres component, and append our rating component there.
In the UpsellFlightDetailsPresModule (apps/@otter/demo-app/src/demo-components/components/availability/upsell-premium/sub-components/upsell-flight-details/upsell-flight-details-pres.module.ts), we need to import the component we have created: FlightRatingContModule
...
import { FlightRatingContModule } from '../../../../../flight-rating/index';
...
@NgModule({
imports: [
...
FlightRatingContModule
]
})
In the UpsellFlightDetailsPres template, we have to append our component at the end.
(apps/@otter/demo-app/src/demo-components/components/availability/upsell-premium/sub-components/upsell-flight-details/upsell-flight-details-pres.template.html)
...
<ng-container ...>
...
<ng-container *ngFor="let segment of boundDetails.segments">
<o3r-flight-rating-cont></o3r-flight-rating-cont>
</ng-container>
</ng-container>
At this point, you should have this kind of display: 
Optional We have to update the unit tests of the UpsellFlightDetails pres component as we have added a new component inside it.
(apps/@otter/demo-app/src/demo-components/components/availability/upsell-premium/sub-components/upsell-flight-details/upsell-flight-details-pres.spec.ts)
...
@Component({
selector: 'o3r-flight-rating-cont',
template: '',
inputs: [
'config'
'flightId',
'id'
]
})
class MockFlightRatingContComponent {}
describe('FlightDetailsPresenterComponent', () => {
...
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ..., MockFlightRatingContComponent],
...})
...
});
})
Allright, let's now use the data we have in our store, and display it in our newly created component. Let's recap what we need to do.
Container:
flightId, which we will use to identify for which flight we want to display the rating.RatingStore - We need to access the data we have retrieved from the Rating API.RatingModel) according to the flightId, and forward it to our component's presenter.Presenter:
RatingModel from the container(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.component.ts)
...
import { RatingModel } from '../../../store/ratings/index';
...
export class FlightRatingPresComponent implements /* ... */ {
...
/**
* @inheritdoc
*/
@Input()
public rating: RatingModel | undefined;
...
}
(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.template.html)
<!-- Replace everything ;) -->
<div><span>Rating: {{rating?.rating | number:'1.1-1'}} </span><span># Votes: {{rating?.votes}}</span></div>
(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.context.ts)
...
import { RatingModel } from '../../../store/ratings/index';
/**
* The ContextInput interface describes all the inputs of the component
*/
export interface FlightRatingPresContextInput {
/** Rating input */
rating: RatingModel | undefined;
}
(apps/@otter/demo-app/src/demo-components/flight-rating/container/flight-rating-cont.module.ts)
...
// We need to import the rating store module
import { RatingsStoreModule } from '../../../store/ratings/index';
@NgModule({
// Add the RatingStoreModule to the list of imports
imports: [ CommonModule, FlightRatingPresModule, RatingsStoreModule ],
declarations: [ FlightRatingContComponent ],
exports: [ FlightRatingContComponent ],
providers: [ ]
})
(apps/@otter/demo-app/src/demo-components/flight-rating/container/flight-rating-cont.context.ts)
/**
* The ContextInput interface describes all the inputs of the component
*/
export interface FlightRatingContContextInput {
...
/**
* flightId of the flight for which we want to manage ratings
*/
flightId: string;
}
(apps/@otter/demo-app/src/demo-components/flight-rating/container/flight-rating-cont.component.ts)
...
import { select, Store } from '@ngrx/store';
import { map } from 'rxjs/operators';
import { RatingModel, RatingsStore, selectRatingsEntities } from '../../../store/ratings/index';
...
export class FlightRatingContComponent /* ... */ {
...
/**
* flightId of the flight for which we want to manage ratings
*/
@Input()
public flightId: string;
/**
* Observable with the RatingModel for our flightId
*/
public flightRating$: Observable<RatingModel>;
...
// Inject the RatingsStore
constructor(private store: Store<RatingsStore>,
...
)
ngOnInit() {
// Provides the rating corresponding to our flightId
this.flightRating$ = this.store.pipe(
select(selectRatingsEntities),
map((ratingsMap: { [id: string]: RatingModel }) => ratingsMap[this.flightId])
);
}
...
}
Positive : If you implemented the bonus selector, feel free to use it instead of selectRatingsEntities. It will spare you the map operator.
(apps/@otter/demo-app/src/demo-components/flight-rating/container/flight-rating-cont.template.html)
<o3r-flight-rating-pres
[rating]="flightRating$ | async">
</o3r-flight-rating-pres>
And do not forget to give a flightId as input to your FlightRatingContainerComponent from FlightDetailsPresenterComponent's template as well!
(apps/@otter/demo-app/src/demo-components/components/availability/upsell-premium/sub-components/upsell-flight-details/upsell-flight-details-pres.template.html)
...
<ng-container ...>
...
<ng-container *ngFor="let flightItem of boundDetails.segments">
<o3r-flight-rating-cont
[flightId]="flightItem.flight.marketingAirlineCode + flightItem.flight.marketingFlightNumber">
</o3r-flight-rating-cont>
</ng-container>
</ng-container>
If all went well, you should obtain a display such as this one: 
Are you still with us? We're almost done. We now want to allow our users to give their impression of the flight by casting their vote.
As we could see in the Store hands-on part, the store generator provides many Actions and their corresponding Reducers out-of-the-box. However, according to your API needs, you may need to add some code to handle your specific use-cases.
And it's exactly the case to allow users to vote. In the generated store we have actions which are handling collections, but no actions which are handling only one item from the collection, which is what we need because the user will vote for one flight at once. Let's start:
There is an action called ACTION_UPSERT_ENTITIES_FROM_API. It expects (in our case) an array of Rating (Rating[]) as payload. However, our addRatingToFlight method (from the Flight Rating SDK), that we've seen previously, returns a single Rating.
One solution is to properly enhance the store with:
ACTION_UPSERT_ENTITY_FROM_API, making it clear that only one Rating is returned,ACTION_UPSERT_ENTITIES_FROM_API),(apps/@otter/demo-app/src/store/ratings/ratings.actions.ts)
/** async actions affecting a single entity */
const ACTION_UPSERT_ENTITY_FROM_API = '[Ratings] upsert entity from api';
...
/**
* Action to put store into pending state and call upsertRatingsEntities action when it resolves. If call fails, dispatch failRatingsEntities action.
* Pending state is updated after the resolution of the call
*/
export const upsertRatingsEntityFromApi = createAction(ACTION_UPSERT_ENTITY_FROM_API, asyncProps<FromApiActionPayload<Rating>>());
(apps/@otter/demo-app/src/store/ratings/ratings.reducer.ts)
export const ratingsReducerFeatures: On<RatingsState>[] = [
...
on(actions.upsertRatingsEntityFromApi, (state, payload) => asyncStoreItemAdapter.addRequest(state, payload.requestId))
];
(apps/@otter/demo-app/src/store/ratings/ratings.effect.ts)
...
import {
...
upsertRatingsEntityFromApi
} from './ratings.actions';
...
/**
* Upsert the entity with the reply content, dispatch failRatingsEntities if it catches a failure
*/
public upsertEntityFromApi$ = createEffect(()=>
this.actions$.pipe(
ofType(upsertRatingsEntityFromApi),
mergeMap((payload) =>
from(payload.call).pipe(
map((reply) => upsertRatingsEntities({entities: [reply], requestId: payload.requestId})),
catchError((err) => of(failRatingsEntities({error: err, requestId: payload.requestId})))
)
)
)
);
...
We'll need to do the following enhancements for our generated component:
Container:
addRatingToFlight) with the grade received from the presenterPresenter:
(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.component.ts)
...
import {
...
EventEmitter,
Output } from '@angular/core';
...
export class FlightRatingPresComponent /* ... */ {
/**
* @inheritdoc
*/
@Output()
public vote: EventEmitter<number> = new EventEmitter<number>();
...
/**
* VoteAction - Called from the template
* We need to prevent the event to bubble up so it won't expand the display.
*
* @param vote
* @param e
*/
public voteAction(vote: number, e: Event) {
e.preventDefault();
e.stopPropagation();
this.vote.emit(vote);
}
...
}
(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.template.html)
<!-- Replace all -->
<div><span>Rating: {{rating?.rating | number:'1.1-1'}} </span><span># Votes: {{rating?.votes}}</span></div>
<div class="rating">
<ng-container *ngFor="let i of [5, 4, 3, 2, 1]">
<button (click)="voteAction(i, $event)" class="icon-star">{{i}}</button>
</ng-container>
</div>
(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.context.ts)
...
/**
* The ContextOutput interface describes all the outputs of the component
*/
export interface FlightRatingPresContextOutput {
/** Vote output to bubble up to container */
vote: number;
}
...
(apps/@otter/demo-app/src/demo-components/flight-rating/container/flight-rating-cont.component.ts)
...
import { ApiFactoryService } from '@o3r/apis-manager';
import { upsertRatingsEntityFromApi } from '../../../store/ratings/index';
...
export class FlightRatingContComponent /* ... */ {
...
private ratingApi: RatingApi;
constructor(
private store: Store<RatingsStore>,
private apiFactoryService: ApiFactoryService, /* Add the ratingApiFactoryService */
@Optional() configurationService?: ConfigurationBaseService
) {
...
this.ratingApi = this.apiFactoryService.getApi(RatingApi);
}
...
/**
* Receives a rating request from the presenter
* @param _$event
*/
public handleVote(grade: number) {
this.store.dispatch(
upsertRatingsEntityFromApi({
call: this.ratingApi.addRatingToFlight(
{flightId: this.flightId, ratingBody: {rating: grade}}
)
})
);
}
}
(apps/@otter/demo-app/src/demo-components/flight-rating/container/flight-rating-cont.template.html)
<o3r-flight-rating-pres
[rating]="flightRating$ | async"
(vote)="handleVote($event)">
</o3r-flight-rating-pres>
At this point, your component should be fully functional, with the ability to rate flights: 
Now that everything works - just for fun - we can enhance the visuals of our component. We're still far from being artists, but if you include the following SCSS files, it will look better. Feel free to come-up with your own styling!
(apps/@otter/demo-app/src/demo-components/components/availability/upsell-premium/sub-components/upsell-flight-details/upsell-flight-details-pres.template.html)
...
<o3r-flight-rating-cont
...
class="flight-rating">
</o3r-flight-rating-cont>
...
(apps/@otter/demo-app/src/demo-components/components/availability/upsell-premium/sub-components/upsell-flight-details/upsell-flight-details-pres.style.scss)
.flight-rating {
width: 100%;
}
(packages/@otter/demo-components/src/flight-rating/presenter/flight-rating-pres.style.scss)
@import './flight-rating-pres.style.theme';
o3r-flight-rating-pres {
text-align: right;
width: 100%;
div {
margin-right: .5rem;
}
// Your custom SCSS
.rating {
direction: rtl;
}
button {
display: inline-block;
position: relative;
color: $star-color;
cursor: pointer;
}
.rating > button:hover,
.rating > button:hover ~ button {
color: $star-highlight-color;
}
}
(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.style.theme.scss)
@use '@o3r/styling' as o3r;
$palette: o3r.get-mandatory(o3r.$default-theme, 'highlight');
$palette-primary: o3r.get-mandatory(o3r.$default-theme, 'primary');
$flight-rating-pres-palette-warn: o3r.get-mandatory(o3r.$default-theme, 'warn');
$star-color: o3r.variable('star-color', o3r.color($palette-primary, 200));
$star-highlight-color: o3r.variable('star-highlight-color', o3r.color($flight-rating-pres-palette-warn, 600));
And this is the final result, with the styles provided:

Don't hesitate to check the Documentation about the localization in Otter.
Let's now have a look at what has been generated by default for the component in the localization file (apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.localization.json):
{}
Let's add 2 keys:
o3r-flight-rating-pres.rating-label to represent the label preceding the current rating of a flighto3r-flight-rating-pres.number-of-votes-label to represent the label preceding the total number of votes for a flightYour updated localization file should look like:
{
"o3r-flight-rating-pres.rating-label": {
"description": "Label preceding the current rating of a flight",
"defaultValue": "Rating:"
},
"o3r-flight-rating-pres.number-of-votes-label": {
"description": "Label preceding the total number of votes for a flight",
"defaultValue": "Votes:"
}
}
Now let's update the translation file with 2 new translation keys: (apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.translation.ts)
import {Translation} from '@o3r/core';
//This interface enables the type checking inside the presenter
export interface FlightRatingPresTranslation extends Translation {
ratingLabel: string;
numberOfVotesLabel: string;
}
//This map is the link between the properties that will be used in your html template, and the translation keys from
//the localization bundle
export const translations: FlightRatingPresTranslation = {
ratingLabel: 'o3r-flight-rating-pres.rating-label',
numberOfVotesLabel: 'o3r-flight-rating-pres.number-of-votes-label'
};
IMPORTANT: The translations map in the flight-rating-pres.localization.json file is considered as the DEFAULT localization for the FlightRatingPres component. There are different ways to compute the localization bundle in your application:
--no-localization option: This option takes the component.localization.json files and merge them in a bundle that will be used as localization bundle for the application, then performs a noDuplicates check on keys (configurable with checkKeys option), that will fail the build if duplicated keys are foundAll this localization processing is performed at BUILD time thanks to the localization plugin/loader that you can find in the builders directory from the @o3r/localization part of the Otter library.
Let's add them to the template now!
The goal for this part is to:
rating is defined (use a ), display in 2 span the translation for the ratingLabel, and the translation for the numberOfVotesLabel with the associated valuesrating is not defined, displays "No rating yet" (Use )PS: Here we will use the –no-localization option
Final result:
<-- -->
<ng-container *ngIf="rating; else noRating">
<span class="mr-2">{{translations.ratingLabel | translate}} {{rating.rating}}</span>
<span class="mr-2">{{translations.numberOfVotesLabel | translate}} {{rating.votes}}</span>
</ng-container>
<ng-template #noRating>
<strong class="mr-2">No rating yet.</strong>
</ng-template>
...
Now you should be able to see your translations !
As refresher, don't hesitate to check the Documentation about the fixtures in Otter.
To sum up, the fixtures are classes which contain mainly accessors to the DOM of the components. Each component has its own fixture class. The purpose of the fixtures is to help for debugging, testing the component itself, and integration with parents and child components.
To retrieve the score and number of votes for a specific flight you'll have to create fixtures for FlightRatingPres component.
As mentioned above the fixtures are accessors for DOM elements, so to ease a bit the task, add html classes on rating and votes parts of the component.
Your .html should look like:
(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.template.html)
...
<ng-container *ngIf="rating; else noRating">
<span class="mr-2">{{translations.ratingLabel | translate}}
<span class="rating-score">{{rating.rating.toFixed(2)}}</span>
</span>
<span class="mr-2">{{translations.numberOfVotesLabel | translate}}
<span class="rating-votes">{{rating.votes}}</span>
</span>
</ng-container>
...
The generator is creating the skeleton for fixtures in .fixture.ts file of the component.
Now that we have the ids for the targeted html elements, add the fixtures to get the values (texts) of these elements
(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.fixture.ts)
...
export interface FlightRatingPresFixture extends ComponentFixtureProfile {
getRating(): Promise<string | undefined>;
getNumberOfVotes(): Promise<string | undefined>;
}
export class FlightRatingPresFixtureComponent extends O3rComponentFixture implements FlightRatingPresFixture {
private readonly RATING_SCORE = '.rating-score';
private readonly RATING_VOTES = '.rating-votes';
/**
* Get rating score.
*/
public async getRating() {
const el = await this.queryAll(this.RATING_SCORE);
return this.throwOnUndefined(el[0]).getText();
}
/**
* Get number of votes for rating.
*/
public async getNumberOfVotes() {
const el = await this.queryAll(this.RATING_VOTES);
return this.throwOnUndefined(el[0]).getText();
}
}
And that's it.
You have created 2 fixtures for the component. They can be integrated with fixtures from the parent components, for instance, if we want to get the values for a specific flight in an upsell bound.
Or, the fixtures can be used to unit test your component.
P.S. See next chapter
The generator has already created a basic unit test for our component.
Enhance it to create unit test for the display of the rating and for the number of votes.
You should have something like:
(apps/@otter/demo-app/src/demo-components/flight-rating/presenter/flight-rating-pres.spec.ts)
import {ChangeDetectionStrategy, Provider} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {BrowserModule} from '@angular/platform-browser';
import {LocalizationService} from '@o3r/localization';
import {O3rElement} from '@o3r/testing/core';
import {mockTranslationModules} from '@o3r/testing/localization';
import {TranslateCompiler, TranslateFakeCompiler} from '@ngx-translate/core';
import {FlightRatingPresComponent} from './flight-rating-pres.component';
import {FlightRatingPresFixtureComponent} from './flight-rating-pres.fixture';
let component: FlightRatingPresComponent;
let componentFixture: FlightRatingPresFixtureComponent;
let fixture: ComponentFixture<FlightRatingPresComponent>;
const localizationConfiguration = {language: 'en'};
const mockTranslations = {
en: {}
};
const mockTranslationsCompilerProvider: Provider = {
provide: TranslateCompiler,
useClass: TranslateFakeCompiler
};
describe('FlightRatingPresComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [FlightRatingPresComponent],
imports: [
BrowserModule,
...mockTranslationModules(localizationConfiguration, mockTranslations, mockTranslationsCompilerProvider)
]
}).overrideComponent(FlightRatingPresComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
const localizationService = TestBed.inject(LocalizationService);
localizationService.configure();
});
beforeEach(() => {
fixture = TestBed.createComponent(FlightRatingPresComponent);
component = fixture.componentInstance;
component.rating = {flightId: 'ABCD', rating: 4.6, votes: 100, requestIds: ['dummy']};
componentFixture = new FlightRatingPresFixtureComponent(new O3rElement(fixture.debugElement));
});
it('should define objects', () => {
fixture.detectChanges();
expect(component).toBeDefined();
expect(componentFixture).toBeDefined();
});
it('should return rating score using fixtures', async () => {
fixture.detectChanges();
const rating = await componentFixture.getRating();
expect(rating).toBe('4.6');
});
it('should return number of votes using fixtures', async () => {
fixture.detectChanges();
const votes = await componentFixture.getNumberOfVotes();
expect(votes).toBe('100');
});
it('should not find the rating when no votes for a flight', async () => {
component.rating = undefined;
fixture.detectChanges();
let notFoundRatingElement;
const rating = await componentFixture.getRating().catch((notFoundError) => {
notFoundRatingElement = notFoundError;
});
expect(notFoundRatingElement).toBeDefined();
expect(rating).not.toBeDefined();
});
});
Note When adding the unit test for rating equals undefined, you will see that you'll have a compilation error, saying that component.rating cannot be undefined. Of course the rating can be undefined, for the flights with no votes (we have an ngIf in the template to check that).
So we have discovered a small bug related to types, leading to the fact that testing is not doubting.
Update the component class with undefined type for rating input. Don't forget to update the .context file too.
@Input()
public rating: RatingModel | undefined;
Just for comparison, add the unit test in the classical way, using the DebugElement from angular.
it('should return rating score using html element', () => {
fixture.detectChanges();
const rating = fixture.debugElement.queryAll(By.css('.rating-score'))[0].nativeElement.textContent;
expect(rating).toBe('4.55');
});
it('should return number of votes using html element', () => {
fixture.detectChanges();
const votes = fixture.debugElement.queryAll(By.css('.rating-votes'))[0].nativeElement.textContent;
expect(votes).toBe('100');
});
Note With this method you are using the hardcoded html element classes in the unit test, so if you change the class in the component, you will have to change the unit test too. With the fixtures everything is handled inside them. The same thing will happen for e2e tests.
Before running the tests, you can force to run only your new added unit tests, using describe.only:
...
describe.only('FlightRatingPresComponent', () => {
...
To run the unit test, you will have to go at the root of the Otter library and run:
yarn nx run otter-demo-app:test
You can run them too in the VSCode, if you installed all the recomended plugins (including the ones for Jest).
The solution to all the exercises including bonuses is available in branch training/solutions so to check it out you can:
git checkout training/solutions