docs/en/tutorials/book-store/part-10.md
//[doc-seo]
{
"Description": "Learn how to establish a 1 to N relationship between Book and Author entities in your ABP Framework application with this step-by-step tutorial."
}
//[doc-params]
{
"UI": ["MVC","Blazor","BlazorServer","BlazorWebApp","NG", "MAUIBlazor"],
"DB": ["EF","Mongo"]
}
//[doc-nav]
{
"Previous": {
"Name": "Authors: User Interface",
"Path": "tutorials/book-store/part-09"
}
}
We have created Book and Author functionalities for the book store application. However, currently there is no relation between these entities.
In this tutorial, we will establish a 1 to N relation between the Author and the Book entities.
Open the Books/Book.cs in the Acme.BookStore.Domain project and add the following property to the Book entity:
public Guid AuthorId { get; set; }
{{if DB=="EF"}}
In this tutorial, we preferred to not add a navigation property to the
Authorentity from theBookclass (likepublic Author Author { get; set; }). This is due to follow the DDD best practices (rule: refer to other aggregates only by id). However, you can add such a navigation property and configure it for the EF Core. In this way, you don't need to write join queries while getting books with their authors (like we will be doing below) which makes your application code simpler.
{{end}}
Added a new, required AuthorId property to the Book entity. But, what about the existing books on the database? They currently don't have AuthorIds and this will be a problem when we try to run the application.
This is a typical migration problem and the decision depends on your case;
We prefer to delete the database {{if DB=="EF"}}(you can run the Drop-Database in the Package Manager Console){{end}} since this is just an example project and data loss is not important. Since this topic is not related to the ABP, we don't go deeper for all the scenarios.
{{if DB=="EF"}}
Locate to OnModelCreating method in the BookStoreDbContext class that under the EntityFrameworkCore folder of the Acme.BookStore.EntityFrameworkCore project and change the builder.Entity<Book> part as shown below:
builder.Entity<Book>(b =>
{
b.ToTable(BookStoreConsts.DbTablePrefix + "Books", BookStoreConsts.DbSchema);
b.ConfigureByConvention(); //auto configure for the base class props
b.Property(x => x.Name).IsRequired().HasMaxLength(128);
// ADD THE MAPPING FOR THE RELATION
b.HasOne<Author>().WithMany().HasForeignKey(x => x.AuthorId).IsRequired();
});
The startup solution is configured to use Entity Framework Core Code First Migrations. Since we've changed the database mapping configuration, we should create a new migration and apply changes to the database.
Open a command-line terminal in the directory of the Acme.BookStore.EntityFrameworkCore project and type the following command:
dotnet ef migrations add Added_AuthorId_To_Book
This should create a new migration class with the following code in its Up method:
migrationBuilder.AddColumn<Guid>(
name: "AuthorId",
table: "AppBooks",
type: "uniqueidentifier",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateIndex(
name: "IX_AppBooks_AuthorId",
table: "AppBooks",
column: "AuthorId");
migrationBuilder.AddForeignKey(
name: "FK_AppBooks_AppAuthors_AuthorId",
table: "AppBooks",
column: "AuthorId",
principalTable: "AppAuthors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
AuthorId field to the AppBooks table.AuthorId field.AppAuthors table.If you are using Visual Studio, you may want to use
Add-Migration Added_AuthorId_To_Book -c BookStoreDbContextandUpdate-Database -Context BookStoreDbContextcommands in the Package Manager Console (PMC). In this case, ensure that {{if UI=="MVC"}}Acme.BookStore.Web{{else if UI=="BlazorServer" || UI=="BlazorWebApp"}}Acme.BookStore.Blazor{{else if UI=="Blazor" || UI=="NG" || UI=="MAUIBlazor"}}Acme.BookStore.HttpApi.Host{{end}} is the startup project andAcme.BookStore.EntityFrameworkCoreis the Default Project in PMC.
{{end}}
Since the AuthorId is a required property of the Book entity, current data seeder code can not work. Open the BookStoreDataSeederContributor in the Acme.BookStore.Domain project and change as the following:
using System;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Books;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
namespace Acme.BookStore;
public class BookStoreDataSeederContributor
: IDataSeedContributor, ITransientDependency
{
private readonly IRepository<Book, Guid> _bookRepository;
private readonly IAuthorRepository _authorRepository;
private readonly AuthorManager _authorManager;
public BookStoreDataSeederContributor(
IRepository<Book, Guid> bookRepository,
IAuthorRepository authorRepository,
AuthorManager authorManager)
{
_bookRepository = bookRepository;
_authorRepository = authorRepository;
_authorManager = authorManager;
}
public async Task SeedAsync(DataSeedContext context)
{
if (await _bookRepository.GetCountAsync() > 0)
{
return;
}
var orwell = await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"George Orwell",
new DateTime(1903, 06, 25),
"Orwell produced literary criticism and poetry, fiction and polemical journalism; and is best known for the allegorical novella Animal Farm (1945) and the dystopian novel Nineteen Eighty-Four (1949)."
)
);
var douglas = await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"Douglas Adams",
new DateTime(1952, 03, 11),
"Douglas Adams was an English author, screenwriter, essayist, humorist, satirist and dramatist. Adams was an advocate for environmentalism and conservation, a lover of fast cars, technological innovation and the Apple Macintosh, and a self-proclaimed 'radical atheist'."
)
);
await _bookRepository.InsertAsync(
new Book
{
AuthorId = orwell.Id, // SET THE AUTHOR
Name = "1984",
Type = BookType.Dystopia,
PublishDate = new DateTime(1949, 6, 8),
Price = 19.84f
},
autoSave: true
);
await _bookRepository.InsertAsync(
new Book
{
AuthorId = douglas.Id, // SET THE AUTHOR
Name = "The Hitchhiker's Guide to the Galaxy",
Type = BookType.ScienceFiction,
PublishDate = new DateTime(1995, 9, 27),
Price = 42.0f
},
autoSave: true
);
}
}
The only change is that we set the AuthorId properties of the Book entities.
Delete existing books or delete the database before executing the
DbMigrator. See the Database & Data Migration section above for more info.
{{if DB=="EF"}}
You can now run the .DbMigrator console application to migrate the database schema and seed the initial data.
{{else if DB=="Mongo"}}
You can now run the .DbMigrator console application to seed the initial data.
{{end}}
We will change the BookAppService to support the Author relation.
Let's begin from the DTOs.
Open the BookDto class in the Books folder of the Acme.BookStore.Application.Contracts project and add the following properties:
public Guid AuthorId { get; set; }
public string AuthorName { get; set; }
The final BookDto class should be following:
using System;
using Volo.Abp.Application.Dtos;
namespace Acme.BookStore.Books;
public class BookDto : AuditedEntityDto<Guid>
{
public Guid AuthorId { get; set; }
public string AuthorName { get; set; }
public string Name { get; set; }
public BookType Type { get; set; }
public DateTime PublishDate { get; set; }
public float Price { get; set; }
}
Open the CreateUpdateBookDto class in the Books folder of the Acme.BookStore.Application.Contracts project and add an AuthorId property as shown:
public Guid AuthorId { get; set; }
Create a new class, AuthorLookupDto, inside the Books folder of the Acme.BookStore.Application.Contracts project:
using System;
using Volo.Abp.Application.Dtos;
namespace Acme.BookStore.Books;
public class AuthorLookupDto : EntityDto<Guid>
{
public string Name { get; set; }
}
This will be used in a new method that will be added to the IBookAppService.
Open the IBookAppService interface in the Books folder of the Acme.BookStore.Application.Contracts project and add a new method, named GetAuthorLookupAsync, as shown below:
using System;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
namespace Acme.BookStore.Books;
public interface IBookAppService :
ICrudAppService< //Defines CRUD methods
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto> //Used to create/update a book
{
// ADD the NEW METHOD
Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync();
}
This new method will be used from the UI to get a list of authors and fill a dropdown list to select the author of a book.
Open the BookAppService class in the Books folder of the Acme.BookStore.Application project and replace the file content with the following code:
{{if DB=="EF"}}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Dynamic.Core;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Permissions;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Domain.Repositories;
namespace Acme.BookStore.Books;
[Authorize(BookStorePermissions.Books.Default)]
public class BookAppService :
CrudAppService<
Book, //The Book entity
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto>, //Used to create/update a book
IBookAppService //implement the IBookAppService
{
private readonly IAuthorRepository _authorRepository;
public BookAppService(
IRepository<Book, Guid> repository,
IAuthorRepository authorRepository)
: base(repository)
{
_authorRepository = authorRepository;
GetPolicyName = BookStorePermissions.Books.Default;
GetListPolicyName = BookStorePermissions.Books.Default;
CreatePolicyName = BookStorePermissions.Books.Create;
UpdatePolicyName = BookStorePermissions.Books.Edit;
DeletePolicyName = BookStorePermissions.Books.Delete;
}
public override async Task<BookDto> GetAsync(Guid id)
{
//Get the IQueryable<Book> from the repository
var queryable = await Repository.GetQueryableAsync();
//Prepare a query to join books and authors
var query = from book in queryable
join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id
where book.Id == id
select new { book, author };
//Execute the query and get the book with author
var queryResult = await AsyncExecuter.FirstOrDefaultAsync(query);
if (queryResult == null)
{
throw new EntityNotFoundException(typeof(Book), id);
}
var bookDto = ObjectMapper.Map<Book, BookDto>(queryResult.book);
bookDto.AuthorName = queryResult.author.Name;
return bookDto;
}
public override async Task<PagedResultDto<BookDto>> GetListAsync(PagedAndSortedResultRequestDto input)
{
//Get the IQueryable<Book> from the repository
var queryable = await Repository.GetQueryableAsync();
//Prepare a query to join books and authors
var query = from book in queryable
join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id
select new {book, author};
//Paging
query = query
.OrderBy(NormalizeSorting(input.Sorting))
.Skip(input.SkipCount)
.Take(input.MaxResultCount);
//Execute the query and get a list
var queryResult = await AsyncExecuter.ToListAsync(query);
//Convert the query result to a list of BookDto objects
var bookDtos = queryResult.Select(x =>
{
var bookDto = ObjectMapper.Map<Book, BookDto>(x.book);
bookDto.AuthorName = x.author.Name;
return bookDto;
}).ToList();
//Get the total count with another query
var totalCount = await Repository.GetCountAsync();
return new PagedResultDto<BookDto>(
totalCount,
bookDtos
);
}
public async Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync()
{
var authors = await _authorRepository.GetListAsync();
return new ListResultDto<AuthorLookupDto>(
ObjectMapper.Map<List<Author>, List<AuthorLookupDto>>(authors)
);
}
private static string NormalizeSorting(string sorting)
{
if (sorting.IsNullOrEmpty())
{
return $"book.{nameof(Book.Name)}";
}
if (sorting.Contains("authorName", StringComparison.OrdinalIgnoreCase))
{
return sorting.Replace(
"authorName",
"author.Name",
StringComparison.OrdinalIgnoreCase
);
}
return $"book.{sorting}";
}
}
Let's see the changes we've done:
[Authorize(BookStorePermissions.Books.Default)] to authorize the methods we've newly added/overrode (remember, authorize attribute is valid for all the methods of the class when it is declared for a class).IAuthorRepository to query from the authors.GetAsync method of the base CrudAppService, which returns a single BookDto object with the given id.
AsyncExecuter.FirstOrDefaultAsync(...) to execute the query and get a result. It is a way to use asynchronous LINQ extensions without depending on the database provider API. Check the repository documentation to understand why we've used it.EntityNotFoundException which results an HTTP 404 (not found) result if requested book was not present in the database.BookDto object using the ObjectMapper, then assigning the AuthorName manually.GetListAsync method of the base CrudAppService, which returns a list of books. The logic is similar to the previous method, so you can easily understand the code.GetAuthorLookupAsync. This simple gets all the authors. The UI uses this method to fill a dropdown list and select and author while creating/editing books.{{else if DB=="Mongo"}}
using System;
using System.Collections.Generic;
using System.Linq.Dynamic.Core;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Permissions;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
namespace Acme.BookStore.Books;
[Authorize(BookStorePermissions.Books.Default)]
public class BookAppService :
CrudAppService<
Book, //The Book entity
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto>, //Used to create/update a book
IBookAppService //implement the IBookAppService
{
private readonly IAuthorRepository _authorRepository;
public BookAppService(
IRepository<Book, Guid> repository,
IAuthorRepository authorRepository)
: base(repository)
{
_authorRepository = authorRepository;
GetPolicyName = BookStorePermissions.Books.Default;
GetListPolicyName = BookStorePermissions.Books.Default;
CreatePolicyName = BookStorePermissions.Books.Create;
UpdatePolicyName = BookStorePermissions.Books.Edit;
DeletePolicyName = BookStorePermissions.Books.Create;
}
public async override Task<BookDto> GetAsync(Guid id)
{
var book = await Repository.GetAsync(id);
var bookDto = ObjectMapper.Map<Book, BookDto>(book);
var author = await _authorRepository.GetAsync(book.AuthorId);
bookDto.AuthorName = author.Name;
return bookDto;
}
public async override Task<PagedResultDto<BookDto>>
GetListAsync(PagedAndSortedResultRequestDto input)
{
//Set a default sorting, if not provided
if (input.Sorting.IsNullOrWhiteSpace())
{
input.Sorting = nameof(Book.Name);
}
//Get the IQueryable<Book> from the repository
var queryable = await Repository.GetQueryableAsync();
//Get the books
var books = await AsyncExecuter.ToListAsync(
queryable
.OrderBy(input.Sorting)
.Skip(input.SkipCount)
.Take(input.MaxResultCount)
);
//Convert to DTOs
var bookDtos = ObjectMapper.Map<List<Book>, List<BookDto>>(books);
//Get a lookup dictionary for the related authors
var authorDictionary = await GetAuthorDictionaryAsync(books);
//Set AuthorName for the DTOs
bookDtos.ForEach(bookDto => bookDto.AuthorName =
authorDictionary[bookDto.AuthorId].Name);
//Get the total count with another query (required for the paging)
var totalCount = await Repository.GetCountAsync();
return new PagedResultDto<BookDto>(
totalCount,
bookDtos
);
}
public async Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync()
{
var authors = await _authorRepository.GetListAsync();
return new ListResultDto<AuthorLookupDto>(
ObjectMapper.Map<List<Author>, List<AuthorLookupDto>>(authors)
);
}
private async Task<Dictionary<Guid, Author>>
GetAuthorDictionaryAsync(List<Book> books)
{
var authorIds = books
.Select(b => b.AuthorId)
.Distinct()
.ToArray();
var queryable = await _authorRepository.GetQueryableAsync();
var authors = await AsyncExecuter.ToListAsync(
queryable.Where(a => authorIds.Contains(a.Id))
);
return authors.ToDictionary(x => x.Id, x => x);
}
}
Let's see the changes we've done:
[Authorize(BookStorePermissions.Books.Default)] to authorize the methods we've newly added/overrode (remember, authorize attribute is valid for all the methods of the class when it is declared for a class).IAuthorRepository to query from the authors.GetAsync method of the base CrudAppService, which returns a single BookDto object with the given id.GetListAsync method of the base CrudAppService, which returns a list of books. This code separately queries the authors from database and sets the name of the authors in the application code. Instead, you could create a custom repository method and perform a join query or take the power of the MongoDB API to get the books and their authors in a single query, which would be more performant.GetAuthorLookupAsync. This simple gets all the authors. The UI uses this method to fill a dropdown list and select and author while creating/editing books.{{end}}
Introduced the AuthorLookupDto class and used object mapping inside the GetAuthorLookupAsync method. So, we need to add a new mapping definition inside the BookStoreApplicationMappers.cs file of the Acme.BookStore.Application project:
[Mapper]
public partial class AuthorToAuthorLookupDtoMapper : MapperBase<Author, AuthorLookupDto>
{
public override partial AuthorLookupDto Map(Author source);
public override partial void Map(Author source, AuthorLookupDto destination);
}
Some of the unit tests will fail since we made some changed on the AuthorAppService. Open the BookAppService_Tests in the Books folder of the Acme.BookStore.Application.Tests project and change the content as the following:
using System;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Shouldly;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Modularity;
using Volo.Abp.Validation;
using Xunit;
namespace Acme.BookStore.Books;
public abstract class BookAppService_Tests<TStartupModule> : BookStoreApplicationTestBase<TStartupModule>
where TStartupModule : IAbpModule
{
private readonly IBookAppService _bookAppService;
private readonly IAuthorAppService _authorAppService;
protected BookAppService_Tests()
{
_bookAppService = GetRequiredService<IBookAppService>();
_authorAppService = GetRequiredService<IAuthorAppService>();
}
[Fact]
public async Task Should_Get_List_Of_Books()
{
//Act
var result = await _bookAppService.GetListAsync(
new PagedAndSortedResultRequestDto()
);
//Assert
result.TotalCount.ShouldBeGreaterThan(0);
result.Items.ShouldContain(b => b.Name == "1984" &&
b.AuthorName == "George Orwell");
}
[Fact]
public async Task Should_Create_A_Valid_Book()
{
var authors = await _authorAppService.GetListAsync(new GetAuthorListDto());
var firstAuthor = authors.Items.First();
//Act
var result = await _bookAppService.CreateAsync(
new CreateUpdateBookDto
{
AuthorId = firstAuthor.Id,
Name = "New test book 42",
Price = 10,
PublishDate = System.DateTime.Now,
Type = BookType.ScienceFiction
}
);
//Assert
result.Id.ShouldNotBe(Guid.Empty);
result.Name.ShouldBe("New test book 42");
}
[Fact]
public async Task Should_Not_Create_A_Book_Without_Name()
{
var exception = await Assert.ThrowsAsync<AbpValidationException>(async () =>
{
await _bookAppService.CreateAsync(
new CreateUpdateBookDto
{
Name = "",
Price = 10,
PublishDate = DateTime.Now,
Type = BookType.ScienceFiction
}
);
});
exception.ValidationErrors
.ShouldContain(err => err.MemberNames.Any(m => m == "Name"));
}
}
Should_Get_List_Of_Books from b => b.Name == "1984" to b => b.Name == "1984" && b.AuthorName == "George Orwell" to check if the author name was filled.Should_Create_A_Valid_Book method to set the AuthorId while creating a new book, since it is required anymore.{{if UI=="MVC"}}
Book list page change is trivial. Open the Pages/Books/Index.js in the Acme.BookStore.Web project and add an authorName column between the name and type columns:
...
{
title: l('Name'),
data: "name"
},
// ADDED the NEW AUTHOR NAME COLUMN
{
title: l('Author'),
data: "authorName"
},
{
title: l('Type'),
data: "type",
render: function (data) {
return l('Enum:BookType.' + data);
}
},
...
When you run the application, you can see the Author column on the table:
Open the Pages/Books/CreateModal.cshtml.cs in the Acme.BookStore.Web project and change the file content as shown below:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Books;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;
namespace Acme.BookStore.Web.Pages.Books;
public class CreateModalModel : BookStorePageModel
{
[BindProperty]
public CreateBookViewModel Book { get; set; }
public List<SelectListItem> Authors { get; set; }
private readonly IBookAppService _bookAppService;
public CreateModalModel(
IBookAppService bookAppService)
{
_bookAppService = bookAppService;
}
public async Task OnGetAsync()
{
Book = new CreateBookViewModel();
var authorLookup = await _bookAppService.GetAuthorLookupAsync();
Authors = authorLookup.Items
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToList();
}
public async Task<IActionResult> OnPostAsync()
{
await _bookAppService.CreateAsync(
ObjectMapper.Map<CreateBookViewModel, CreateUpdateBookDto>(Book)
);
return NoContent();
}
public class CreateBookViewModel
{
[SelectItems(nameof(Authors))]
[DisplayName("Author")]
public Guid AuthorId { get; set; }
[Required]
[StringLength(128)]
public string Name { get; set; } = string.Empty;
[Required]
public BookType Type { get; set; } = BookType.Undefined;
[Required]
[DataType(DataType.Date)]
public DateTime PublishDate { get; set; } = DateTime.Now;
[Required]
public float Price { get; set; }
}
}
Book property from CreateUpdateBookDto to the new CreateBookViewModel class defined in this file. The main motivation of this change to customize the model class based on the User Interface (UI) requirements. We didn't want to use UI-related [SelectItems(nameof(Authors))] and [DisplayName("Author")] attributes inside the CreateUpdateBookDto class.Authors property that is filled inside the OnGetAsync method using the IBookAppService.GetAuthorLookupAsync method defined before.OnPostAsync method to map CreateBookViewModel object to a CreateUpdateBookDto object since IBookAppService.CreateAsync expects a parameter of this type.Open the Pages/Books/EditModal.cshtml.cs in the Acme.BookStore.Web project and change the file content as shown below:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Books;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;
namespace Acme.BookStore.Web.Pages.Books;
public class EditModalModel : BookStorePageModel
{
[BindProperty]
public EditBookViewModel Book { get; set; }
public List<SelectListItem> Authors { get; set; }
private readonly IBookAppService _bookAppService;
public EditModalModel(IBookAppService bookAppService)
{
_bookAppService = bookAppService;
}
public async Task OnGetAsync(Guid id)
{
var bookDto = await _bookAppService.GetAsync(id);
Book = ObjectMapper.Map<BookDto, EditBookViewModel>(bookDto);
var authorLookup = await _bookAppService.GetAuthorLookupAsync();
Authors = authorLookup.Items
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToList();
}
public async Task<IActionResult> OnPostAsync()
{
await _bookAppService.UpdateAsync(
Book.Id,
ObjectMapper.Map<EditBookViewModel, CreateUpdateBookDto>(Book)
);
return NoContent();
}
public class EditBookViewModel
{
[HiddenInput]
public Guid Id { get; set; }
[SelectItems(nameof(Authors))]
[DisplayName("Author")]
public Guid AuthorId { get; set; }
[Required]
[StringLength(128)]
public string Name { get; set; } = string.Empty;
[Required]
public BookType Type { get; set; } = BookType.Undefined;
[Required]
[DataType(DataType.Date)]
public DateTime PublishDate { get; set; } = DateTime.Now;
[Required]
public float Price { get; set; }
}
}
Book property from CreateUpdateBookDto to the new EditBookViewModel class defined in this file, just like done before for the create modal above.Id property inside the new EditBookViewModel class.Authors property that is filled inside the OnGetAsync method using the IBookAppService.GetAuthorLookupAsync method.OnPostAsync method to map EditBookViewModel object to a CreateUpdateBookDto object since IBookAppService.UpdateAsync expects a parameter of this type.These changes require a small change in the EditModal.cshtml. Remove the <abp-input asp-for="Id" /> tag since we no longer need to it (since moved it to the EditBookViewModel). The final content of the EditModal.cshtml should be following:
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Web.Pages.Books
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model EditModalModel
@inject IStringLocalizer<BookStoreResource> L
@{
Layout = null;
}
<abp-dynamic-form abp-model="Book" asp-page="/Books/EditModal">
<abp-modal>
<abp-modal-header title="@L["Update"].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>
The changes above requires to define some object to object mappings. Open the BookStoreWebMappers.cs in the Acme.BookStore.Web project and create the following mapping definitions:
using Riok.Mapperly.Abstractions;
using Volo.Abp.Mapperly;
//...
[Mapper]
public partial class CreateBookViewModelToCreateUpdateBookDtoMapper : MapperBase<Pages.Books.CreateModalModel.CreateBookViewModel, CreateUpdateBookDto>
{
public override partial CreateUpdateBookDto Map(Pages.Books.CreateModalModel.CreateBookViewModel source);
public override partial void Map(Pages.Books.CreateModalModel.CreateBookViewModel source, CreateUpdateBookDto destination);
}
[Mapper]
public partial class BookDtoToEditBookViewModelMapper : MapperBase<BookDto, Pages.Books.EditModalModel.EditBookViewModel>
{
public override partial Pages.Books.EditModalModel.EditBookViewModel Map(BookDto source);
public override partial void Map(BookDto source, Pages.Books.EditModalModel.EditBookViewModel destination);
}
[Mapper]
public partial class EditBookViewModelToCreateUpdateBookDtoMapper : MapperBase<Pages.Books.EditModalModel.EditBookViewModel, CreateUpdateBookDto>
{
public override partial CreateUpdateBookDto Map(Pages.Books.EditModalModel.EditBookViewModel source);
public override partial void Map(Pages.Books.EditModalModel.EditBookViewModel source, CreateUpdateBookDto destination);
}
You can run the application and try to create a new book or update an existing book. You will see a drop down list on the create/update form to select the author of the book:
{{else if UI=="NG"}}
Since the HTTP APIs have been changed, you need to update Angular client side service proxies. Before running generate-proxy command, your host must be up and running.
Run the following command in the angular folder (you may need to stop the angular application):
abp generate-proxy -t ng
This command will update the service proxy files under the /src/app/proxy/ folder.
Book list page change is trivial. Open the /src/app/book/book.component.html and add the following column definition between the Name and Type columns:
<ngx-datatable-column
[name]="'::Author' | abpLocalization"
prop="authorName"
[sortable]="false"
/>
When you run the application, you can see the Author column on the table:
The next step is to add an Author selection (dropdown) to the create/edit forms. The final UI will look like the one shown below:
Added the Author dropdown as the first element in the form.
Open the /src/app/book/book.component.ts and and change the content as shown below:
import { ListService, PagedResultDto } from '@abp/ng.core';
import { Component, OnInit } from '@angular/core';
import { BookService, BookDto, bookTypeOptions, AuthorLookupDto } from '@proxy/books';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap';
import { ConfirmationService, Confirmation } from '@abp/ng.theme.shared';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({
selector: 'app-book',
templateUrl: './book.component.html',
styleUrls: ['./book.component.scss'],
providers: [ListService, { provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }],
})
export class BookComponent implements OnInit {
book = { items: [], totalCount: 0 } as PagedResultDto<BookDto>;
form: FormGroup;
selectedBook = {} as BookDto;
authors$: Observable<AuthorLookupDto[]>;
bookTypes = bookTypeOptions;
isModalOpen = false;
public readonly list = inject(ListService);
private readonly bookService = inject(BookService);
private readonly fb = inject(FormBuilder);
private readonly confirmation = inject(ConfirmationService);
constructor() {
this.authors$ = bookService.getAuthorLookup().pipe(map((r) => r.items));
}
ngOnInit() {
const bookStreamCreator = (query) => this.bookService.getList(query);
this.list.hookToQuery(bookStreamCreator).subscribe((response) => {
this.book = response;
});
}
createBook() {
this.selectedBook = {} as BookDto;
this.buildForm();
this.isModalOpen = true;
}
editBook(id: string) {
this.bookService.get(id).subscribe((book) => {
this.selectedBook = book;
this.buildForm();
this.isModalOpen = true;
});
}
buildForm() {
this.form = this.fb.group({
authorId: [this.selectedBook.authorId || null, Validators.required],
name: [this.selectedBook.name || null, 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;
}
const request = this.selectedBook.id
? this.bookService.update(this.selectedBook.id, this.form.value)
: this.bookService.create(this.form.value);
request.subscribe(() => {
this.isModalOpen = false;
this.form.reset();
this.list.get();
});
}
delete(id: string) {
this.confirmation.warn('::AreYouSureToDelete', 'AbpAccount::AreYouSure').subscribe((status) => {
if (status === Confirmation.Status.confirm) {
this.bookService.delete(id).subscribe(() => this.list.get());
}
});
}
}
AuthorLookupDto, Observable and map.authors$: Observable<AuthorLookupDto[]>; field after the selectedBook.this.authors$ = bookService.getAuthorLookup().pipe(map((r) => r.items)); into the constructor. authorId: [this.selectedBook.authorId || null, Validators.required], into the buildForm() function.Open the /src/app/book/book.component.html and add the following form group just before the book name form group:
<div class="form-group">
<label for="author-id">Author</label><span> * </span>
<select class="form-control" id="author-id" formControlName="authorId">
<option [ngValue]="null">Select author</option>
@for (author of authors$ | async; track author) {
<option [ngValue]="author.id">
{%{{{ author.name }}}%}
</option>
}
</select>
</div>
That's all. Just run the application and try to create or edit an author.
{{end}}
{{if UI == "Blazor" || UI == "BlazorServer" || UI == "BlazorWebApp" || UI == "MAUIBlazor" }}
It is very easy to show the Author Name in the book list. Open the /Pages/Books.razor file in the {{ if UI == "BlazorServer" }}Acme.BookStore.Blazor {{ else if UI == "MAUIBlazor" }}Acme.BookStore.MauiBlazor {{ else }}Acme.BookStore.Blazor.Client{{ end }} project and add the following DataGridColumn definition just after the Name (book name) column:
<DataGridColumn TItem="BookDto"
Field="@nameof(BookDto.AuthorName)"
Caption="@L["Author"]"></DataGridColumn>
When you run the application, you can see the Author column on the table:
Add the following field to the @code section of the Books.razor file:
IReadOnlyList<AuthorLookupDto> authorList = Array.Empty<AuthorLookupDto>();
Override the OnInitializedAsync method and adding the following code:
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
authorList = (await AppService.GetAuthorLookupAsync()).Items;
}
base.OnInitializedAsync() since AbpCrudPageBase has some initialization code to be executed.Override the OpenCreateModalAsync method and adding the following code:
protected override async Task OpenCreateModalAsync()
{
if (!authorList.Any())
{
throw new UserFriendlyException(message: L["AnAuthorIsRequiredForCreatingBook"]);
}
await base.OpenCreateModalAsync();
NewEntity.AuthorId = authorList.First().Id;
}
The final @code block should be the following:
@code
{
//ADDED A NEW FIELD
IReadOnlyList<AuthorLookupDto> authorList = Array.Empty<AuthorLookupDto>();
public Books() // Constructor
{
LocalizationResource = typeof(BookStoreResource);
CreatePolicyName = BookStorePermissions.Books.Create;
UpdatePolicyName = BookStorePermissions.Books.Edit;
DeletePolicyName = BookStorePermissions.Books.Delete;
}
//GET AUTHORS ON INITIALIZATION
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
authorList = (await AppService.GetAuthorLookupAsync()).Items;
}
protected override async Task OpenCreateModalAsync()
{
if (!authorList.Any())
{
throw new UserFriendlyException(message: L["AnAuthorIsRequiredForCreatingBook"]);
}
await base.OpenCreateModalAsync();
NewEntity.AuthorId = authorList.First().Id;
}
}
Finally, add the following Field definition into the ModalBody of the Create modal, as the first item, before the Name field:
<Field>
<FieldLabel>@L["Author"]</FieldLabel>
<Select TValue="Guid" @bind-SelectedValue="@NewEntity.AuthorId">
@foreach (var author in authorList)
{
<SelectItem TValue="Guid" Value="@author.Id">
@author.Name
</SelectItem>
}
</Select>
</Field>
This requires to add a new localization key to the en.json file:
"AnAuthorIsRequiredForCreatingBook": "An author is required to create a book"
You can run the application to see the Author Selection while creating a new book:
Add the following Field definition into the ModalBody of the Edit modal, as the first item, before the Name field:
<Field>
<FieldLabel>@L["Author"]</FieldLabel>
<Select TValue="Guid" @bind-SelectedValue="@EditingEntity.AuthorId">
@foreach (var author in authorList)
{
<SelectItem TValue="Guid" Value="@author.Id">
@author.Name
</SelectItem>
}
</Select>
</Field>
That's all. We are reusing the authorList defined for the Create modal.
{{end}}