Skip to content

Latest commit

 

History

History
1418 lines (1119 loc) · 41.9 KB

File metadata and controls

1418 lines (1119 loc) · 41.9 KB

ASP.NET Core {{UI_Value}} Tutorial - Part 2

//[doc-params]
{
    "UI": ["MVC","NG"]
}

{{ if UI == "MVC" DB="ef" DB_Text="Entity Framework Core" UI_Text="mvc" else if UI == "NG" DB="mongodb" DB_Text="MongoDB" UI_Text="angular" else DB ="?" UI_Text="?" end }}

About this tutorial

This is the second part of the ASP.NET Core {{UI_Value}} tutorial series. All parts:

You can also watch this video course prepared by an ABP community member, based on this tutorial.

{{if UI == "MVC"}}

Creating a new book

In this section, you will learn how to create a new modal dialog form to create a new book. The modal dialog will look like in the below image:

bookstore-create-dialog

Create the modal form

Create a new razor page, named CreateModal.cshtml under the Pages/Books folder of the Acme.BookStore.Web project.

bookstore-add-create-dialog

CreateModal.cshtml.cs

Open the CreateModal.cshtml.cs file (CreateModalModel class) and replace with the following code:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;

namespace Acme.BookStore.Web.Pages.Books
{
    public class CreateModalModel : BookStorePageModel
    {
        [BindProperty]
        public CreateUpdateBookDto Book { get; set; }

        private readonly IBookAppService _bookAppService;

        public CreateModalModel(IBookAppService bookAppService)
        {
            _bookAppService = bookAppService;
        }

        public async Task<IActionResult> OnPostAsync()
        {
            await _bookAppService.CreateAsync(Book);
            return NoContent();
        }
    }
}
  • This class is derived from the BookStorePageModel instead of standard PageModel. BookStorePageModel inherits the PageModel and adds some common properties & methods that can be used in your page model classes.
  • [BindProperty] attribute on the Book property binds post request data to this property.
  • This class simply injects the IBookAppService in the constructor and calls the CreateAsync method in the OnPostAsync handler.
CreateModal.cshtml

Open the CreateModal.cshtml file and paste the code below:

@page
@inherits Acme.BookStore.Web.Pages.BookStorePage
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model Acme.BookStore.Web.Pages.Books.CreateModalModel
@{
    Layout = null;
}
<abp-dynamic-form abp-model="Book" data-ajaxForm="true" asp-page="/Books/CreateModal">
    <abp-modal>
        <abp-modal-header title="@L["NewBook"].Value"></abp-modal-header>
        <abp-modal-body>
            <abp-form-content />
        </abp-modal-body>
        <abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
    </abp-modal>
</abp-dynamic-form>
  • This modal uses abp-dynamic-form tag helper to automatically create the form from the model CreateBookViewModel.
    • abp-model attribute indicates the model object where it's the Book property in this case.
    • data-ajaxForm attribute sets the form to submit via AJAX, instead of a classic page post.
    • abp-form-content tag helper is a placeholder to render the form controls (it is optional and needed only if you have added some other content in the abp-dynamic-form tag, just like in this page).

Add the "New book" button

Open the Pages/Books/Index.cshtml and set the content of abp-card-header tag as below:

<abp-card-header>
    <abp-row>
        <abp-column size-md="_6">
            <h2>@L["Books"]</h2>
        </abp-column>
        <abp-column size-md="_6" class="text-right">
            <abp-button id="NewBookButton"
                        text="@L["NewBook"].Value"
                        icon="plus"
                        button-type="Primary" />
        </abp-column>
    </abp-row>
</abp-card-header>

This adds a new button called New book to the top-right of the table:

bookstore-new-book-button

Open the pages/books/index.js and add the following code just after the Datatable configuration:

var createModal = new abp.ModalManager(abp.appPath + 'Books/CreateModal');

createModal.onResult(function () {
    dataTable.ajax.reload();
});

$('#NewBookButton').click(function (e) {
    e.preventDefault();
    createModal.open();
});
  • abp.ModalManager is a helper class to manage modals in the client side. It internally uses Twitter Bootstrap's standard modal, but abstracts many details by providing a simple API.

Now, you can run the application and add new books using the new modal form.

Updating a book

Create a new razor page, named EditModal.cshtml under the Pages/Books folder of the Acme.BookStore.Web project:

bookstore-add-edit-dialog

EditModal.cshtml.cs

Open the EditModal.cshtml.cs file (EditModalModel class) and replace with the following code:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;

namespace Acme.BookStore.Web.Pages.Books
{
    public class EditModalModel : BookStorePageModel
    {
        [HiddenInput]
        [BindProperty(SupportsGet = true)]
        public Guid Id { get; set; }

