|
|
## ASP.NET Core {{UI_Value}} Tutorial - Part 2
|
|
|
````json
|
|
|
//[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:
|
|
|
|
|
|
* [Part I: Creating the project and book list page](part-1.md)
|
|
|
* **Part II: Creating, updating and deleting books (this tutorial)**
|
|
|
* [Part III: Integration tests](part-3.md)
|
|
|
|
|
|
*You can also watch [this video course](https://amazingsolutions.teachable.com/p/lets-build-the-bookstore-application) 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](./images/bookstore-create-dialog-2.png)
|
|
|
|
|
|
#### 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](./images/bookstore-add-create-dialog-v2.png)
|
|
|
|
|
|
##### CreateModal.cshtml.cs
|
|
|
|
|
|
Open the `CreateModal.cshtml.cs` file (`CreateModalModel` class) and replace with the following code:
|
|
|
|
|
|
````C#
|
|
|
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:
|
|
|
|
|
|
````html
|
|
|
@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:
|
|
|
|
|
|
````html
|
|
|
<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](./images/bookstore-new-book-button.png)
|
|
|
|
|
|
Open the `pages/books/index.js` and add the following code just after the `Datatable` configuration:
|
|
|
|
|
|
````js
|
|
|
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](./images/bookstore-add-edit-dialog.png)
|
|
|
|
|
|
#### EditModal.cshtml.cs
|
|
|
|
|
|
Open the `EditModal.cshtml.cs` file (`EditModalModel` class) and replace with the following code:
|
|
|
|
|
|
````csharp
|
|
|
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:
|
|
|
|
|
|
````csharp
|
|
|
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:
|
|
|
|
|
|
````html
|
|
|
@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:
|
|
|
|
|
|
````html
|
|
|
<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:
|
|
|
|
|
|
````js
|
|
|
$(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](./images/bookstore-edit-button.png)
|
|
|
|
|
|
### Deleting a book
|
|
|
|
|
|
Open the `pages/books/index.js` and add a new item to the `rowAction` `items`:
|
|
|
|
|
|
````js
|
|
|
{
|
|
|
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:
|
|
|
|
|
|
````js
|
|
|
$(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:
|
|
|
|
|
|
````json
|
|
|
"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:
|
|
|
|
|
|
```js
|
|
|
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:
|
|
|
|
|
|
```js
|
|
|
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:
|
|
|
|
|
|
```js
|
|
|
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:
|
|
|
|
|
|
```js
|
|
|
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:
|
|
|
|
|
|
```html
|
|
|
<div class="card">
|
|
|
<div class="card-header">
|
|
|
<div class="row">
|
|
|
<div class="col col-md-6">
|
|
|
<h5 class="card-title">
|
|
|
{%{{{ '::Menu:Books' | abpLocalization }}}%}
|
|
|
</h5>
|
|
|
</div>
|
|
|
<!--Added new book button -->
|
|
|
<div class="text-right col col-md-6">
|
|
|
<div class="text-lg-right pt-2">
|
|
|
<button
|
|
|
id="create"
|
|
|
class="btn btn-primary"
|
|
|
type="button"
|
|
|
(click)="createBook()"
|
|
|
>
|
|
|
<i class="fa fa-plus mr-1"></i>
|
|
|
<span>{%{{{ "::NewBook" | abpLocalization }}}%}</span>
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="card-body">
|
|
|
<abp-table
|
|
|
[value]="books$ | async"
|
|
|
[abpLoading]="loading"
|
|
|
[headerTemplate]="tableHeader"
|
|
|
[bodyTemplate]="tableBody"
|
|
|
[rows]="10"
|
|
|
[scrollable]="true"
|
|
|
>
|
|
|
</abp-table>
|
|
|
<ng-template #tableHeader>
|
|
|
<tr>
|
|
|
<th>{%{{{ "::Name" | abpLocalization }}}%}</th>
|
|
|
<th>{%{{{ "::Type" | abpLocalization }}}%}</th>
|
|
|
<th>{%{{{ "::PublishDate" | abpLocalization }}}%}</th>
|
|
|
<th>{%{{{ "::Price" | abpLocalization }}}%}</th>
|
|
|
</tr>
|
|
|
</ng-template>
|
|
|
<ng-template #tableBody let-data>
|
|
|
<tr>
|
|
|
<td>{%{{{ data.name }}}%}</td>
|
|
|
<td>{%{{{ booksType[data.type] }}}%}</td>
|
|
|
<td>{%{{{ data.publishDate | date }}}%}</td>
|
|
|
<td>{%{{{ data.price }}}%}</td>
|
|
|
</tr>
|
|
|
</ng-template>
|
|
|
</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:
|
|
|
|
|
|
```js
|
|
|
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](./images/bookstore-empty-new-book-modal.png)
|
|
|
|
|
|
#### Create a reactive form
|
|
|
|
|
|
[Reactive forms](https://angular.io/guide/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:
|
|
|
|
|
|
```js
|
|
|
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](https://angular.io/api/forms/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:
|
|
|
|
|
|
```html
|
|
|
<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](https://ng-bootstrap.github.io/#/components/datepicker/overview) in this component.
|
|
|
|
|
|
#### Datepicker requirements
|
|
|
|
|
|
Open `books.module.ts` file in `app\books` folder and replace the content as below:
|
|
|
|
|
|
```js
|
|
|
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:
|
|
|
|
|
|
```js
|
|
|
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](https://ng-bootstrap.github.io/#/components/datepicker/overview) 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:
|
|
|
|
|
|
```js
|
|
|
['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](./images/bookstore-new-book-form.png)
|
|
|
|
|
|
#### 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.
|
|
|
|
|
|
```html
|
|
|
<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](./images/bookstore-new-book-form-v2.png)
|
|
|
|
|
|
Open `book-list.component.ts` file in `app\books\book-list` folder and replace the content as below:
|
|
|
|
|
|
```js
|
|
|
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.
|
|
|
|
|
|
```js
|
|
|
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:
|
|
|
|
|
|
```js
|
|
|
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:
|
|
|
|
|
|
```js
|
|
|
@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`.
|
|
|
|
|
|
```js
|
|
|
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, Validators.required],
|
|
|
publishDate: [
|
|
|
this.selectedBook.publishDate
|
|
|
? new Date(this.selectedBook.publishDate)
|
|
|
: null,
|
|
|
Validators.required
|
|
|
],
|
|
|
price: [this.selectedBook.price || null, Validators.required]
|
|
|
});
|
|
|
}
|
|
|
|
|
|
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 `<div class="card-body">` tag as below:
|
|
|
|
|
|
```html
|
|
|
<div class="card-body">
|
|
|
<abp-table
|
|
|
[value]="books$ | async"
|
|
|
[abpLoading]="loading"
|
|
|
[headerTemplate]="tableHeader"
|
|
|
[bodyTemplate]="tableBody"
|
|
|
[rows]="10"
|
|
|
[scrollable]="true"
|
|
|
>
|
|
|
</abp-table>
|
|
|
<ng-template #tableHeader>
|
|
|
<tr>
|
|
|
<th>{%{{{ "::Actions" | abpLocalization }}}%}</th>
|
|
|
<th>{%{{{ "::Name" | abpLocalization }}}%}</th>
|
|
|
<th>{%{{{ "::Type" | abpLocalization }}}%}</th>
|
|
|
<th>{%{{{ "::PublishDate" | abpLocalization }}}%}</th>
|
|
|
<th>{%{{{ "::Price" | abpLocalization }}}%}</th>
|
|
|
</tr>
|
|
|
</ng-template>
|
|
|
<ng-template #tableBody let-data>
|
|
|
<tr>
|
|
|
<td>
|
|
|
<div ngbDropdown container="body" 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" | abpLocalization }}}%}
|
|
|
</button>
|
|
|
<div ngbDropdownMenu>
|
|
|
<button ngbDropdownItem (click)="editBook(data.id)">
|
|
|
{%{{{ "::Edit" | abpLocalization }}}%}
|
|
|
</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>
|
|
|
</div>
|
|
|
```
|
|
|
|
|
|
- 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](https://ng-bootstrap.github.io/#/components/dropdown/examples) for the dropdown menu of actions.
|
|
|
|
|
|
The final UI looks like as below:
|
|
|
|
|
|
![Action buttons](./images/bookstore-actions-buttons.png)
|
|
|
|
|
|
Open `book-list.component.html` in `app\books\book-list` folder and find the `<ng-template #abpHeader>` tag and replace the content as below.
|
|
|
|
|
|
```html
|
|
|
<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 a book
|
|
|
|
|
|
#### BooksService
|
|
|
|
|
|
Open `books.service.ts` in `app\books\shared` folder and add the below `delete` method to delete a book.
|
|
|
|
|
|
```js
|
|
|
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`.
|
|
|
|
|
|
```js
|
|
|
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:
|
|
|
|
|
|
```js
|
|
|
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:
|
|
|
|
|
|
```html
|
|
|
<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](./images/bookstore-final-actions-dropdown.png)
|
|
|
|
|
|
#### Delete confirmation dialog
|
|
|
|
|
|
Open `book-list.component.ts` in`app\books\book-list` folder and inject the `ConfirmationService`.
|
|
|
|
|
|
Replace the constructor as below:
|
|
|
|
|
|
```js
|
|
|
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 :
|
|
|
|
|
|
```js
|
|
|
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](./images/bookstore-confirmation-popup.png)
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
### Next Part
|
|
|
|
|
|
See the [next part](part-3.md) of this tutorial.
|