Introduction
We will be creating the NgRx demo application from scratch using the Firefly Semantics Slice Lightweight Reactive Application State Manager.
The NgRx Demo allows us to search the Google Books API and place books in our own collection.
We will be doing this incrementally in phases describing the Slice state management implementation details as we go along.
Final Stackblitz Demo
To play around with this demo log in with the username test and use any password.
Stackblitz
Go to Stackblitz and start a brand new Angular project.
Delete the hello.component.ts
and remove it from app.module.ts
correspondingly.
Remove the <hello name=”{{ name }}”></hello>
from app.component.html
.
Dependencies
The dependencies for this project are as follows:
@angular/material
@fireflysemantics/slice
@fireflysemantics/material-base-module
@ngneat/until-destroy
@types/nanoid
nanoid
These need to be installed using NPM via the command line in a desktop angular project.
Stackblitz will automatically pull in peer dependencies, so when Slice is declared as a dependency it automatically pulls in @types/nanoid
and naonoid
.
The dependency @ngneat/until-destroy
is used to automatically unsubscribe when a component is destroyed.
Styling and Icons
Update src/styles.css
with:
@import "~@angular/material/prebuilt-themes/indigo-pink.css";
Add the material icons cdn link to src/index.html
:
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
Model
The folder src/app/model
will hold our model types within index.ts
:
export type Book = {
id: string;
volumeInfo: {
title: string;
subtitle: string;
authors: string[];
publisher: string;
publishDate: string;
description: string;
averageRating: number;
ratingsCount: number;
imageLinks: {
thumbnail: string;
smallThumbnail: string;
};
};
};
export type Credentials = {
username: string;
password: string;
};
export type User = {
name: string;
};
The Book
model represents book instances we have retrieved via the Google Books API.
The Credentials
are login credentials.
The User
type represents an authenticated user.
Services
The folder src/app/services
will contain all our services.
Application State Service
The application state service allows us to observe the state of:
- Side navigation (Open or Closed)
- User ( Null if not logged in)
- Authentication Error
These have been modeled in a Firefly Semantics Slice Object Store. Click on this link for an introduction.
import { Injectable } from '@angular/core';
import {
KeyObsValueReset,
ObsValueReset,
OStore,
OStoreStart,
} from '@fireflysemantics/slice';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { User } from '../model';
//===================================
//Create the User Key for local storage
//===================================
export const USER_KEY = 'USER_KEY';
interface ISTART extends KeyObsValueReset {
SIDENAV: ObsValueReset;
USER: ObsValueReset;
AUTHENTICATION_ERROR: ObsValueReset;
}
@Injectable({ providedIn: 'root' })
export class AppStateService {
START: OStoreStart = {
SIDENAV: { value: false, reset: false },
USER: { value: null, reset: null },
AUTHENTICATION_ERROR: { value: false, reset: false },
};
public OS: OStore<ISTART> = new OStore(this.START);
//===================================
//State parameters that we wish to observe
//===================================
public sideNavOpen$: Observable<boolean> = this.OS.S.SIDENAV.obs;
public user$: Observable<User> = this.OS.S.USER.obs;
public isLoggedIn$: Observable<boolean> = this.user$.pipe(map((u) => !!u));
public authenticationError$: Observable<boolean> =
this.OS.S.AUTHENTICATION_ERROR.obs;
constructor() {
const user = JSON.parse(localStorage.getItem(USER_KEY)) || null;
this.OS.put(this.OS.S.USER, user);
}
/**
* If the user is logged out just call
* updateUser() without an argument.
*/
updateUser(user?: User) {
if (user) {
this.OS.put(this.OS.S.USER, user);
localStorage.setItem(USER_KEY, JSON.stringify(user));
} else {
this.OS.put(this.OS.S.USER, null);
localStorage.setItem(USER_KEY, null);
}
}
setAuthenticationError(e: boolean) {
this.OS.put(this.OS.S.AUTHENTICATION_ERROR, e);
}
/**
* Toggle the sidenav
*/
toggleSidenav() {
this.OS.put(this.OS.S.SIDENAV, !this.OS.snapshot(this.OS.S.SIDENAV));
}
}
The interface ISTART
is used to name the reactive state properties and it gives us autocomplete for these values on the store:
interface ISTART extends KeyObsValueReset {
SIDENAV: ObsValueReset;
USER: ObsValueReset;
AUTHENTICATION_ERROR: ObsValueReset;
}
The START
object provides the initial set of values for the reactive state:
START: OStoreStart = {
SIDENAV: { value: false, reset: false },
USER: { value: null, reset: null },
AUTHENTICATION_ERROR: { value: false, reset: false }
};
The Object Store (OStore
) is created like this:
public OS: OStore<ISTART> = new OStore(this.START);
For convenience we will provide typed Observable references within the app-state.service:
//===================================
//State parameters that we wish to observe
//===================================
public sideNavOpen$: Observable<boolean> = this.OS.S.SIDENAV.obs;
public user$: Observable<User> = this.OS.S.USER.obs;
public isLoggedIn$: Observable<boolean> = this.user$.pipe(map((u) => !!u));
public authenticationError$: Observable<boolean> = this.OS.S.AUTHENTICATION_ERROR.obs;
In the constructor we also load the user from local storage:
const user = JSON.parse(localStorage.getItem(USER_KEY)) || null;
this.OS.put(this.OS.S.USER, user);
If the user does not log out this will simulate the user remaining authenticated.
The updateUser
method is called by the login component when the user authenticates:
/**
* If the user is logged out just call
* updateUser() without an argument.
*/
updateUser(user?: User) {
if (user) {
this.OS.put(this.OS.S.USER, user);
localStorage.setItem(USER_KEY, JSON.stringify(user));
} else {
this.OS.put(this.OS.S.USER, null);
localStorage.setItem(USER_KEY, null);
}
}
The setAuthenticationError allows us to indicate when the login does not succeed:
setAuthenticationError(e: boolean) {
this.OS.put(this.OS.S.AUTHENTICATION_ERROR, e);
}
The toggleSidenav method is a utility for setting the sidenav state:
/**
* Toggle the sidenav
*/
toggleSidenav() {
this.OS.put(this.OS.S.SIDENAV,
!this.OS.snapshot(this.OS.S.SIDENAV));
}
Authentication Service
The auth.service.ts
authenticates the user:
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { AppStateService } from './app-state.service';
import { Credentials } from '../model';
export type Creds = { password: string; username: string };
@Injectable({ providedIn: 'root' })
export class AuthService {
constructor(private s: AppStateService, private r: Router) {}
login({ username, password }: Credentials) {
if (username !== 'test') {
this.s.setAuthenticationError(true);
} else {
this.s.setAuthenticationError(false);
this.s.updateUser({ name: username });
this.r.navigate(['/']);
}
}
logout() {
this.s.updateUser();
this.r.navigate(['/login']);
}
}
This service uses the application state service to update the user on login:
this.state.updateUser({name:username});
And clear the user on logout:
this.state.updateUser();
Authentication Guard Service
The auth-guard.service.ts
guards the application from being accessed when the user is not authenticated.
import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { Observable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { AppStateService } from './app-state.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private s: AppStateService, private router: Router) {}
canActivate(): Observable<boolean> {
return this.s.isLoggedIn$.pipe(
switchMap((isLoggedIn) => {
if (!isLoggedIn) {
this.router.navigate(['/login']);
}
return of(isLoggedIn);
})
);
}
}
If the user is not authenticated then the router
navigates to /login
:
return this.appState.isLoggedIn$.pipe(
switchMap((isLoggedIn) => {
if (!isLoggedIn) {
this.router.navigate(['/login']);
}
Book Service
The book.service.ts
contains the Entity Stores for book search results and the book collection.
For an introduction to Entity Stores see Introduction to the Firefly Semantics Slice Reactive Entity Store.
The service also has the methods for performing API queries:
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import {
map,
tap,
filter,
debounceTime,
distinctUntilChanged,
} from 'rxjs/operators';
import { Book } from '../model';
import { EStore } from '@fireflysemantics/slice';
@Injectable()
export class BookService {
private API_PATH = 'https://www.googleapis.com/books/v1/volumes';
public bookStore: EStore<Book> = new EStore();
public bookCollection: EStore<Book> = new EStore();
public books$: Observable<Book[]> = this.bookStore.observe();
public collection$: Observable<Book[]> = this.bookCollection.observe();
constructor(private http: HttpClient) {
this.bookStore
.observeQuery()
.pipe(
filter(Boolean),
debounceTime(200),
distinctUntilChanged(),
tap(async (query: string) => {
const bo: Observable<Book[]> = this.searchAPI(query);
const books: Book[] = await bo.toPromise();
this.bookStore.reset();
this.bookStore.postA(books);
})
)
.subscribe();
}
onSearch(query: string) {
this.bookStore.query = query;
}
searchAPI(query: string): Observable<Book[]> {
return this.http
.get<{ items: Book[] }>(`${this.API_PATH}?q=${query}`)
.pipe(map((books) => books.items || []));
}
getById(volumeId: string): Observable<Book> {
return this.http.get<Book>(`${this.API_PATH}/${volumeId}`);
}
/**
* Toggle the book collection with the `book` inttance.
* @param book The book to remove or add.
*/
toggleCollection(book: Book) {
this.bookCollection.toggle(book);
}
}
The book entity store is created like this:
public bookStore: EStore<Book> = new EStore();
And the entity store for the collection like this:
public bookCollection: EStore<Book> = new EStore();
We observe the books in the book entity store like this:
public books$: Observable<Book[]> = this.bookStore.observe();
The books$
observable is used by the search component to render books found by the Google Books API.
The collection$
is used to observe the book collection:
public collection$: Observable<Book[]> = this.bookCollection.observe();
This observable will be used by the collection component to render the books in our collection.
Listening For and Fetching Book Query Changes
When the user types in a search query we will store the query string on the bookStore.query
property such that we can observe this property and react to changes in its state:
onSearch(query: string) {
this.bookStore.query = query;
}
We implement the observation of this property in the constructor of the BookService
:
constructor(private http: HttpClient) {
this.bookStore.observeQuery().pipe(filter(Boolean),
debounceTime(200),
distinctUntilChanged(),
tap(async (query: string) => {
const bo: Observable<Book[]> = this.searchAPI(query);
const books: Book[] = await bo.toPromise();
this.bookStore.reset();
this.bookStore.postA(books);
})
)
.subscribe();
}
By calling this.bookStore.observeQuery()
we create an observable that fires whenever the user creates keyup
events in the search field.
We debounce
these events so that we react to them at most every 200
milliseconds.
We then search the API for books that match the query, reset the store, and post the new search result to the store.
Posting the new results to the store will cause the books$
observable to emit the new search results and this will then be rendered by the search component.
The other methods in the service are for searching the Google Book API and toggling our book collection with Book instances:
toggleCollection(book:Book) {
this.bookCollection.toggle(book);
}
Book Resolver Service
The book-resolver.service.ts
makes the selected book available to the ViewBookComponent
route.
We wire it up like this:
{
path: ':id',
component: ViewBookComponent,
resolve: { book: BookResolverService }
}
import { Injectable } from '@angular/core';
import { Resolve, ActivatedRouteSnapshot, Router } from '@angular/router';
import { Book } from '../model';
import { BookService } from './book.service';
@Injectable()
export class BookResolverService implements Resolve<Book> {
constructor(private router: Router, private bookService: BookService) {}
async resolve(route: ActivatedRouteSnapshot) {
const id = route.paramMap.get('id');
let book = this.bookService.bookStore.findOneByID(id);
if (!book) {
try {
book = await this.bookService.getById(id).toPromise();
return book;
} catch (err) {
this.router.navigate(['/404']);
return null;
}
}
return book;
}
}
The resolver first tries to lookup the book based on the provided :id parameter:
const id = route.paramMap.get('id');
let book = this.bookService.bookStore.findOneByID(id);
If it can't find the book it looks for it in the API:
book = await this.bookService.getById(id).toPromise();
return book;
If it still can't find it it will navigate to the NotFound
component.
Pipes
Ellipsis Pipe
The src/app/pipes/ellipsis.pipe.ts
abbreviates the title of the book if it is longer than 250 characters..
Commas Pipe
The src/app/pipes/commas.pipe.ts
creates a string with correct grammar from the array of authors.
Components
Authors Presentation Component
The src/app/components/book/authors.component.ts
renders the authors associated with a book.
Book Detail Presentation Component
The src/app/components/book/book-detail.component.ts
renders the detailed display of a Book instance.
Collection Page Component
The src/app/components/book/collection.component.ts
renders the book collection.
The collection rendered by the component is retrieved from the BookService
like this:
this.books$ = this.bookService.
bookCollection.observe().
pipe(untilDestroyed(this));
Preview List Presentation Component
The src/app/components/book/preview-list.component.ts
is used to preview books contained in the user collection and also to display books in the search result listing.
Preview Presentation Component
The src/app/components/book/preview.component.ts
component renders a preview of a Book
instance.
Search Smart Component
The src/app/components/book/search.component.ts
binds the query to the bookStore.query
parameter that we are observing in the BookService
via this method.
search(query:string) {
this.bookService.onSearch(query);
}
The search method is triggered when the user types in the search field.
This is all that is needed to trigger the search. The search will update the books$
observable and then the search results are rendered by this part of the search component template:
<bc-book-preview-list
[books]="bookService.books$ | async">
</bc-book-preview-list>
Also note the template for the search field:
<mat-form-field>
<input matInput
placeholder="Search books"
[value]="bookService.bookStore.query"
(keyup)="search($event.target.value)" autocomplete=off>
</mat-form-field>
We are initializing the value of the field using the query string stored on the bookStore.query
field.
This way if we navigate away from the search component and come back it will remember our last query.
View Book Smart Component
The src/app/components/book/view-book.component.ts
retrieves the book from the route:
this.book = this.route.snapshot.data['book'];
It then clears the bookStore active books:
this.bookService.bookStore.clearActive();
this.bookService.bookStore.addActive(this.book);
this.isSelectedBookInCollection$ = this.bookCollection$.pipe(map(() => this.bookCollection.contains(this.book)));
Then we add the book to the bookStore.active
map of active books.
And finally we initialize the isSelectedBookInCollection$
observable so that we can notify the UI as to whether the currently active book is in the collection or not.
Login Component
The src/app/components/core/login.component.ts
logs the user in via submit:
submit() {
if (this.form.valid) {
this.authService.login(this.form.value);
}
}
If there is an error the authService
will notify the application of it by calling the AppStateService.setAuthenticationError
method causing authenticationError$: Observable<boolean>
to broadcast true:
if (username !== 'test') {
this.state.setAuthenticationError(true);
}
This in turn causes the UI to display the error.
Nav Item Presentation Component
The component src/app/core/nav-item.component.ts
renders navigation items.
Not Found Presentation Component
The src/app/components/core/not-found.component.ts
is routed to when the user enters a path that is not valid, but more specifically it is used if the /books/:id
path tries to access an :id that does not exist. The resolver will route to /404
.
Side Nav Presentation Component
The component src/app/components/core/side-nav.component.ts
renders the side navigation menu.
Toolbar Component
The component src/app/components/core/toolbar.component.ts
renders the side toolbar.
Application Shell
The src/app/app.component.ts
contains our application shell.
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AppStateService } from './services/app-state.service';
import { AuthService } from './services/auth.service';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
constructor(
private router: Router,
public s: AppStateService,
public authService: AuthService
) {}
signIn() {
this.s.toggleSidenav();
this.router.navigate(['login']);
}
logout() {
this.s.toggleSidenav();
this.authService.logout();
this.router.navigate(['login']);
}
}
The file app.component.html
contains the application shell template:
<mat-sidenav-container fullscreen>
<bc-sidenav [open]="s.sideNavOpen$ | async">
<bc-nav-item (navigate)="signIn()" *ngIf="!(s.isLoggedIn$ | async)">
Sign In
</bc-nav-item>
<bc-nav-item
(navigate)="s.toggleSidenav()"
*ngIf="s.isLoggedIn$ | async"
routerLink="/books/collection"
icon="book"
hint="View your book collection"
>
My Collection
</bc-nav-item>
<bc-nav-item
(navigate)="s.toggleSidenav()"
*ngIf="s.isLoggedIn$ | async"
routerLink="/books"
icon="search"
hint="Find your next book!"
>
Browse Books
</bc-nav-item>
<bc-nav-item (navigate)="logout()" *ngIf="s.isLoggedIn$ | async">
Sign Out
</bc-nav-item>
</bc-sidenav>
<bc-toolbar (openMenu)="s.toggleSidenav()"> Book Collection </bc-toolbar>
<router-outlet></router-outlet>
</mat-sidenav-container>
When the user not signed in then the navigation item is shown via this *ngIf
directive:
<bc-nav-item (navigate)="signIn()" *ngIf="!(state.isLoggedIn$ | async)">
Sign In
</bc-nav-item>
Also note that the side navigation is gets the open and close state from the sideNavOpen$
observable:
<bc-sidenav [open]="state.sideNavOpen$ | async">
Modules
We will create a core.module.ts
containing the core components used to boot the application. The book.module.ts
is lazy loaded once the user authenticates and accesses the /books
path.
Have a look at the Stackblitz demo to see the design of the various components.
Material Module
We will be getting all the Angular Material components from the module:
@fireflysemantics/material-base-module
The MaterialBaseModule
needs to be imported into all the application modules, except for the app-routing.module.ts
, since all the other modules use the various Angular Material components.
Book Module
The src/app/modules/book.module.ts
is a lazy loaded module containing all the components used to search for and create a book collection.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Routes, RouterModule } from '@angular/router';
import { HttpClientModule } from '@angular/common/http';
import { MaterialBaseModule } from '@fireflysemantics/material-base-module';
import { PipesModule } from '../pipes';
import { BookAuthorsComponent } from '../components/book/authors.component';
import { BookDetailComponent } from '../components/book/book-detail.component';
import { CollectionComponent } from '../components/book/collection.component';
import { BookPreviewListComponent } from '../components/book/preview-list.component';
import { BookPreviewComponent } from '../components/book/preview.component';
import { BookSearchComponent } from '../components/book/search.component';
import { ViewBookComponent } from '../components/book/view-book.component';
import { BookService } from '../services/book.service';
import { BookResolverService } from '../services/book-resolver.service';
export const routes: Routes = [
{ path: '', component: BookSearchComponent, pathMatch: 'full' },
{ path: 'collection', component: CollectionComponent },
{
path: ':id',
component: ViewBookComponent,
resolve: { book: BookResolverService },
},
];
@NgModule({
imports: [
PipesModule,
HttpClientModule,
MaterialBaseModule,
RouterModule.forChild(routes),
CommonModule,
],
declarations: [
BookSearchComponent,
BookPreviewListComponent,
BookPreviewComponent,
BookAuthorsComponent,
BookDetailComponent,
ViewBookComponent,
CollectionComponent,
],
providers: [BookService, BookResolverService],
})
export class BookModule {}
The module also contains the Routes
for the the various components that the module bundles.
Pipes Module
The module src/app/modules/pipes.module.ts
contains the pipes:
commas.pipe.ts
: Used to format the authors componentellipsis.pipe.ts
: Used to abbreviate content
This module is imported into the books.module.ts
.
Core Module
The core module contains all the custom components used to instantiate the application shell:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { MaterialBaseModule } from '@fireflysemantics/material-base-module';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { LoginComponent } from '../components/core/login.component';
import { NavItemComponent } from '../components/core/nav-item.component';
import { NotFoundComponent } from '../components/core/not-found.component';
import { SideNavComponent } from '../components/core/sidenav.component';
import { ToolbarComponent } from '../components/core/toolbar.component';
const COMPONENTS = [
SideNavComponent,
NavItemComponent,
ToolbarComponent,
LoginComponent,
NotFoundComponent,
];
@NgModule({
imports: [
CommonModule,
RouterModule,
MaterialBaseModule,
FormsModule,
ReactiveFormsModule,
],
declarations: COMPONENTS,
exports: COMPONENTS,
})
export class CoreModule {}
App Routing Module
The app-routing.module.ts
contains our main application routes. The books route handles loading lazy loading of the book.module.ts
.
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from './services/auth-guard.service';
import { LoginComponent } from './components/core/login.component';
const routes: Routes = [
{ path: '', redirectTo: '/books', pathMatch: 'full' },
{
path: 'books',
loadChildren: () =>
import('./modules/book.module').then((m) => m.BookModule),
canActivate: [AuthGuard],
},
{ path: 'login', component: LoginComponent },
];
@NgModule({
imports: [RouterModule.forRoot(routes, {})],
exports: [RouterModule],
})
export class AppRoutingModule {}
App Module
The app.module
imports the core.module
, the app-routing.module
, the Firefly Semantics Material Base Module and the Angular Forms Modules.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { CoreModule } from './modules/core.module';
import { AppRoutingModule } from './app-routing.module';
import { MaterialBaseModule } from '@fireflysemantics/material-base-module';
@NgModule({
imports: [
BrowserModule,
BrowserAnimationsModule,
FormsModule,
ReactiveFormsModule,
CoreModule,
AppRoutingModule,
MaterialBaseModule,
],
declarations: [AppComponent],
bootstrap: [AppComponent],
})
export class AppModule {}
Application Component
Now we just need to ready our application component by dependency injecting the services it needs and declaring a the signIn
and logout
methods:
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AppStateService } from './services/app-state.service';
import { AuthService } from './services/auth.service';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
constructor(
private router: Router,
public s: AppStateService,
public authService: AuthService
) {}
signIn() {
this.s.toggleSidenav();
this.router.navigate(['login']);
}
logout() {
this.s.toggleSidenav();
this.authService.logout();
this.router.navigate(['login']);
}
}
The shell template looks like this:
<mat-sidenav-container fullscreen>
<bc-sidenav [open]="s.sideNavOpen$ | async">
<bc-nav-item (navigate)="signIn()" *ngIf="!(s.isLoggedIn$ | async)">
Sign In
</bc-nav-item>
<bc-nav-item
(navigate)="s.toggleSidenav()"
*ngIf="s.isLoggedIn$ | async"
routerLink="/books/collection"
icon="book"
hint="View your book collection"
>
My Collection
</bc-nav-item>
<bc-nav-item
(navigate)="s.toggleSidenav()"
*ngIf="s.isLoggedIn$ | async"
routerLink="/books"
icon="search"
hint="Find your next book!"
>
Browse Books
</bc-nav-item>
<bc-nav-item (navigate)="logout()" *ngIf="s.isLoggedIn$ | async">
Sign Out
</bc-nav-item>
</bc-sidenav>
<bc-toolbar (openMenu)="s.toggleSidenav()"> Book Collection </bc-toolbar>
<router-outlet></router-outlet>
</mat-sidenav-container>
And that’s it. Our application is ready. We hope you enjoyed this article and if you like Firefly Semantics Slice please star our Github Repository.
Material Icons
Within index.html
we need to add our material icons:
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<my-app>loading</my-app>
Summary
In this guide we reviewed the implementation of the NgRx demo application using the Firefly Semantics Slice Lightweight Reactive State Manager for web and mobile applications.
If you have any comments or suggestion for the guide please add them to the Slice Github Repository as an issue.
We would also love it if you could star the repository.