        [BindProperty]
        public CreateUpdateBookDto Book { get; set; }

        private readonly IBookAppService _bookAppService;

        public EditModalModel(IBookAppService bookAppService)
        {
            _bookAppService = bookAppService;
        }

        public async Task OnGetAsync()
        {
            var bookDto = await _bookAppService.GetAsync(Id);
            Book = ObjectMapper.Map<BookDto, CreateUpdateBookDto>(bookDto);
        }

        public async Task<IActionResult> OnPostAsync()
        {
            await _bookAppService.UpdateAsync(Id, Book);
            return NoContent();
        }
    }
}
  • [HiddenInput] and [BindProperty] are standard ASP.NET Core MVC attributes. SupportsGet is used to be able to get Id value from query string parameter of the request.
  • In the GetAsync method, we get BookDto from BookAppService and this is being mapped to the DTO object CreateUpdateBookDto.
  • The OnPostAsync uses BookAppService.UpdateAsync() to update the entity.

Mapping from BookDto to CreateUpdateBookDto

To be able to map the BookDto to CreateUpdateBookDto, configure a new mapping. To do this, open the BookStoreWebAutoMapperProfile.cs in the Acme.BookStore.Web project and change it as shown below:

using AutoMapper;

namespace Acme.BookStore.Web
{
    public class BookStoreWebAutoMapperProfile : Profile
    {
        public BookStoreWebAutoMapperProfile()
        {
            CreateMap<BookDto, CreateUpdateBookDto>();
        }
    }
}
  • We have just added CreateMap<BookDto, CreateUpdateBookDto>(); to define this mapping.

EditModal.cshtml

Replace EditModal.cshtml content with the following content:

@page
@inherits Acme.BookStore.Web.Pages.BookStorePage
@using Acme.BookStore.Web.Pages.Books
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model EditModalModel
@{
    Layout = null;
}
<abp-dynamic-form abp-model="Book" data-ajaxForm="true" asp-page="/Books/EditModal">
    <abp-modal>
        <abp-modal-header title="@L["Update"].Value"></abp-modal-header>
        <abp-modal-body>
            <abp-input asp-for="Id" />
            <abp-form-content />
        </abp-modal-body>
        <abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
    </abp-modal>
</abp-dynamic-form>

This page is very similar to the CreateModal.cshtml, except:

  • It includes an abp-input for the Id property to store Id of the editing book (which is a hidden input).
  • It uses Books/EditModal as the post URL and Update text as the modal header.

Add "Actions" dropdown to the table

We will add a dropdown button to the table named Actions.

Open the Pages/Books/Index.cshtml page and change the <abp-table> section as shown below:

<abp-table striped-rows="true" id="BooksTable">
    <thead>
        <tr>
            <th>@L["Actions"]</th>
            <th>@L["Name"]</th>
            <th>@L["Type"]</th>
            <th>@L["PublishDate"]</th>
            <th>@L["Price"]</th>
            <th>@L["CreationTime"]</th>
        </tr>
    </thead>
</abp-table>
  • We just added a new th tag for the "Actions" button.

Open the pages/books/index.js and replace the content as below:

$(function () {

    var l = abp.localization.getResource('BookStore');

    var createModal = new abp.ModalManager(abp.appPath + 'Books/CreateModal');
    var editModal = new abp.ModalManager(abp.appPath + 'Books/EditModal');

    var dataTable = $('#BooksTable').DataTable(abp.libs.datatables.normalizeConfiguration({
        processing: true,
        serverSide: true,
        paging: true,
        searching: false,
        autoWidth: false,
        scrollCollapse: true,
        order: [[1, "asc"]],
        ajax: abp.libs.datatables.createAjax(acme.bookStore.book.getList),
        columnDefs: [
            {
                rowAction: {
                    items:
                        [
                            {
                                text: l('Edit'),
                                action: function (data) {
                                    editModal.open({ id: data.record.id });
                                }
                            }
                        ]
                }
            },
            { data: "name" },
            { data: "type" },
            { data: "publishDate" },
            { data: "price" },
            { data: "creationTime" }
        ]
    }));

    createModal.onResult(function () {
        dataTable.ajax.reload();
    });

    editModal.onResult(function () {
        dataTable.ajax.reload();
    });

    $('#NewBookButton').click(function (e) {
        e.preventDefault();
        createModal.open();
    });
});
  • Used abp.localization.getResource('BookStore') to be able to use the same localization texts defined on the server-side.
  • Added a new ModalManager named createModal to open the create modal dialog.
  • Added a new ModalManager named editModal to open the edit modal dialog.
  • Added a new column at the beginning of the columnDefs section. This column is used for the "Actions" dropdown button.
  • "New Book" action simply calls createModal.open() to open the create dialog.
  • "Edit" action simply calls editModal.open() to open the edit dialog.

You can run the application and edit any book by selecting the edit action. The final UI looks as below:

bookstore-books-table-actions

Deleting a book

Open the pages/books/index.js and add a new item to the rowAction items:

{
    text: l('Delete'),
    confirmMessage: function (data) {
        return l('BookDeletionConfirmationMessage', data.record.name);
    },
    action: function (data) {
        acme.bookStore.book
            .delete(data.record.id)
            .then(function() {
                abp.notify.info(l('SuccessfullyDeleted'));
                dataTable.ajax.reload();
            });
    }
}
  • confirmMessage option is used to ask a confirmation question before executing the action.
  • acme.bookStore.book.delete() method makes an AJAX request to JavaScript proxy function to delete a book.
  • abp.notify.info() shows a notification after the delete operation.

The final index.js content is shown below:

$(function () {

    var l = abp.localization.getResource('BookStore');

    var createModal = new abp.ModalManager(abp.appPath + 'Books/CreateModal');
    var editModal = new abp.ModalManager(abp.appPath + 'Books/EditModal');

    var dataTable = $('#BooksTable').DataTable(abp.libs.datatables.normalizeConfiguration({
        processing: true,
        serverSide: true,
        paging: true,
        searching: false,
        autoWidth: false,
        scrollCollapse: true,
        order: [[1, "asc"]],
        ajax: abp.libs.datatables.createAjax(acme.bookStore.book.getList),
        columnDefs: [
            {
                rowAction: {
                    items:
                    [
                        {
                            text: l('Edit'),
                            action: function (data) {
                                editModal.open({ id: data.record.id });
                            }
                        },
                        {
                            text: l('Delete'),
                            confirmMessage: function (data) {
                                return l('BookDeletionConfirmationMessage', data.record.name);
                            },
                            action: function (data) {
                                acme.bookStore.book
                                    .delete(data.record.id)
                                    .then(function() {
                                        abp.notify.info(l('SuccessfullyDeleted'));
                                        dataTable.ajax.reload();
                                    });
                            }
                        }
                    ]
                }
            },
            { data: "name" },
            { data: "type" },
            { data: "publishDate" },
            { data: "price" },
            { data: "creationTime" }
        ]
    }));

    createModal.onResult(function () {
        dataTable.ajax.reload();
    });

    editModal.onResult(function () {
        dataTable.ajax.reload();
    });

    $('#NewBookButton').click(function (e) {
        e.preventDefault();
        createModal.open();
    });
});

Open the en.json in the Acme.BookStore.Domain.Shared project and add the following translations:

"BookDeletionConfirmationMessage": "Are you sure to delete the book {0}?",
"SuccessfullyDeleted": "Successfully deleted"

Run the application and try to delete a book.

{{end}}

{{if UI == "NG"}}

Creating a new book

In this section, you will learn how to create a new modal dialog form to create a new book.

Type definition

Open books.ts file in app\store\models folder and replace the content as below:

export namespace Books {
  export interface State {
    books: Response;
  }

  export interface Response {
    items: Book[];
    totalCount: number;
  }

  export interface Book {
    name: string;
    type: BookType;
    publishDate: string;
    price: number;
    lastModificationTime: string;
    lastModifierId: string;
    creationTime: string;
    creatorId: string;
    id: string;
  }

  export enum BookType {
    Undefined,
    Adventure,
    Biography,
    Dystopia,
    Fantastic,
    Horror,
    Science,
    ScienceFiction,
    Poetry,
  }

  //<== added CreateUpdateBookInput interface ==>
  export interface CreateUpdateBookInput {
    name: string;
    type: BookType;
    publishDate: string;
    price: number;
  }
}
  • We added CreateUpdateBookInput interface.
  • You can see the properties of this interface from Swagger UI.
  • The CreateUpdateBookInput interface matches with the CreateUpdateBookDto in the backend.

Service method

Open the books.service.ts file in app\books\shared folder and replace the content as below:

import { Injectable } from '@angular/core';
import { RestService } from '@abp/ng.core';
import { Books } from '../../store/models';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class BooksService {
  constructor(private restService: RestService) {}

  get(): Observable<Books.Response> {
    return this.restService.request<void, Books.Response>({
      method: 'GET',
      url: '/api/app/book'
    });
  }

  //<== added create method ==>
  create(createBookInput: Books.CreateUpdateBookInput): Observable<Books.Book> {
    return this.restService.request<Books.CreateUpdateBookInput, Books.Book>({
      method: 'POST',
      url: '/api/app/book',
      body: createBookInput
    });
  }
}
  • We added the create method to perform an HTTP Post request to the server.
  • restService.request function gets generic parameters for the types sent to and received from the server. This example sends a CreateUpdateBookInput object and receives a Book object (you can set void for request or return type if not used).

State definitions

Open books.action.ts in app\store\actions folder and replace the content as below:

import { Books } from '../models'; //<== added this line ==>

export class GetBooks {
  static readonly type = '[Books] Get';
}

//added CreateUpdateBook class
export class CreateUpdateBook {
  static readonly type = '[Books] Create Update Book';
  constructor(public payload: Books.CreateUpdateBookInput) { }
}
  • We imported the Books namespace and created the CreateUpdateBook action.

Open books.state.ts file in app\store\states and replace the content as below:

import { State, Action, StateContext, Selector } from '@ngxs/store';
import { GetBooks, CreateUpdateBook } from '../actions/books.actions'; //<== added CreateUpdateBook==>
import { Books } from '../models/books';
import { BooksService } from '../../books/shared/books.service';
import { tap } from 'rxjs/operators';

@State<Books.State>({
  name: 'BooksState',
  defaults: { books: {} } as Books.State,
})
export class BooksState {
  @Selector()
  static getBooks(state: Books.State) {
    return state.books.items || [];
  }

  constructor(private booksService: BooksService) { }

  @Action(GetBooks)
  get(ctx: StateContext<Books.State>) {
    return this.booksService.get().pipe(
      tap(booksResponse => {
        ctx.patchState({
          books: booksResponse,
        });
      }),
    );
  }

  //added CreateUpdateBook action listener
  @Action(CreateUpdateBook)
  save(ctx: StateContext<Books.State>, action: CreateUpdateBook) {
    return this.booksService.create(action.payload);
  }
}
  • We imported CreateUpdateBook action and defined the save method that will listen to a CreateUpdateBook action to create a book.

When the SaveBook action dispatched, the save method is being executed. It calls create method of the BooksService.

Add a modal to BookListComponent

Open book-list.component.html file in books\book-list folder and replace the content as below:

<div class="row entry-row">
    <div class="col-auto">
        <h1 class="content-header-title">{%{{{ '::Menu:Books' | abpLocalization }}}%}</h1>
    </div>
    <div class="col-lg-auto pl-lg-0">
        <abp-breadcrumb></abp-breadcrumb>
    </div>
    
    <!--Added new book button -->
    <div class="col">
        <div class="text-lg-right pt-2" id="AbpContentToolbar">
            <button id="create" class="btn btn-primary btn-sm" type="button" (click)="createBook()">
                <i class="fa fa-plus mr-1"></i> <span>{%{{{ '::NewBook' | abpLocalization }}}%}</span>
            </button>
        </div>
    </div>
</div>

<div id="wrapper" class="card">
    <div class="card-body">
        <p-table [value]="books$ | async" [loading]="loading" [paginator]="true" [rows]="10">
            <ng-template pTemplate="header">
                <tr>
                    <th>{%{{{ '::Name' | abpLocalization }}}%}</th>
                    <th>{%{{{ '::Type' | abpLocalization }}}%}</th>
                    <th>{%{{{ '::PublishDate' | abpLocalization }}}%}</th>
                    <th>{%{{{ '::Price' | abpLocalization }}}%}</th>
                </tr>
            </ng-template>
            <ng-template pTemplate="body" let-data>
                <tr>
                    <td>{%{{{ data.name }}}%}</td>
                    <td>{%{{{ booksType[data.type] }}}%}</td>
                    <td>{%{{{ data.publishDate | date }}}%}</td>
                    <td>{%{{{ data.price }}}%}</td>
                </tr>
            </ng-template>
        </p-table>
    </div>
</div>

<!--added modal-->
<abp-modal [(visible)]="isModalOpen">
    <ng-template #abpHeader>
        <h3>{%{{{ '::NewBook' | abpLocalization }}}%}</h3>
    </ng-template>

    <ng-template #abpBody> </ng-template>

    <ng-template #abpFooter>
        <button type="button" class="btn btn-secondary" #abpClose>
             {%{{{ 'AbpAccount::Close' | abpLocalization }}}%}
        </button>
    </ng-template>
</abp-modal>
  • We added the abp-modal which renders a modal to allow user to create a new book.
  • abp-modal is a pre-built component to show modals. While you could use another approach to show a modal, abp-modal provides additional benefits.
  • We added New book button to the AbpContentToolbar.

Open book-list.component. file in books\book-list folder and replace the content as below:

import { Component, OnInit } from '@angular/core';
import { Store, Select } from '@ngxs/store';
import { BooksState } from '../../store/states';
import { Observable } from 'rxjs';
import { Books } from '../../store/models';
import { GetBooks } from '../../store/actions';

@Component({
  selector: 'app-book-list',
  templateUrl: './book-list.component.html',
  styleUrls: ['./book-list.component.scss'],
})
export class BookListComponent implements OnInit {
  @Select(BooksState.getBooks)
  books$: Observable<Books.Book[]>;

  booksType = Books.BookType;

  loading = false;

  isModalOpen = false; //<== added this line ==>

  constructor(private store: Store) { }

  ngOnInit() {
    this.get();
  }

  get() {
    this.loading = true;
    this.store.dispatch(new GetBooks()).subscribe(() => {
      this.loading = false;
    });
  }

  //added createBook method
  createBook() {
    this.isModalOpen = true;
  }
}
  • We added isModalOpen = false and createBook method.

You can open your browser and click New book button to see the new modal.

Empty modal for new book

Create a reactive form

Reactive forms provide a model-driven approach to handling form inputs whose values change over time.

Open book-list.component.ts file in app\books\book-list folder and replace the content as below:

import { Component, OnInit } from '@angular/core';
import { Store, Select } from '@ngxs/store';
import { BooksState } from '../../store/states';
import { Observable } from 'rxjs';
import { Books } from '../../store/models';
import { GetBooks } from '../../store/actions';
import { FormGroup, FormBuilder, Validators } from '@angular/forms'; //<== added this line ==>

@Component({
  selector: 'app-book-list',
  templateUrl: './book-list.component.html',
  styleUrls: ['./book-list.component.scss'],
})
export class BookListComponent implements OnInit {
  @Select(BooksState.getBooks)
  books$: Observable<Books.Book[]>;

  booksType = Books.BookType;

  loading = false;

  isModalOpen = false;

  form: FormGroup;

  constructor(private store: Store, private fb: FormBuilder) { } //<== added FormBuilder ==>

  ngOnInit() {
    this.get();
  }

  get() {
    this.loading = true;
    this.store.dispatch(new GetBooks()).subscribe(() => {
      this.loading = false;
    });
  }

  createBook() {
    this.buildForm(); //<== added this line ==>
    this.isModalOpen = true;
  }

  //added buildForm method
  buildForm() {
    this.form = this.fb.group({
      name: ['', Validators.required],
      type: [null, Validators.required],
      publishDate: [null, Validators.required],
      price: [null, Validators.required],
    });
  }
}
  • We imported FormGroup, FormBuilder and Validators.
  • We injected fb: FormBuilder service to the constructor. The FormBuilder service provides convenient methods for generating controls. It reduces the amount of boilerplate needed to build complex forms.
  • We added buildForm method to the end of the file and executed buildForm() in the createBook method. This method creates a reactive form to be able to create a new book.
    • The group method of FormBuilder, fb creates a FormGroup.
    • Added Validators.required static method which validates the relevant form element.

Create the DOM elements of the form

Open book-list.component.html in app\books\book-list folder and replace <ng-template #abpBody> </ng-template> with the following code part:

<ng-template #abpBody>
  <form [formGroup]="form">
    <div class="form-group">
      <label for="book-name">Name</label><span> * </span>
      <input type="text" id="book-name" class="form-control" formControlName="name" autofocus />
    </div>

    <div class="form-group">
      <label for="book-price">Price</label><span> * </span>
      <input type="number" id="book-price" class="form-control" formControlName="price" />
    </div>

    <div class="form-group">
      <label for="book-type">Type</label><span> * </span>
      <select class="form-control" id="book-type" formControlName="type">
        <option [ngValue]="null">Select a book type</option>
        <option [ngValue]="booksType[type]" *ngFor="let type of bookTypeArr"> {%{{{ type }}}%}</option>
      </select>
    </div>

    <div class="form-group">
      <label>Publish date</label><span> * </span>
      <input
        #datepicker="ngbDatepicker"
        class="form-control"
        name="datepicker"
        formControlName="publishDate"
        ngbDatepicker
        (click)="datepicker.toggle()"
      />
    </div>
  </form>
</ng-template>
  • This template creates a form with Name, Price, Type and Publish date fields.
  • We've used NgBootstrap datepicker in this component.

Datepicker requirements

Open books.module.ts file in app\books folder and replace the content as below:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

import { BooksRoutingModule } from './books-routing.module';
import { BooksComponent } from './books.component';
import { BookListComponent } from './book-list/book-list.component';
import { SharedModule } from '../shared/shared.module';
import { NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; //<== added this line ==>

@NgModule({
  declarations: [BooksComponent, BookListComponent],
  imports: [
    CommonModule,
    BooksRoutingModule,
    SharedModule,
    NgbDatepickerModule //<== added this line ==>
  ]
})
export class BooksModule { }
  • We imported NgbDatepickerModule to be able to use the date picker.

Open book-list.component.ts file in app\books\book-list folder and replace the content as below:

import { Component, OnInit } from '@angular/core';
import { Store, Select } from '@ngxs/store';
import { BooksState } from '../../store/states';
import { Observable } from 'rxjs';
import { Books } from '../../store/models';
import { GetBooks } from '../../store/actions';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'; //<== added this line ==>

@Component({
  selector: 'app-book-list',
  templateUrl: './book-list.component.html',
  styleUrls: ['./book-list.component.scss'],
  providers: [{ provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }] //<== added this line ==>
})
export class BookListComponent implements OnInit {
  @Select(BooksState.getBooks)
  books$: Observable<Books.Book[]>;

  booksType = Books.BookType;

  //added bookTypeArr array
  bookTypeArr = Object.keys(Books.BookType).filter(
    bookType => typeof this.booksType[bookType] === 'number'
  );

  loading = false;

  isModalOpen = false;

  form: FormGroup;

  constructor(private store: Store, private fb: FormBuilder) { }

  ngOnInit() {
    this.get();
  }

  get() {
    this.loading = true;
    this.store.dispatch(new GetBooks()).subscribe(() => {
      this.loading = false;
    });
  }

  createBook() {
    this.buildForm();
    this.isModalOpen = true;
  }

  buildForm() {
    this.form = this.fb.group({
      name: ['', Validators.required],
      type: [null, Validators.required],
      publishDate: [null, Validators.required],
      price: [null, Validators.required],
    });
  }
}
  • We imported NgbDateNativeAdapter, NgbDateAdapter

  • We added a new provider NgbDateAdapter that converts Datepicker value to Date type. See the datepicker adapters for more details.

  • We added bookTypeArr array to be able to use it in the combobox values. The bookTypeArr contains the fields of the BookType enum. Resulting array is shown below:

    ['Adventure', 'Biography', 'Dystopia', 'Fantastic' ...]

    This array was used in the previous form template in the ngFor loop.

Now, you can open your browser to see the changes:

New book modal

Saving the book

Open book-list.component.html in app\books\book-list folder and add the following abp-button to save the new book.

<ng-template #abpFooter>
  <button type="button" class="btn btn-secondary" #abpClose>
      {%{{{ 'AbpAccount::Close' | abpLocalization }}}%}
  </button>
    
  <!--added save button-->
  <button class="btn btn-primary" (click)="save()" [disabled]="form.invalid">
        <i class="fa fa-check mr-1"></i>
        {%{{{ 'AbpAccount::Save' | abpLocalization }}}%}
  </button>
</ng-template>
  • This adds a save button to the bottom area of the modal:

Save button to the modal

Open book-list.component.ts file in app\books\book-list folder and replace the content as below:

import { Component, OnInit } from '@angular/core';
import { Store, Select } from '@ngxs/store';
import { BooksState } from '../../store/states';
import { Observable } from 'rxjs';
import { Books } from '../../store/models';
import { GetBooks, CreateUpdateBook } from '../../store/actions'; //<== added CreateUpdateBook ==>
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap';

@Component({
  selector: 'app-book-list',
  templateUrl: './book-list.component.html',
  styleUrls: ['./book-list.component.scss'],
  providers: [{ provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }]
})
export class BookListComponent implements OnInit {
  @Select(BooksState.getBooks)
  books$: Observable<Books.Book[]>;

  booksType = Books.BookType;

  bookTypeArr = Object.keys(Books.BookType).filter(
    bookType => typeof this.booksType[bookType] === 'number'
  );

  loading = false;

  isModalOpen = false;

  form: FormGroup;

  constructor(private store: Store, private fb: FormBuilder) { }

  ngOnInit() {
    this.get();
  }

  get() {
    this.loading = true;
    this.store.dispatch(new GetBooks()).subscribe(() => {
      this.loading = false;
    });
  }

  createBook() {
    this.buildForm();
    this.isModalOpen = true;
  }

  buildForm() {
    this.form = this.fb.group({
      name: ['', Validators.required],
      type: [null, Validators.required],
      publishDate: [null, Validators.required],
      price: [null, Validators.required],
    });
  }

  //<== added save ==>
  save() {
    if (this.form.invalid) {
      return;
    }

    this.store.dispatch(new CreateUpdateBook(this.form.value)).subscribe(() => {
      this.isModalOpen = false;
      this.form.reset();
      this.get();
    });
  }
}
  • We imported CreateUpdateBook.
  • We added save method

Updating an existing book

BooksService

Open the books.service.ts in app\books\shared folder and add the getById and update methods.

getById(id: string): Observable<Books.Book> {
  return this.restService.request<void, Books.Book>({
    method: 'GET',
    url: `/api/app/book/${id}`
  });
}

update(updateBookInput: Books.CreateUpdateBookInput, id: string): Observable<Books.Book> {
  return this.restService.request<Books.CreateUpdateBookInput, Books.Book>({
    method: 'PUT',
    url: `/api/app/book/${id}`,
    body: updateBookInput
  });
}

CreateUpdateBook action

Open the books.actions.ts in app\store\actions folder and replace the content as below:

import { Books } from '../models';

export class GetBooks {
  static readonly type = '[Books] Get';
}

export class CreateUpdateBook {
  static readonly type = '[Books] Create Update Book';
  constructor(public payload: Books.CreateUpdateBookInput, public id?: string) { } //<== added id parameter ==>
}
  • We added id parameter to the CreateUpdateBook action's constructor.

Open the books.state.ts in app\store\states folder and replace the save method as below:

@Action(CreateUpdateBook)
save(ctx: StateContext<Books.State>, action: CreateUpdateBook) {
    if (action.id) {
        return this.booksService.update(action.payload, action.id);
    } else {
        return this.booksService.create(action.payload);
    }
}

BookListComponent

Open book-list.component.ts in app\books\book-list folder and inject BooksService dependency by adding it to the constructor and add a variable named selectedBook.

import { Component, OnInit } from '@angular/core';
import { Store, Select } from '@ngxs/store';
import { BooksState } from '../../store/states';
import { Observable } from 'rxjs';
import { Books } from '../../store/models';
import { GetBooks, CreateUpdateBook } from '../../store/actions';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap';
import { BooksService } from '../shared/books.service'; //<== imported BooksService ==>

@Component({
  selector: 'app-book-list',
  templateUrl: './book-list.component.html',
  styleUrls: ['./book-list.component.scss'],
  providers: [{ provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }]
})
export class BookListComponent implements OnInit {
  @Select(BooksState.getBooks)
  books$: Observable<Books.Book[]>;

  booksType = Books.BookType;

  bookTypeArr = Object.keys(Books.BookType).filter(
    bookType => typeof this.booksType[bookType] === 'number'
  );

  loading = false;

  isModalOpen = false;

  form: FormGroup;

  selectedBook = {} as Books.Book; //<== declared selectedBook ==>

  constructor(private store: Store, private fb: FormBuilder, private booksService: BooksService) { }

  ngOnInit() {
    this.get();
  }

  get() {
    this.loading = true;
    this.store.dispatch(new GetBooks()).subscribe(() => {
      this.loading = false;
    });
  }

  //<== this method is replaced ==>
  createBook() {
    this.selectedBook = {} as Books.Book; //<== added ==>
    this.buildForm();
    this.isModalOpen = true;
  }

  //<== added editBook method ==>
  editBook(id: string) {
    this.booksService.getById(id).subscribe(book => {
      this.selectedBook = book;
      this.buildForm();
      this.isModalOpen = true;
    });
  }

  //<== this method is replaced ==>
  buildForm() {
    this.form = this.fb.group({
      name: [this.selectedBook.name || '', Validators.required],
      type: this.selectedBook.type || null,
      publishDate: this.selectedBook.publishDate ? new Date(this.selectedBook.publishDate) : null,
      price: this.selectedBook.price || null,
    });
  }

  save() {
    if (this.form.invalid) {
      return;
    }

    //<== added this.selectedBook.id ==>
    this.store.dispatch(new CreateUpdateBook(this.form.value, this.selectedBook.id))
      .subscribe(() => {
        this.isModalOpen = false;
        this.form.reset();
        this.get();
      });
  }
}
  • We imported BooksService.
  • We declared a variable named selectedBook as Books.Book.
  • We injected BooksService to the constructor. BooksService is being used to retrieve the book data which is being edited.
  • We added editBook method. This method fetches the book with the given Id and sets it to selectedBook object.
  • We replaced the buildForm method so that it creates the form with the selectedBook data.
  • We replaced the createBook method so it sets selectedBook to an empty object.
  • We added selectedBook.id to the constructor of the new CreateUpdateBook.

Add "Actions" dropdown to the table

Open the book-list.component.html in app\books\book-list folder and replace the p-table tag as below:

<p-table [value]="books$ | async" [loading]="loading" [paginator]="true" [rows]="10">
  <ng-template pTemplate="header">
    <tr>
      <th>Actions</th>
      <th>Book name</th>
      <th>Book type</th>
      <th>Publish date</th>
      <th>Price</th>
    </tr>
  </ng-template>
  <ng-template pTemplate="body" let-data>
    <tr>
      <td>
        <div ngbDropdown class="d-inline-block">
          <button
            class="btn btn-primary btn-sm dropdown-toggle"
            data-toggle="dropdown"
            aria-haspopup="true"
            ngbDropdownToggle
          >
            <i class="fa fa-cog mr-1"></i>Actions
          </button>
          <div ngbDropdownMenu>
            <button ngbDropdownItem (click)="editBook(data.id)">Edit</button>
          </div>
        </div>
      </td>
      <td>{%{{{ data.name }}}%}</td>
      <td>{%{{{ booksType[data.type] }}}%}</td>
      <td>{%{{{ data.publishDate | date }}}%}</td>
      <td>{%{{{ data.price }}}%}</td>
    </tr>
  </ng-template>
</p-table>
  • We added a th for the "Actions" column.
  • We added button with ngbDropdownToggle to open actions when clicked the button.
  • We have used to NgbDropdown for the dropdown menu of actions.

The final UI looks like as below:

Action buttons

Open book-list.component.html in app\books\book-list folder and find the <ng-template #abpHeader> tag and replace the content as below.

<ng-template #abpHeader>
    <h3>{%{{{ (selectedBook.id ? 'AbpIdentity::Edit' : '::NewBook' ) | abpLocalization }}}%}</h3>
</ng-template>
  • This template will show Edit text for edit record operation, New Book for new record operation in the title.

Deleting an existing book

BooksService

Open books.service.ts in app\books\shared folder and add the below delete method to delete a book.

delete(id: string): Observable<void> {
  return this.restService.request<void, void>({
    method: 'DELETE',
    url: `/api/app/book/${id}`
  });
}
  • Delete method gets id parameter and makes a DELETE HTTP request to the relevant endpoint.

DeleteBook action

Open books.actions.ts in app\store\actions folder and add an action named DeleteBook.

export class DeleteBook {
  static readonly type = '[Books] Delete';
  constructor(public id: string) {}
}

Open the books.state.ts in app\store\states folder and replace the content as below:

import { State, Action, StateContext, Selector } from '@ngxs/store';
import { GetBooks, CreateUpdateBook, DeleteBook } from '../actions/books.actions'; //<== added DeleteBook==>
import { Books } from '../models/books';
import { BooksService } from '../../books/shared/books.service';
import { tap } from 'rxjs/operators';

@State<Books.State>({
  name: 'BooksState',
  defaults: { books: {} } as Books.State,
})
export class BooksState {
  @Selector()
  static getBooks(state: Books.State) {
    return state.books.items || [];
  }

  constructor(private booksService: BooksService) { }

  @Action(GetBooks)
  get(ctx: StateContext<Books.State>) {
    return this.booksService.get().pipe(
      tap(booksResponse => {
        ctx.patchState({
          books: booksResponse,
        });
      }),
    );
  }

  @Action(CreateUpdateBook)
  save(ctx: StateContext<Books.State>, action: CreateUpdateBook) {
    if (action.id) {
      return this.booksService.update(action.payload, action.id);
    } else {
      return this.booksService.create(action.payload);
    }
  }

  //<== added DeleteBook ==>
  @Action(DeleteBook)
  delete(ctx: StateContext<Books.State>, action: DeleteBook) {
    return this.booksService.delete(action.id);
  }
}
  • We imported DeleteBook .

  • We added DeleteBook action listener to the end of the file.

Add a delete button

Open book-list.component.html in app\books\book-list folder and modify the ngbDropdownMenu to add the delete button as shown below:

<div ngbDropdownMenu>
  <!-- added Delete button -->
    <button ngbDropdownItem (click)="delete(data.id, data.name)">
        {%{{{ 'AbpAccount::Delete' | abpLocalization }}}%}
    </button>
</div>

The final actions dropdown UI looks like below:

bookstore-final-actions-dropdown

Delete confirmation dialog

Open book-list.component.ts inapp\books\book-list folder and inject the ConfirmationService.

Replace the constructor as below:

import { ConfirmationService } from '@abp/ng.theme.shared';
//...

constructor(
    private store: Store, private fb: FormBuilder,
    private booksService: BooksService,
    private confirmationService: ConfirmationService // <== added this line ==>
) { }
  • We imported ConfirmationService.
  • We injected ConfirmationService to the constructor.

In the book-list.component.ts add a delete method :

import { GetBooks, CreateUpdateBook, DeleteBook } from '../../store/actions'; //<== added DeleteBook ==>

import { ConfirmationService, Confirmation } from '@abp/ng.theme.shared'; //<== added Confirmation ==>

//...

delete(id: string, name: string) {
    this.confirmationService
        .warn('::AreYouSureToDelete', 'AbpAccount::AreYouSure')
        .subscribe(status => {
        if (status === Confirmation.Status.confirm) {
            this.store.dispatch(new DeleteBook(id)).subscribe(() => this.get());
        }
    });
}

The delete method shows a confirmation popup and subscribes for the user response. DeleteBook action dispatched only if user clicks to the Yes button. The confirmation popup looks like below:

bookstore-confirmation-popup

{{end}}

Next Part

See the next part of this tutorial.