@ -0,0 +1,73 @@ |
|||||
|
# Quartz Background Job Manager |
||||
|
|
||||
|
[Quartz](https://www.quartz-scheduler.net/) is an advanced background job manager. You can integrate Quartz with the ABP Framework to use it instead of the [default background job manager](Background-Jobs.md). In this way, you can use the same background job API for Quartz and your code will be independent of Quartz. If you like, you can directly use Quartz's API, too. |
||||
|
|
||||
|
> See the [background jobs document](Background-Jobs.md) to learn how to use the background job system. This document only shows how to install and configure the Quartz integration. |
||||
|
|
||||
|
## Installation |
||||
|
|
||||
|
It is suggested to use the [ABP CLI](CLI.md) to install this package. |
||||
|
|
||||
|
### Using the ABP CLI |
||||
|
|
||||
|
Open a command line window in the folder of the project (.csproj file) and type the following command: |
||||
|
|
||||
|
````bash |
||||
|
abp add-package Volo.Abp.BackgroundJobs.Quartz |
||||
|
```` |
||||
|
|
||||
|
### Manual Installation |
||||
|
|
||||
|
If you want to manually install; |
||||
|
|
||||
|
1. Add the [Volo.Abp.BackgroundJobs.Quartz](https://www.nuget.org/packages/Volo.Abp.BackgroundJobs.Quartz) NuGet package to your project: |
||||
|
|
||||
|
```` |
||||
|
Install-Package Volo.Abp.BackgroundJobs.Quartz |
||||
|
```` |
||||
|
|
||||
|
2. Add the `AbpBackgroundJobsQuartzModule` to the dependency list of your module: |
||||
|
|
||||
|
````csharp |
||||
|
[DependsOn( |
||||
|
//...other dependencies |
||||
|
typeof(AbpBackgroundJobsQuartzModule) //Add the new module dependency |
||||
|
)] |
||||
|
public class YourModule : AbpModule |
||||
|
{ |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
## Configuration |
||||
|
|
||||
|
Quartz is a very configurable library,and the ABP framework provides `AbpQuartzPreOptions` for this. You can use the `PreConfigure` method in your module class to pre-configure this option. ABP will use it when initializing the Quartz module. For example: |
||||
|
|
||||
|
````csharp |
||||
|
[DependsOn( |
||||
|
//...other dependencies |
||||
|
typeof(AbpBackgroundJobsQuartzModule) //Add the new module dependency |
||||
|
)] |
||||
|
public class YourModule : AbpModule |
||||
|
{ |
||||
|
public override void PreConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
var configuration = context.Services.GetConfiguration(); |
||||
|
|
||||
|
PreConfigure<AbpQuartzPreOptions>(options => |
||||
|
{ |
||||
|
options.Properties = new NameValueCollection |
||||
|
{ |
||||
|
["quartz.jobStore.dataSource"] = "BackgroundJobsDemoApp", |
||||
|
["quartz.jobStore.type"] = "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz", |
||||
|
["quartz.jobStore.tablePrefix"] = "QRTZ_", |
||||
|
["quartz.serializer.type"] = "json", |
||||
|
["quartz.dataSource.BackgroundJobsDemoApp.connectionString"] = configuration.GetConnectionString("Quartz"), |
||||
|
["quartz.dataSource.BackgroundJobsDemoApp.provider"] = "SqlServer", |
||||
|
["quartz.jobStore.driverDelegateType"] = "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz", |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Quartz stores job and scheduling information **in memory by default**. In the example, we use the pre-configuration of [options pattern](Options.md) to change it to the database. For more configuration of Quartz, please refer to the Quartz's [documentation](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/index.html). |
||||
@ -0,0 +1,3 @@ |
|||||
|
# Background Workers |
||||
|
|
||||
|
TODO |
||||
@ -0,0 +1,68 @@ |
|||||
|
# Quartz Background Worker Manager |
||||
|
|
||||
|
[Quartz](https://www.quartz-scheduler.net/) is an advanced background worker manager. You can integrate Quartz with the ABP Framework to use it instead of the [default background worker manager](Background-Worker.md). ABP simply integrates quartz. |
||||
|
|
||||
|
## Installation |
||||
|
|
||||
|
It is suggested to use the [ABP CLI](CLI.md) to install this package. |
||||
|
|
||||
|
### Using the ABP CLI |
||||
|
|
||||
|
Open a command line window in the folder of the project (.csproj file) and type the following command: |
||||
|
|
||||
|
````bash |
||||
|
abp add-package Volo.Abp.BackgroundWorkers.Quartz |
||||
|
```` |
||||
|
|
||||
|
### Manual Installation |
||||
|
|
||||
|
If you want to manually install; |
||||
|
|
||||
|
1. Add the [Volo.Abp.BackgroundWorkers.Quartz](https://www.nuget.org/packages/Volo.Abp.BackgroundWorkers.Quartz) NuGet package to your project: |
||||
|
|
||||
|
```` |
||||
|
Install-Package Volo.Abp.BackgroundWorkers.Quartz |
||||
|
```` |
||||
|
|
||||
|
2. Add the `AbpBackgroundWorkersQuartzModule` to the dependency list of your module: |
||||
|
|
||||
|
````csharp |
||||
|
[DependsOn( |
||||
|
//...other dependencies |
||||
|
typeof(AbpBackgroundWorkersQuartzModule) //Add the new module dependency |
||||
|
)] |
||||
|
public class YourModule : AbpModule |
||||
|
{ |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### Configuration |
||||
|
|
||||
|
See [Configuration](Background-Jobs-Quartz.md#Configuration). |
||||
|
|
||||
|
### Create a Background Worker |
||||
|
|
||||
|
A background work is a class that derives from the `QuartzBackgroundWorkerBase` base class. for example. A simple worker class is shown below: |
||||
|
|
||||
|
```` csharp |
||||
|
public class MyLogWorker : QuartzBackgroundWorkerBase |
||||
|
{ |
||||
|
public MyLogWorker() |
||||
|
{ |
||||
|
JobDetail = JobBuilder.Create<MyLogWorker>().Build(); |
||||
|
Trigger = TriggerBuilder.Create().StartNow().Build(); |
||||
|
} |
||||
|
|
||||
|
public override Task Execute(IJobExecutionContext context) |
||||
|
{ |
||||
|
Logger.LogInformation("Executed MyLogWorker..!"); |
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
We simply implemented the Execute method to write a log. The background worker is a **singleton by default**. If you want, you can also implement a [dependency interface](Dependency-Injection.md#DependencyInterfaces) to register it as another life cycle. |
||||
|
|
||||
|
### More |
||||
|
|
||||
|
Please see Quartz's [documentation](https://www.quartz-scheduler.net/documentation/index.html) for more information. |
||||
@ -0,0 +1,912 @@ |
|||||
|
# EF Core Database Migrations |
||||
|
|
||||
|
This document begins by **introducing the default structure** provided by [the application startup template](Startup-Templates/Application.md) and **discusses various scenarios** you may want to implement for your own application. |
||||
|
|
||||
|
> This document is for who want to fully understand and customize the database structure comes with [the application startup template](Startup-Templates/Application.md). If you simply want to create entities and manage your code first migrations, just follow [the startup tutorials](Tutorials/Index.md). |
||||
|
|
||||
|
### Source Code |
||||
|
|
||||
|
You can find the source code of the example project referenced by this document [here](https://github.com/abpframework/abp/tree/dev/samples/EfCoreMigrationDemo). However, you need to read and understand this document in order to understand the example project's source code. |
||||
|
|
||||
|
## About the EF Core Code First Migrations |
||||
|
|
||||
|
Entity Framework Core provides an easy to use and powerful [database migration system](https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/). ABP Framework [startup templates](Startup-Templates/Index.md) take the advantage of this system to allow you to develop your application in a standard way. |
||||
|
|
||||
|
However, EF Core migration system is **not so good in a modular environment** where each module maintains its **own database schema** while two or more modules may **share a single database** in practical. |
||||
|
|
||||
|
Since ABP Framework cares about modularity in all aspects, it provides a **solution** to this problem. It is important to understand this solution if you need to **customize your database structure**. |
||||
|
|
||||
|
> See [EF Core's own documentation](https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/) to fully learn the EF Core Code First Migrations and why you need to such a system. |
||||
|
|
||||
|
## The Default Solution & Database Configuration |
||||
|
|
||||
|
When you [create a new web application](https://abp.io/get-started) (with EF Core, which is the default database provider), your solution structure will be similar to the picture below: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
Actual solution structure may be a bit different based on your preferences, but the database part will be same. |
||||
|
|
||||
|
> This document will use the `Acme.BookStore` example project name to refer the projects and classes. You need to find the corresponding class/project in your solution. |
||||
|
|
||||
|
### The Database Structure |
||||
|
|
||||
|
The startup template has some [application modules](Modules/Index.md) pre-installed. Each layer of the solution has corresponding module **package references**. So, the `.EntityFrameworkCore` project has the NuGet references for the `.EntityFrameworkCore` packages of the used modules: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
In this way, you collect all the **EF Core dependencies** under the `.EntityFrameworkCore` project. |
||||
|
|
||||
|
> In addition to the module references, it references to the `Volo.Abp.EntityFrameworkCore.SqlServer` package since the startup template is pre-configured for the **SQL Server**. See the documentation if you want to [switch to another DBMS](Entity-Framework-Core-Other-DBMS.md). |
||||
|
|
||||
|
While every module has its own `DbContext` class by design and can use its **own physical database**, the solution is configured to use a **single shared database** as shown in the figure below: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
This is **the simplest configuration** and suitable for most of the applications. `appsettings.json` file has a **single connection string**, named `Default`: |
||||
|
|
||||
|
````json |
||||
|
"ConnectionStrings": { |
||||
|
"Default": "..." |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
So, you have a **single database schema** which contains all the tables of the modules **sharing** this database. |
||||
|
|
||||
|
ABP Framework's [connection string](Connection-Strings.md) system allows you to easily **set a different connection string** for a desired module: |
||||
|
|
||||
|
````json |
||||
|
"ConnectionStrings": { |
||||
|
"Default": "...", |
||||
|
"AbpAuditLogging": "..." |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
The example configuration about tells to the ABP Framework to use the second connection string for the [Audit Logging module](Modules/Audit-Logging.md). |
||||
|
|
||||
|
**However, this is just the beginning**. You also need to create the second database, create audit log tables inside it and maintain the database tables using the code first migrations approach. One of the main purposes of this document is to guide you on such **database separation** scenarios. |
||||
|
|
||||
|
#### Module Tables |
||||
|
|
||||
|
Every module uses its **own databases tables**. For example, the [Identity Module](Modules/Identity.md) has some tables to manage the users and roles in the system. |
||||
|
|
||||
|
##### Table Prefixes |
||||
|
|
||||
|
Since it is allowed to share a single database by all modules (it is the default configuration), a module typically uses a **table name prefix** to group its own tables. |
||||
|
|
||||
|
The fundamental modules, like [Identity](Modules/Identity.md), [Tenant Management](Modules/Tenant-Management.md) and [Audit Logs](Modules/Audit-Logging.md), use the `Abp` prefix, while some other modules use their own prefixes. [Identity Server](Modules/IdentityServer.md) module uses the `IdentityServer` prefix for example. |
||||
|
|
||||
|
If you want, you can **change the database table name prefix** for a module for your application. Example: |
||||
|
|
||||
|
````csharp |
||||
|
Volo.Abp.IdentityServer.AbpIdentityServerDbProperties.DbTablePrefix = "Ids"; |
||||
|
```` |
||||
|
|
||||
|
This code changes the prefix of the [Identity Server](Modules/IdentityServer.md) module. Write this code **at the very beginning** in your application. |
||||
|
|
||||
|
> Every module also defines `DbSchema` property (near to `DbTablePrefix`), so you can set it for the databases support the schema usage. |
||||
|
|
||||
|
### The Projects |
||||
|
|
||||
|
From the database point of view, there are three important projects those will be explained in the next sections. |
||||
|
|
||||
|
#### .EntityFrameworkCore Project |
||||
|
|
||||
|
This project has the `DbContext` class (`BookStoreDbContext` for this sample) of your application. |
||||
|
|
||||
|
**Every module uses its own `DbContext` class** to access to the database. Likewise, your application has its own `DbContext`. You typically use this `DbContext` in your application code (in your custom [repositories](Repositories.md) if you follow the best practices). It is almost an empty `DbContext` since your application don't have any entities at the beginning, except the pre-defined `AppUser` entity: |
||||
|
|
||||
|
````csharp |
||||
|
[ConnectionStringName("Default")] |
||||
|
public class BookStoreDbContext : AbpDbContext<BookStoreDbContext> |
||||
|
{ |
||||
|
public DbSet<AppUser> Users { get; set; } |
||||
|
|
||||
|
/* Add DbSet properties for your Aggregate Roots / Entities here. */ |
||||
|
|
||||
|
public BookStoreDbContext(DbContextOptions<BookStoreDbContext> options) |
||||
|
: base(options) |
||||
|
{ |
||||
|
|
||||
|
} |
||||
|
|
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
base.OnModelCreating(builder); |
||||
|
|
||||
|
/* Configure the shared tables (with included modules) here */ |
||||
|
|
||||
|
builder.Entity<AppUser>(b => |
||||
|
{ |
||||
|
//Sharing the same table "AbpUsers" with the IdentityUser |
||||
|
b.ToTable("AbpUsers"); |
||||
|
|
||||
|
//Configure base properties |
||||
|
b.ConfigureByConvention(); |
||||
|
b.ConfigureAbpUser(); |
||||
|
|
||||
|
//Moved customization of the "AbpUsers" table to an extension method |
||||
|
b.ConfigureCustomUserProperties(); |
||||
|
}); |
||||
|
|
||||
|
/* Configure your own tables/entities inside the ConfigureBookStore method */ |
||||
|
builder.ConfigureBookStore(); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
This simple `DbContext` class still needs some explanations: |
||||
|
|
||||
|
* It defines a `[ConnectionStringName]` attribute which tells ABP to always use the `Default` connection string for this `Dbcontext`. |
||||
|
* It inherits from the `AbpDbContext<T>` instead of the standard `DbContext` class. You can see the [EF Core integration](Entity-Framework-Core.md) document for more. For now, know that the `AbpDbContext<T>` base class implements some conventions of the ABP Framework to automate some common tasks for you. |
||||
|
* It declares a `DbSet` property for the `AppUser` entity. `AppUser` shares the same table (named `AbpUsers` by default) with the `IdentityUser` entity of the [Identity module](Modules/Identity.md). The startup template provides this entity inside the application since we think that the User entity is generally needs to be customized in your application. |
||||
|
* The constructor takes a `DbContextOptions<T>` instance. |
||||
|
* It overrides the `OnModelCreating` method to define the EF Core mappings. |
||||
|
* It first calls the the `base.OnModelCreating` method to let the ABP Framework to implement the base mappings for us. |
||||
|
* It then configures the mapping for the `AppUser` entity. There is a special case for this entity (it shares a table with the Identity module), which will be explained in the next sections. |
||||
|
* It finally calls the `builder.ConfigureBookStore()` extension method to configure other entities of your application. |
||||
|
|
||||
|
This design will be explained in more details after introducing the other database related projects. |
||||
|
|
||||
|
#### .EntityFrameworkCore.DbMigrations Project |
||||
|
|
||||
|
As mentioned in the previous section, every module (and your application) have **their own** separate `DbContext` classes. Each `DbContext` class only defines the entity to table mappings related to its own module and each module (and your application) use the related `DbContext` class **on runtime**. |
||||
|
|
||||
|
As you know, EF Core Code First migration system relies on a `DbContext` class **to track and generate** the code first migrations. So, which `DbContext` we should use for the migrations? The answer is *none of them*. There is another `DbContext` defined in the `.EntityFrameworkCore.DbMigrations` project (which is the `BookStoreMigrationsDbContext` for this example solution). |
||||
|
|
||||
|
##### The MigrationsDbContext |
||||
|
|
||||
|
The `MigrationsDbContext` is only used to create and apply the database migrations. It is **not used on runtime**. It **merges** all the entity to table mappings of all the used modules plus the application's mappings. |
||||
|
|
||||
|
In this way, you create and maintain a **single database migration path**. However, there are some difficulties of this approach and the next sections explains how ABP Framework overcomes these difficulties. But first, see the `BookStoreMigrationsDbContext` class as an example: |
||||
|
|
||||
|
````csharp |
||||
|
/* This DbContext is only used for database migrations. |
||||
|
* It is not used on runtime. See BookStoreDbContext for the runtime DbContext. |
||||
|
* It is a unified model that includes configuration for |
||||
|
* all used modules and your application. |
||||
|
*/ |
||||
|
public class BookStoreMigrationsDbContext : AbpDbContext<BookStoreMigrationsDbContext> |
||||
|
{ |
||||
|
public BookStoreMigrationsDbContext( |
||||
|
DbContextOptions<BookStoreMigrationsDbContext> options) |
||||
|
: base(options) |
||||
|
{ |
||||
|
|
||||
|
} |
||||
|
|
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
base.OnModelCreating(builder); |
||||
|
|
||||
|
/* Include modules to your migration db context */ |
||||
|
builder.ConfigurePermissionManagement(); |
||||
|
builder.ConfigureSettingManagement(); |
||||
|
builder.ConfigureBackgroundJobs(); |
||||
|
builder.ConfigureAuditLogging(); |
||||
|
builder.ConfigureIdentity(); |
||||
|
builder.ConfigureIdentityServer(); |
||||
|
builder.ConfigureFeatureManagement(); |
||||
|
builder.ConfigureTenantManagement(); |
||||
|
|
||||
|
/* Configure customizations for entities from the modules included */ |
||||
|
builder.Entity<IdentityUser>(b => |
||||
|
{ |
||||
|
b.ConfigureCustomUserProperties(); |
||||
|
}); |
||||
|
|
||||
|
/* Configure your own tables/entities inside the ConfigureBookStore method */ |
||||
|
builder.ConfigureBookStore(); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
##### Sharing the Mapping Code |
||||
|
|
||||
|
First problem is that: A module uses its own `DbContext` which needs to the database mappings. The `MigrationsDbContext` also needs to the same mapping in order to create the database tables for this module. We definitely **don't want to duplicate** the mapping code. |
||||
|
|
||||
|
The solution is to define an **extension method** (on the `ModelBuilder`) that can be called by both `DbContext` classes. So, all modules define such extension methods. |
||||
|
|
||||
|
For example, the `builder.ConfigureBackgroundJobs()` method call configures the database tables for the [Background Jobs module](Modules/Background-Jobs.md). The definition of this extension method is something like that: |
||||
|
|
||||
|
````csharp |
||||
|
public static class BackgroundJobsDbContextModelCreatingExtensions |
||||
|
{ |
||||
|
public static void ConfigureBackgroundJobs( |
||||
|
this ModelBuilder builder, |
||||
|
Action<BackgroundJobsModelBuilderConfigurationOptions> optionsAction = null) |
||||
|
{ |
||||
|
var options = new BackgroundJobsModelBuilderConfigurationOptions( |
||||
|
BackgroundJobsDbProperties.DbTablePrefix, |
||||
|
BackgroundJobsDbProperties.DbSchema |
||||
|
); |
||||
|
|
||||
|
optionsAction?.Invoke(options); |
||||
|
|
||||
|
builder.Entity<BackgroundJobRecord>(b => |
||||
|
{ |
||||
|
b.ToTable(options.TablePrefix + "BackgroundJobs", options.Schema); |
||||
|
|
||||
|
b.ConfigureCreationTime(); |
||||
|
b.ConfigureExtraProperties(); |
||||
|
|
||||
|
b.Property(x => x.JobName) |
||||
|
.IsRequired() |
||||
|
.HasMaxLength(BackgroundJobRecordConsts.MaxJobNameLength); |
||||
|
|
||||
|
//... |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
This extension method also gets options to change the database table prefix and schema for this module, but it is not important here. |
||||
|
|
||||
|
The final application calls the extension methods inside the `MigrationsDbContext` class, so it can decide which modules are included in the database maintained by this `MigrationsDbContext`. If you want to create a second database and move some module tables to the second database, then you need to have a second `MigrationsDbContext` class which only calls the extension methods of the related modules. This topic will be detailed in the next sections. |
||||
|
|
||||
|
The same `ConfigureBackgroundJobs` method is also called in the `DbContext` of the Background Jobs module: |
||||
|
|
||||
|
````csharp |
||||
|
[ConnectionStringName(BackgroundJobsDbProperties.ConnectionStringName)] |
||||
|
public class BackgroundJobsDbContext |
||||
|
: AbpDbContext<BackgroundJobsDbContext>, IBackgroundJobsDbContext |
||||
|
{ |
||||
|
public DbSet<BackgroundJobRecord> BackgroundJobs { get; set; } |
||||
|
|
||||
|
public BackgroundJobsDbContext(DbContextOptions<BackgroundJobsDbContext> options) |
||||
|
: base(options) |
||||
|
{ |
||||
|
|
||||
|
} |
||||
|
|
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
base.OnModelCreating(builder); |
||||
|
|
||||
|
//Reuse the same extension method! |
||||
|
builder.ConfigureBackgroundJobs(); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
In this way, the mapping configuration of a module can be shared between `DbContext` classes. The code above is inside the related module NuGet package, so you don't care about it. |
||||
|
|
||||
|
##### Reusing a Table of a Module |
||||
|
|
||||
|
You may want to **reuse a table** of a depended module in your application. In this case, you have two options: |
||||
|
|
||||
|
1. You can **directly use the entity** defined by the module. |
||||
|
2. You can **create a new entity** mapping to the same database table. |
||||
|
|
||||
|
###### Use the Entity Defined by a Module |
||||
|
|
||||
|
Using an entity defined a module is pretty easy and standard. For example, Identity module defines the `IdentityUser` entity. You can inject the [repository](Repositories.md) for the `IdentityUser` and perform the standard repository operations for this entity. Example: |
||||
|
|
||||
|
````csharp |
||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.Domain.Repositories; |
||||
|
using Volo.Abp.Identity; |
||||
|
|
||||
|
namespace Acme.BookStore |
||||
|
{ |
||||
|
public class MyService : ITransientDependency |
||||
|
{ |
||||
|
private readonly IRepository<IdentityUser, Guid> _identityUserRepository; |
||||
|
|
||||
|
public MyService(IRepository<IdentityUser, Guid> identityUserRepository) |
||||
|
{ |
||||
|
_identityUserRepository = identityUserRepository; |
||||
|
} |
||||
|
|
||||
|
public async Task DoItAsync() |
||||
|
{ |
||||
|
//Get all users |
||||
|
var users = await _identityUserRepository.GetListAsync(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
This example injects the `IRepository<IdentityUser, Guid>` (default repository) which defines the standard repository methods and implements the `IQueryable` interface. |
||||
|
|
||||
|
> In addition, Identity module defines the `IIdentityUserRepository` (custom repository) that can also be injected and used by your application. `IIdentityUserRepository` provides additional custom methods for the `IdentityUser` entity while it does not implement the `IQueryable` interface. |
||||
|
|
||||
|
###### Create a New Entity |
||||
|
|
||||
|
Working with an entity of a module is easy if you want to use the entity as is. However, you may want to define your own entity class and map to the same database table in the following cases; |
||||
|
|
||||
|
* You want to **add a new field** to the table and map it to a property in the entity. You can't use the module's entity since it doesn't have the related property. |
||||
|
* You want to **use a subset of the table fields**. You don't want to access to all properties of the entity and hide the unrelated properties (from a security perspective or just by design). |
||||
|
* You don't want to directly **depend on** a module entity class. |
||||
|
|
||||
|
In any case, the progress is same. Assume that you want to create an entity, named `AppRole`, mapped to the same table of the `IdentityRole` entity of the [Identity module](Modules/Identity.md). |
||||
|
|
||||
|
Here, we will show the implementation, then **will discuss the limitations** of this approach. |
||||
|
|
||||
|
First, create a new `AppRole` class in your `.Domain` project: |
||||
|
|
||||
|
````csharp |
||||
|
using System; |
||||
|
using Volo.Abp.Domain.Entities; |
||||
|
using Volo.Abp.MultiTenancy; |
||||
|
|
||||
|
namespace Acme.BookStore.Roles |
||||
|
{ |
||||
|
public class AppRole : AggregateRoot<Guid>, IMultiTenant |
||||
|
{ |
||||
|
// Properties shared with the IdentityRole class |
||||
|
|
||||
|
public Guid? TenantId { get; private set; } |
||||
|
public string Name { get; private set; } |
||||
|
|
||||
|
//Additional properties |
||||
|
|
||||
|
public string Title { get; set; } |
||||
|
|
||||
|
private AppRole() |
||||
|
{ |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
* It's inherited from [the `AggregateRoot<Guid>` class](Entities.md) and implements [the `IMultiTenant` interface](Multi-Tenancy.md) because the `IdentityRole` also does the same. |
||||
|
* You can add any properties defined by the `IdentityRole` entity. This examples add only the `TenantId` and `Name` properties since we only need them here. You can make the setters private (like in this example) to prevent changing Identity module's properties accidently. |
||||
|
* You can add custom (additional) properties. This example adds the `Title` property. |
||||
|
* The **constructor is private**, so it is not allowed to directly create a new `AppRole` entity. Creating a role is a responsibility of the Identity module. You can query roles, set/update your custom properties, but you should not create or delete a role in your code, as a best practice (while there is nothing restricts you). |
||||
|
|
||||
|
Now, it is time to define the EF Core mappings. Open the `DbContext` of your application (`BookStoreDbContext` in this sample) and add the following property: |
||||
|
|
||||
|
````csharp |
||||
|
public DbSet<AppRole> Roles { get; set; } |
||||
|
```` |
||||
|
|
||||
|
Then configure the mapping inside the `OnModelCreating` method (after calling the `base.OnModelCreating(builder)`): |
||||
|
|
||||
|
````csharp |
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
base.OnModelCreating(builder); |
||||
|
|
||||
|
/* Configure the shared tables (with included modules) here */ |
||||
|
|
||||
|
//CONFIGURE THE AppRole ENTITY |
||||
|
builder.Entity<AppRole>(b => |
||||
|
{ |
||||
|
b.ToTable("AbpRoles"); |
||||
|
|
||||
|
b.ConfigureByConvention(); |
||||
|
|
||||
|
b.ConfigureCustomRoleProperties(); |
||||
|
}); |
||||
|
|
||||
|
... |
||||
|
|
||||
|
/* Configure your own tables/entities inside the ConfigureBookStore method */ |
||||
|
|
||||
|
builder.ConfigureBookStore(); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
We added the following lines: |
||||
|
|
||||
|
````csharp |
||||
|
builder.Entity<AppRole>(b => |
||||
|
{ |
||||
|
b.ToTable("AbpRoles"); |
||||
|
|
||||
|
b.ConfigureByConvention(); |
||||
|
|
||||
|
b.ConfigureCustomRoleProperties(); |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
* It maps to the same `AbpRoles` table shared with the `IdentityRole` entity. |
||||
|
* `ConfigureByConvention()` configures the standard/base properties (like `TenantId`) and recommended to always call it. |
||||
|
|
||||
|
`ConfigureCustomRoleProperties()` has not exists yet. Define it inside the `BookStoreDbContextModelCreatingExtensions` class (near to your `DbContext` in the `.EntityFrameworkCore` project): |
||||
|
|
||||
|
````csharp |
||||
|
public static void ConfigureCustomRoleProperties<TRole>(this EntityTypeBuilder<TRole> b) |
||||
|
where TRole : class, IEntity<Guid> |
||||
|
{ |
||||
|
b.Property<string>(nameof(AppRole.Title)).HasMaxLength(128); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
* This method only defines the **custom properties** of your entity. |
||||
|
* Unfortunately, we can not utilize the fully **type safety** here (by referencing the `AppRole` entity). The best we can do is to use the `Title` name as type safe. This is because of EF Core migration system can not map two unrelated entity classes to the same database table. |
||||
|
|
||||
|
You've configured the custom property for your `DbContext` used by your application on the runtime. We also need to configure the `MigrationsDbContext`. |
||||
|
|
||||
|
Open the `MigrationsDbContext` (`BookStoreMigrationsDbContext` for this example) and change as shown below: |
||||
|
|
||||
|
````csharp |
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
base.OnModelCreating(builder); |
||||
|
|
||||
|
/* Include modules to your migration db context */ |
||||
|
|
||||
|
... |
||||
|
|
||||
|
/* Configure customizations for entities from the modules included */ |
||||
|
|
||||
|
//CONFIGURE THE CUSTOM ROLE PROPERTIES |
||||
|
builder.Entity<IdentityRole>(b => |
||||
|
{ |
||||
|
b.ConfigureCustomRoleProperties(); |
||||
|
}); |
||||
|
|
||||
|
... |
||||
|
|
||||
|
/* Configure your own tables/entities inside the ConfigureBookStore method */ |
||||
|
|
||||
|
builder.ConfigureBookStore(); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Only added the following lines: |
||||
|
|
||||
|
````csharp |
||||
|
builder.Entity<IdentityRole>(b => |
||||
|
{ |
||||
|
b.ConfigureCustomRoleProperties(); |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
In this way, we re-used the extension method that is used to configure custom property mappings for the role. But, this time, did the same customization for the `IdentityRole` entity. |
||||
|
|
||||
|
Now, you can add a new EF Core database migration using the standard `Add-Migration` command in the Package Manager Console (remember to select `.EntityFrameworkCore.DbMigrations` as the Default Project in the PMC and make sure that the `.Web` project is still the startup project): |
||||
|
|
||||
|
 |
||||
|
|
||||
|
This command will create a new code first migration class as shown below: |
||||
|
|
||||
|
````csharp |
||||
|
public partial class Added_Title_To_Roles : Migration |
||||
|
{ |
||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||
|
{ |
||||
|
migrationBuilder.AddColumn<string>( |
||||
|
name: "Title", |
||||
|
table: "AbpRoles", |
||||
|
maxLength: 128, |
||||
|
nullable: true); |
||||
|
} |
||||
|
|
||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||
|
{ |
||||
|
migrationBuilder.DropColumn( |
||||
|
name: "Title", |
||||
|
table: "AbpRoles"); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
All done! Just run the `Update-Database` command in the PMC or run the `.DbMigrator` project in your solution to apply changes to database. |
||||
|
|
||||
|
Now, you can work with the `AppRole` entity just like any other entity of your application. An example [application service](Application-Services.md) that queries and updates roles: |
||||
|
|
||||
|
````csharp |
||||
|
public class AppRoleAppService : ApplicationService, IAppRoleAppService |
||||
|
{ |
||||
|
private readonly IRepository<AppRole, Guid> _appRoleRepository; |
||||
|
|
||||
|
public AppRoleAppService(IRepository<AppRole, Guid> appRoleRepository) |
||||
|
{ |
||||
|
_appRoleRepository = appRoleRepository; |
||||
|
} |
||||
|
|
||||
|
public async Task<List<AppRoleDto>> GetListAsync() |
||||
|
{ |
||||
|
var roles = await _appRoleRepository.GetListAsync(); |
||||
|
|
||||
|
return roles |
||||
|
.Select(r => new AppRoleDto |
||||
|
{ |
||||
|
Id = r.Id, |
||||
|
Name = r.Name, |
||||
|
Title = r.Title |
||||
|
}) |
||||
|
.ToList(); |
||||
|
} |
||||
|
|
||||
|
public async Task UpdateTitleAsync(Guid id, string title) |
||||
|
{ |
||||
|
var role = await _appRoleRepository.GetAsync(id); |
||||
|
|
||||
|
role.Title = title; |
||||
|
|
||||
|
await _appRoleRepository.UpdateAsync(role); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
There are some **limitations** of creating a new entity and mapping it to a table of a depended module: |
||||
|
|
||||
|
* Your **custom properties must be nullable**. For example, `AppRole.Title` was nullable here. Otherwise, Identity module throws exception because it doesn't know and can not fill the Title when it inserts a new role to the database. |
||||
|
* As a good practice, you should not update the **properties defined by the module**, especially if it requires a business logic. You typically want to manage your own properties. |
||||
|
|
||||
|
##### Alternative Approaches |
||||
|
|
||||
|
Instead of creating a new entity class to add a custom property, you can use the following approaches. |
||||
|
|
||||
|
###### Using the ExtraProperties |
||||
|
|
||||
|
All entities derived from the `AggregateRoot ` class can store name-value pairs in their `ExtraProperties` property, which is a `Dictionary<string, object>` serialized to JSON in the database table. So, you can add values to this dictionary and query again without changing the entity. |
||||
|
|
||||
|
For example, you can store query the title Property inside an `IdentityRole` instead of creating a new entity. Example: |
||||
|
|
||||
|
````csharp |
||||
|
public class IdentityRoleExtendingService : ITransientDependency |
||||
|
{ |
||||
|
private readonly IIdentityRoleRepository _identityRoleRepository; |
||||
|
|
||||
|
public IdentityRoleExtendingService(IIdentityRoleRepository identityRoleRepository) |
||||
|
{ |
||||
|
_identityRoleRepository = identityRoleRepository; |
||||
|
} |
||||
|
|
||||
|
public async Task<string> GetTitleAsync(Guid id) |
||||
|
{ |
||||
|
var role = await _identityRoleRepository.GetAsync(id); |
||||
|
|
||||
|
return role.GetProperty<string>("Title"); |
||||
|
} |
||||
|
|
||||
|
public async Task SetTitleAsync(Guid id, string newTitle) |
||||
|
{ |
||||
|
var role = await _identityRoleRepository.GetAsync(id); |
||||
|
|
||||
|
role.SetProperty("Title", newTitle); |
||||
|
|
||||
|
await _identityRoleRepository.UpdateAsync(role); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
* `GetProperty` and `SetProperty` methods are shortcuts to get and set a value in the `role.ExtraProperties` dictionary and they are the recommended way to work with the extra properties. |
||||
|
|
||||
|
In this way, you can easily attach any type of value to an entity of a depended module. However, there are some drawbacks of this usage: |
||||
|
|
||||
|
* All the extra properties are stored as **a single JSON object** in the database. They are not stored as new table fields, as you may expect. Creating database table indexes and using SQL queries against these properties will be harder compared to simple table fields. |
||||
|
* Property names are strings, so they are **not type safe**. It is recommended to define constants for these kind of properties to prevent typo errors. |
||||
|
|
||||
|
###### Creating a New Table |
||||
|
|
||||
|
Instead of creating a new entity and mapping to the same table, you can also create **your own table** to store your properties. You typically duplicate some values of the original entity. For example, you can add `Name` field to your own table which is a duplication of the `Name` field in the original table. |
||||
|
|
||||
|
In this case, you don't deal with migration problems, however you need to deal with the problems of data duplication. When the duplicated value changes, you should reflect the same change in your table. You can use local or distributed [event bus](Event-Bus.md) to subscribe to the change events for the original entity. This is the recommended way of depending on a microservice's data from another microservice, especially if they have separate physical databases (you can search on the web on data sharing on a microservice design, it is a wide topic to cover here). |
||||
|
|
||||
|
#### Discussion of an Alternative Scenario: Every Module Manages Its Own Migration Path |
||||
|
|
||||
|
As mentioned before, `.EntityFrameworkCore.DbMigrations` merges all the database mappings of all the modules (plus your application's mappings) to create a unified migration path. |
||||
|
|
||||
|
An alternative approach would be to allow each module to have its own migrations to maintain its database tables. While it seems more module in the beginning, it has some important drawbacks: |
||||
|
|
||||
|
* **EF Core migration system depends on the DBMS provider**. For example, if a module has created migrations for SQL Server, then you can not use this migration code for MySQL. It is not practical for a module to maintain migrations for all available DBMS providers. Leaving the migration to the application code (as explained in this document) allows you to **choose the DBMS in the application** code. |
||||
|
* It would be harder or impossible to **share a table** between modules or **re-use a table** of a module in your application. Because EF Core migration system can not handle it and will throw exceptions like "Table XXX is already exists in the database". |
||||
|
* It would be harder to **customize/enhance** the mapping and the resulting migration code. |
||||
|
* It would be harder to track and **apply changes** to database when you use multiple modules. |
||||
|
|
||||
|
## Using Multiple Databases |
||||
|
|
||||
|
The default startup template is organized to use a single database used by all the modules and by your application. However, the ABP Framework and all the pre-built modules are designed so that **they can use multiple databases**. Each module can use its own database or you can group modules into a few databases. |
||||
|
|
||||
|
This section will explain how to move Audit Logging, Setting Management and Permission Management module tables to a **second database** while the remaining modules continue to use the main ("Default") database. |
||||
|
|
||||
|
The resulting structure will be like the figure below: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### Change the Connection Strings Section |
||||
|
|
||||
|
First step is to change the connection string section inside all the `appsettings.json` files. Initially, it is like that: |
||||
|
|
||||
|
````json |
||||
|
"ConnectionStrings": { |
||||
|
"Default": "Server=localhost;Database=BookStore;Trusted_Connection=True;MultipleActiveResultSets=true" |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Change it as shown below: |
||||
|
|
||||
|
````json |
||||
|
"ConnectionStrings": { |
||||
|
"Default": "Server=localhost;Database=BookStore;Trusted_Connection=True;MultipleActiveResultSets=true", |
||||
|
"AbpPermissionManagement": "Server=localhost;Database=BookStore_SecondDb;Trusted_Connection=True;MultipleActiveResultSets=true", |
||||
|
"AbpSettingManagement": "Server=localhost;Database=BookStore_SecondDb;Trusted_Connection=True;MultipleActiveResultSets=true", |
||||
|
"AbpAuditLogging": "Server=localhost;Database=BookStore_SecondDb;Trusted_Connection=True;MultipleActiveResultSets=true" |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Added **three more connection strings** for the related module to target the `BookStore_SecondDb` database (they are all same). For example, `AbpPermissionManagement` is the connection string for the permission management module. |
||||
|
|
||||
|
The `AbpPermissionManagement` is a constant [defined](https://github.com/abpframework/abp/blob/97eaa6ff5a044f503465455c86332e5a277b077a/modules/permission-management/src/Volo.Abp.PermissionManagement.Domain/Volo/Abp/PermissionManagement/AbpPermissionManagementDbProperties.cs#L11) by the permission management module. ABP Framework [connection string selection system](Connection-Strings.md) selects this connection string for the permission management module if you define. If you don't define, it fallbacks to the `Default` connection string. |
||||
|
|
||||
|
### Create a Second Migration Project |
||||
|
|
||||
|
Defining the connection strings as explained above is enough **on runtime**. However, `BookStore_SecondDb` database doesn't exist yet. You need to create the database and the tables for the related modules. |
||||
|
|
||||
|
Just like the main database, we want to use the EF Core Code First migration system to create and maintain the second database. |
||||
|
|
||||
|
An easy way is to create a second project (`.csproj`) for the second migration `DbContext`. |
||||
|
|
||||
|
So, create a new **class library project** in your solution named `Acme.BookStore.EntityFrameworkCore.DbMigrationsForSecondDb` (or name it better if you didn't like it). |
||||
|
|
||||
|
The `.csproj` content should be something like that: |
||||
|
|
||||
|
````xml |
||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<Import Project="..\..\common.props" /> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFramework>netcoreapp3.1</TargetFramework> |
||||
|
<RootNamespace>Acme.BookStore.DbMigrationsForSecondDb</RootNamespace> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\Acme.BookStore.EntityFrameworkCore\Acme.BookStore.EntityFrameworkCore.csproj" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.0" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
</Project> |
||||
|
```` |
||||
|
|
||||
|
You can just copy & modify the content of the original `.DbMigrations` project. This project references to the `.EntityFrameworkCore` project. **Only difference** is the `RootNamespace` value. |
||||
|
|
||||
|
**Add a reference** to this project from the `.Web` project (otherwise, EF Core tooling doesn't allow to use the `Add-Migration` command). |
||||
|
|
||||
|
### Create the Second DbMigrationDbContext |
||||
|
|
||||
|
Create a new `DbContext` for the migrations and call the **extension methods** of the modules to configure the database tables for the related modules: |
||||
|
|
||||
|
````csharp |
||||
|
[ConnectionStringName("AbpPermissionManagement")] |
||||
|
public class BookStoreSecondMigrationsDbContext : |
||||
|
AbpDbContext<BookStoreSecondMigrationsDbContext> |
||||
|
{ |
||||
|
public BookStoreSecondMigrationsDbContext( |
||||
|
DbContextOptions<BookStoreSecondMigrationsDbContext> options) |
||||
|
: base(options) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
base.OnModelCreating(builder); |
||||
|
|
||||
|
/* Include modules to your migration db context */ |
||||
|
|
||||
|
builder.ConfigurePermissionManagement(); |
||||
|
builder.ConfigureSettingManagement(); |
||||
|
builder.ConfigureAuditLogging(); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
> `[ConnectionStringName(...)]` attribute is important here and tells to the ABP Framework which connection string should be used for this `DbContext`. We've used `AbpPermissionManagement`, but all are the same. |
||||
|
|
||||
|
Create a **Design Time Db Factory** class, that is used by the EF Core tooling (by `Add-Migration` and `Update-Database` PCM commands for example): |
||||
|
|
||||
|
````csharp |
||||
|
/* This class is needed for EF Core console commands |
||||
|
* (like Add-Migration and Update-Database commands) */ |
||||
|
public class BookStoreSecondMigrationsDbContextFactory |
||||
|
: IDesignTimeDbContextFactory<BookStoreSecondMigrationsDbContext> |
||||
|
{ |
||||
|
public BookStoreSecondMigrationsDbContext CreateDbContext(string[] args) |
||||
|
{ |
||||
|
var configuration = BuildConfiguration(); |
||||
|
|
||||
|
var builder = new DbContextOptionsBuilder<BookStoreSecondMigrationsDbContext>() |
||||
|
.UseSqlServer(configuration.GetConnectionString("AbpPermissionManagement")); |
||||
|
|
||||
|
return new BookStoreSecondMigrationsDbContext(builder.Options); |
||||
|
} |
||||
|
|
||||
|
private static IConfigurationRoot BuildConfiguration() |
||||
|
{ |
||||
|
var builder = new ConfigurationBuilder() |
||||
|
.SetBasePath(Directory.GetCurrentDirectory()) |
||||
|
.AddJsonFile("appsettings.json", optional: false); |
||||
|
|
||||
|
return builder.Build(); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
This is similar to the class inside the `.EntityFrameworCore.DbMigrations` project, except this one uses the `AbpPermissionManagement` connection string. |
||||
|
|
||||
|
Now, you can open the Package Manager Console, select the `.EntityFrameworkCore.DbMigrationsForSecondDb` project as the default project (make sure the `.Web` project is still the startup project) and run the `Add-Migration "Initial"` and `Update-Database` commands as shown below: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
Now, you should have a new database contains only the tables needed by the related modules: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### Remove Modules from the Main Database |
||||
|
|
||||
|
We've **created a second database** contains tables for the Audit Logging, Permission Management and Setting Management modules. So, we should **delete these tables from the main database**. It is pretty easy. |
||||
|
|
||||
|
First, remove the following lines from the `MigrationsDbContext` class (`BookStoreMigrationsDbContext` for this example): |
||||
|
|
||||
|
````csharp |
||||
|
builder.ConfigurePermissionManagement(); |
||||
|
builder.ConfigureSettingManagement(); |
||||
|
builder.ConfigureAuditLogging(); |
||||
|
```` |
||||
|
|
||||
|
Open the Package Manager Console, select the `.EntityFrameworkCore.DbMigrations` as the Default project (make sure that the `.Web` project is still the startup project) and run the following command: |
||||
|
|
||||
|
```` |
||||
|
Add-Migration "Removed_Audit_Setting_Permission_Modules" |
||||
|
```` |
||||
|
|
||||
|
This command will create a new migration class as shown below: |
||||
|
|
||||
|
````csharp |
||||
|
public partial class Removed_Audit_Setting_Permission_Modules : Migration |
||||
|
{ |
||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||
|
{ |
||||
|
migrationBuilder.DropTable( |
||||
|
name: "AbpAuditLogActions"); |
||||
|
|
||||
|
migrationBuilder.DropTable( |
||||
|
name: "AbpEntityPropertyChanges"); |
||||
|
|
||||
|
migrationBuilder.DropTable( |
||||
|
name: "AbpPermissionGrants"); |
||||
|
|
||||
|
migrationBuilder.DropTable( |
||||
|
name: "AbpSettings"); |
||||
|
|
||||
|
migrationBuilder.DropTable( |
||||
|
name: "AbpEntityChanges"); |
||||
|
|
||||
|
migrationBuilder.DropTable( |
||||
|
name: "AbpAuditLogs"); |
||||
|
} |
||||
|
|
||||
|
... |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Be careful in this step: |
||||
|
|
||||
|
* If you have a **live system**, then you should care about the **data loss**. You need to move the table contents to the second database before deleting the tables. |
||||
|
* If you **haven't started** your project yet, you can consider to **remove all the migrations** and re-create the initial one to have a cleaner migration history. |
||||
|
|
||||
|
Run the `Update-Database` command to delete the tables from your main database. |
||||
|
|
||||
|
Notice that you've also **deleted some initial seed data** (for example, permission grants for the admin role) if you haven't copied it to the new database. If you run the application, you may not login anymore. The solution is simple: **Re-run the `.DbMigrator` console application** in your solution, it will seed the new database. |
||||
|
|
||||
|
### Automate the Second Database Schema Migration |
||||
|
|
||||
|
`.DbMigrator` console application can run the database seed code across multiple databases, without any additional configuration. However, it can not run the EF Core Code First Migrations inside the second database migration project. Now, you will see how to configure the console migration application to handle both databases. |
||||
|
|
||||
|
#### Implementing the IBookStoreDbSchemaMigrator |
||||
|
|
||||
|
`EntityFrameworkCoreBookStoreDbSchemaMigrator` class inside the `Acme.BookStore.EntityFrameworkCore.DbMigrations` project is responsible to migrate the database schema for the `BookStoreMigrationsDbContext`. It should be like that: |
||||
|
|
||||
|
````csharp |
||||
|
[Dependency(ReplaceServices = true)] |
||||
|
public class EntityFrameworkCoreBookStoreDbSchemaMigrator |
||||
|
: IBookStoreDbSchemaMigrator, ITransientDependency |
||||
|
{ |
||||
|
private readonly IServiceProvider _serviceProvider; |
||||
|
|
||||
|
public EntityFrameworkCoreBookStoreDbSchemaMigrator( |
||||
|
IServiceProvider serviceProvider) |
||||
|
{ |
||||
|
_serviceProvider = serviceProvider; |
||||
|
} |
||||
|
|
||||
|
public async Task MigrateAsync() |
||||
|
{ |
||||
|
/* We are intentionally resolving the BookStoreMigrationsDbContext |
||||
|
* from IServiceProvider (instead of directly injecting it) |
||||
|
* to properly get the connection string of the current tenant in the |
||||
|
* current scope. |
||||
|
*/ |
||||
|
|
||||
|
await _serviceProvider |
||||
|
.GetRequiredService<BookStoreMigrationsDbContext>() |
||||
|
.Database |
||||
|
.MigrateAsync(); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
It implements the `IBookStoreDbSchemaMigrator` and **replaces existing services** (see the first line). |
||||
|
|
||||
|
Remove the `[Dependency(ReplaceServices = true)]` line, because we will have two implementations of this interface and we want to use both. We don't want to replace one of them. |
||||
|
|
||||
|
Create a copy of this class inside the new migration project (`Acme.BookStore.EntityFrameworkCore.DbMigrationsForSecondDb`), but use the `BookStoreSecondMigrationsDbContext`. Example implementation: |
||||
|
|
||||
|
````csharp |
||||
|
public class EntityFrameworkCoreSecondBookStoreDbSchemaMigrator |
||||
|
: IBookStoreDbSchemaMigrator, ITransientDependency |
||||
|
{ |
||||
|
private readonly IServiceProvider _serviceProvider; |
||||
|
|
||||
|
public EntityFrameworkCoreSecondBookStoreDbSchemaMigrator( |
||||
|
IServiceProvider serviceProvider) |
||||
|
{ |
||||
|
_serviceProvider = serviceProvider; |
||||
|
} |
||||
|
|
||||
|
public async Task MigrateAsync() |
||||
|
{ |
||||
|
/* We are intentionally resolving the BookStoreSecondMigrationsDbContext |
||||
|
* from IServiceProvider (instead of directly injecting it) |
||||
|
* to properly get the connection string of the current tenant in the |
||||
|
* current scope. |
||||
|
*/ |
||||
|
|
||||
|
await _serviceProvider |
||||
|
.GetRequiredService<BookStoreSecondMigrationsDbContext>() |
||||
|
.Database |
||||
|
.MigrateAsync(); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
> Name of this class is important for [dependency injection](Dependency-Injection.md). It should end with `BookStoreDbSchemaMigrator` to be injectable by `IBookStoreDbSchemaMigrator` reference. |
||||
|
|
||||
|
We, now, have two implementations of the `IBookStoreDbSchemaMigrator` interface, each one responsible to migrate the related database schema. |
||||
|
|
||||
|
#### Define a Module Class for the Second Migration Project |
||||
|
|
||||
|
It is time to define the [module](Module-Development-Basics.md) class for this second migrations (`Acme.BookStore.EntityFrameworkCore.DbMigrationsForSecondDb`) project: |
||||
|
|
||||
|
````csharp |
||||
|
[DependsOn( |
||||
|
typeof(BookStoreEntityFrameworkCoreModule) |
||||
|
)] |
||||
|
public class BookStoreEntityFrameworkCoreSecondDbMigrationsModule : AbpModule |
||||
|
{ |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
context.Services.AddAbpDbContext<BookStoreSecondMigrationsDbContext>(); |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Now, reference `Acme.BookStore.EntityFrameworkCore.DbMigrationsForSecondDb` project from the `Acme.BookStore.DbMigrator` project and `typeof(BookStoreEntityFrameworkCoreSecondDbMigrationsModule)` to the dependency list of the `BookStoreDbMigratorModule`. `BookStoreDbMigratorModule` class should be something like that: |
||||
|
|
||||
|
````csharp |
||||
|
[DependsOn( |
||||
|
typeof(AbpAutofacModule), |
||||
|
typeof(BookStoreEntityFrameworkCoreDbMigrationsModule), |
||||
|
typeof(BookStoreEntityFrameworkCoreSecondDbMigrationsModule), // ADDED THIS! |
||||
|
typeof(BookStoreApplicationContractsModule) |
||||
|
)] |
||||
|
public class BookStoreDbMigratorModule : AbpModule |
||||
|
{ |
||||
|
... |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
We had a reference to the `Acme.BookStore.EntityFrameworkCore.DbMigrationsForSecondDb` project from the `Acme.BookStore.Web` project, but hadn't added module dependency since we hadn't created it before. But, now we have it and we need to add `typeof(BookStoreEntityFrameworkCoreSecondDbMigrationsModule)` to the dependency list of the `BookStoreWebModule` class. |
||||
|
|
||||
|
#### Run the Database Migrator! |
||||
|
|
||||
|
You can run the `.DbMigrator` application to migrate & seed the databases. To test, you can delete both databases and run the `.DbMigrator` application again to see if it creates both of the databases. |
||||
|
|
||||
|
## Conclusion |
||||
|
|
||||
|
This document explains how to split your databases and manage your database migrations of your solution for Entity Framework Core. In brief, you need to have a separate migration project per different databases. |
||||
|
|
||||
|
## Source Code |
||||
|
|
||||
|
You can find the source code of the example project referenced by this document [here](https://github.com/abpframework/abp/tree/dev/samples/EfCoreMigrationDemo). However, you need to read and understand this document in order to understand the example project's source code. |
||||
@ -0,0 +1,4 @@ |
|||||
|
# Identity Management Module |
||||
|
|
||||
|
See [the source code](https://github.com/abpframework/abp/tree/dev/modules/identity). Documentation will come soon... |
||||
|
|
||||
@ -0,0 +1,3 @@ |
|||||
|
# Tenant Management Module |
||||
|
|
||||
|
TODO |
||||
@ -1,659 +1,6 @@ |
|||||
## Angular Tutorial - Part I |
# Tutorials |
||||
|
|
||||
### About this Tutorial |
## Application Development |
||||
|
|
||||
In this tutorial series, you will build an application that is used to manage a list of books & their authors. **Angular** will be used as the UI framework and **MongoDB** will be used as the database provider. |
* [With ASP.NET Core MVC / Razor Pages UI](../Part-1?UI=MVC) |
||||
|
* [With Angular UI](../Part-1?UI=NG) |
||||
This is the first part of the Angular tutorial series. See all parts: |
|
||||
|
|
||||
- **Part I: Create the project and a book list page (this tutorial)** |
|
||||
- [Part II: Create, Update and Delete books](Part-II.md) |
|
||||
- [Part III: Integration Tests](Part-III.md) |
|
||||
|
|
||||
You can access to the **source code** of the application from the [GitHub repository](https://github.com/abpframework/abp/tree/dev/samples/BookStore-Angular-MongoDb). |
|
||||
|
|
||||
### Creating the Project |
|
||||
|
|
||||
Create a new project named `Acme.BookStore` by selecting the Angular as the UI framework and MongoDB as the database provider, create the database and run the application by following the [Getting Started document](../../Getting-Started-Angular-Template.md). |
|
||||
|
|
||||
### Solution Structure (Backend) |
|
||||
|
|
||||
This is how the layered solution structure looks after it's created: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
> You can see the [Application template document](../../Startup-Templates/Application.md) to understand the solution structure in details. However, you will understand the basics with this tutorial. |
|
||||
|
|
||||
### Create the Book Entity |
|
||||
|
|
||||
Domain layer in the startup template is separated into two projects: |
|
||||
|
|
||||
- `Acme.BookStore.Domain` contains your [entities](../../Entities.md), [domain services](../../Domain-Services.md) and other core domain objects. |
|
||||
- `Acme.BookStore.Domain.Shared` contains constants, enums or other domain related objects those can be shared with clients. |
|
||||
|
|
||||
Define [entities](../../Entities.md) in the **domain layer** (`Acme.BookStore.Domain` project) of the solution. The main entity of the application is the `Book`. Create a class, named `Book`, in the `Acme.BookStore.Domain` project as shown below: |
|
||||
|
|
||||
```C# |
|
||||
using System; |
|
||||
using Volo.Abp.Domain.Entities.Auditing; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class Book : AuditedAggregateRoot<Guid> |
|
||||
{ |
|
||||
public string Name { get; set; } |
|
||||
|
|
||||
public BookType Type { get; set; } |
|
||||
|
|
||||
public DateTime PublishDate { get; set; } |
|
||||
|
|
||||
public float Price { get; set; } |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
- ABP has two fundamental base classes for entities: `AggregateRoot` and `Entity`. **Aggregate Root** is one of the **Domain Driven Design (DDD)** concepts. See [entity document](../../Entities.md) for details and best practices. |
|
||||
- `Book` entity inherits `AuditedAggregateRoot` which adds some auditing properties (`CreationTime`, `CreatorId`, `LastModificationTime`... etc.) on top of the `AggregateRoot` class. |
|
||||
- `Guid` is the **primary key type** of the `Book` entity. |
|
||||
|
|
||||
#### BookType Enum |
|
||||
|
|
||||
Define the `BookType` enum in the `Acme.BookStore.Domain.Shared` project: |
|
||||
|
|
||||
```C# |
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public enum BookType |
|
||||
{ |
|
||||
Undefined, |
|
||||
Adventure, |
|
||||
Biography, |
|
||||
Dystopia, |
|
||||
Fantastic, |
|
||||
Horror, |
|
||||
Science, |
|
||||
ScienceFiction, |
|
||||
Poetry |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### Add Book Entity to Your DbContext |
|
||||
|
|
||||
Add a `IMongoCollection` property to the `BookStoreMongoDbContext` inside the `Acme.BookStore.MongoDB` project: |
|
||||
|
|
||||
```csharp |
|
||||
public class BookStoreMongoDbContext : AbpMongoDbContext |
|
||||
{ |
|
||||
public IMongoCollection<Book> Books => Collection<Book>(); |
|
||||
... |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### Add Seed (Sample) Data |
|
||||
|
|
||||
This section is optional, but it would be good to have an initial data in the database in the first run. ABP provides a [data seed system](../../Data-Seeding.md). Create a class deriving from the `IDataSeedContributor` in the `.Domain` project: |
|
||||
|
|
||||
```csharp |
|
||||
using System; |
|
||||
using System.Threading.Tasks; |
|
||||
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; |
|
||||
|
|
||||
public BookStoreDataSeederContributor(IRepository<Book, Guid> bookRepository) |
|
||||
{ |
|
||||
_bookRepository = bookRepository; |
|
||||
} |
|
||||
|
|
||||
public async Task SeedAsync(DataSeedContext context) |
|
||||
{ |
|
||||
if (await _bookRepository.GetCountAsync() > 0) |
|
||||
{ |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
await _bookRepository.InsertAsync( |
|
||||
new Book |
|
||||
{ |
|
||||
Name = "1984", |
|
||||
Type = BookType.Dystopia, |
|
||||
PublishDate = new DateTime(1949, 6, 8), |
|
||||
Price = 19.84f |
|
||||
} |
|
||||
); |
|
||||
|
|
||||
await _bookRepository.InsertAsync( |
|
||||
new Book |
|
||||
{ |
|
||||
Name = "The Hitchhiker's Guide to the Galaxy", |
|
||||
Type = BookType.ScienceFiction, |
|
||||
PublishDate = new DateTime(1995, 9, 27), |
|
||||
Price = 42.0f |
|
||||
} |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
``` |
|
||||
|
|
||||
`BookStoreDataSeederContributor` simply inserts two books into database if there is no book added before. ABP automatically discovers and executes this class when you seed the database by running the `Acme.BookStore.DbMigrator` project. |
|
||||
|
|
||||
### Create the Application Service |
|
||||
|
|
||||
The next step is to create an [application service](../../Application-Services.md) to manage (create, list, update, delete...) the books. Application layer in the startup template is separated into two projects: |
|
||||
|
|
||||
- `Acme.BookStore.Application.Contracts` mainly contains your DTOs and application service interfaces. |
|
||||
- `Acme.BookStore.Application` contains the implementations of your application services. |
|
||||
|
|
||||
#### BookDto |
|
||||
|
|
||||
Create a DTO class named `BookDto` into the `Acme.BookStore.Application.Contracts` project: |
|
||||
|
|
||||
```C# |
|
||||
using System; |
|
||||
using Volo.Abp.Application.Dtos; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookDto : AuditedEntityDto<Guid> |
|
||||
{ |
|
||||
public string Name { get; set; } |
|
||||
|
|
||||
public BookType Type { get; set; } |
|
||||
|
|
||||
public DateTime PublishDate { get; set; } |
|
||||
|
|
||||
public float Price { get; set; } |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
- **DTO** classes are used to **transfer data** between the _presentation layer_ and the _application layer_. See the [Data Transfer Objects document](../../Data-Transfer-Objects.md) for more details. |
|
||||
- `BookDto` is used to transfer book data to the presentation layer in order to show the book information on the UI. |
|
||||
- `BookDto` is derived from the `AuditedEntityDto<Guid>` which has audit properties just like the `Book` class defined above. |
|
||||
|
|
||||
It will be needed to convert `Book` entities to `BookDto` objects while returning books to the presentation layer. [AutoMapper](https://automapper.org) library can automate this conversion when you define the proper mapping. Startup template comes with AutoMapper configured, so you can just define the mapping in the `BookStoreApplicationAutoMapperProfile` class in the `Acme.BookStore.Application` project: |
|
||||
|
|
||||
```csharp |
|
||||
using AutoMapper; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookStoreApplicationAutoMapperProfile : Profile |
|
||||
{ |
|
||||
public BookStoreApplicationAutoMapperProfile() |
|
||||
{ |
|
||||
CreateMap<Book, BookDto>(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### CreateUpdateBookDto |
|
||||
|
|
||||
Create a DTO class named `CreateUpdateBookDto` into the `Acme.BookStore.Application.Contracts` project: |
|
||||
|
|
||||
```c# |
|
||||
using System; |
|
||||
using System.ComponentModel.DataAnnotations; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class CreateUpdateBookDto |
|
||||
{ |
|
||||
[Required] |
|
||||
[StringLength(128)] |
|
||||
public string Name { get; set; } |
|
||||
|
|
||||
[Required] |
|
||||
public BookType Type { get; set; } = BookType.Undefined; |
|
||||
|
|
||||
[Required] |
|
||||
public DateTime PublishDate { get; set; } |
|
||||
|
|
||||
[Required] |
|
||||
public float Price { get; set; } |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
- This DTO class is used to get book information from the user interface while creating or updating a book. |
|
||||
- It defines data annotation attributes (like `[Required]`) to define validations for the properties. DTOs are [automatically validated](../../Validation.md) by the ABP framework. |
|
||||
|
|
||||
Next, add a mapping in `BookStoreApplicationAutoMapperProfile` from the `CreateUpdateBookDto` object to the `Book` entity: |
|
||||
|
|
||||
```csharp |
|
||||
CreateMap<CreateUpdateBookDto, Book>(); |
|
||||
``` |
|
||||
|
|
||||
#### IBookAppService |
|
||||
|
|
||||
Define an interface named `IBookAppService` in the `Acme.BookStore.Application.Contracts` project: |
|
||||
|
|
||||
```C# |
|
||||
using System; |
|
||||
using Volo.Abp.Application.Dtos; |
|
||||
using Volo.Abp.Application.Services; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public interface IBookAppService : |
|
||||
ICrudAppService< //Defines CRUD methods |
|
||||
BookDto, //Used to show books |
|
||||
Guid, //Primary key of the book entity |
|
||||
PagedAndSortedResultRequestDto, //Used for paging/sorting on getting a list of books |
|
||||
CreateUpdateBookDto, //Used to create a new book |
|
||||
CreateUpdateBookDto> //Used to update a book |
|
||||
{ |
|
||||
|
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
- Defining interfaces for application services is <u>not required</u> by the framework. However, it's suggested as a best practice. |
|
||||
- `ICrudAppService` defines common **CRUD** methods: `GetAsync`, `GetListAsync`, `CreateAsync`, `UpdateAsync` and `DeleteAsync`. It's not required to extend it. Instead, you could inherit from the empty `IApplicationService` interface and define your own methods manually. |
|
||||
- There are some variations of the `ICrudAppService` where you can use separated DTOs for each method. |
|
||||
|
|
||||
#### BookAppService |
|
||||
|
|
||||
Implement the `IBookAppService` as named `BookAppService` in the `Acme.BookStore.Application` project: |
|
||||
|
|
||||
```C# |
|
||||
using System; |
|
||||
using Volo.Abp.Application.Dtos; |
|
||||
using Volo.Abp.Application.Services; |
|
||||
using Volo.Abp.Domain.Repositories; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookAppService : |
|
||||
CrudAppService<Book, BookDto, Guid, PagedAndSortedResultRequestDto, |
|
||||
CreateUpdateBookDto, CreateUpdateBookDto>, |
|
||||
IBookAppService |
|
||||
{ |
|
||||
public BookAppService(IRepository<Book, Guid> repository) |
|
||||
: base(repository) |
|
||||
{ |
|
||||
|
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
- `BookAppService` is derived from `CrudAppService<...>` which implements all the CRUD methods defined above. |
|
||||
- `BookAppService` injects `IRepository<Book, Guid>` which is the default repository for the `Book` entity. ABP automatically creates default repositories for each aggregate root (or entity). See the [repository document](../../Repositories.md). |
|
||||
- `BookAppService` uses `IObjectMapper` to convert `Book` objects to `BookDto` objects and `CreateUpdateBookDto` objects to `Book` objects. The Startup template uses the [AutoMapper](http://automapper.org/) library as the object mapping provider. You defined the mappings before, so it will work as expected. |
|
||||
|
|
||||
### Auto API Controllers |
|
||||
|
|
||||
You normally create **Controllers** to expose application services as **HTTP API** endpoints. Thus allowing browser or 3rd-party clients to call them via AJAX. ABP can [**automagically**](../../AspNetCore/Auto-API-Controllers.md) configures your application services as MVC API Controllers by convention. |
|
||||
|
|
||||
#### Swagger UI |
|
||||
|
|
||||
The startup template is configured to run the [swagger UI](https://swagger.io/tools/swagger-ui/) using the [Swashbuckle.AspNetCore](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) library. Run the `Acme.BookStore.HttpApi.Host` application and enter `https://localhost:XXXX/swagger/` (replace XXXX by your own port) as URL on your browser. |
|
||||
|
|
||||
You will see some built-in service endpoints as well as the `Book` service and its REST-style endpoints: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Swagger has a nice UI to test APIs. You can try to execute the `[GET] /api/app/book` API to get a list of books. |
|
||||
|
|
||||
### Create the Books Page |
|
||||
|
|
||||
In this tutorial; |
|
||||
|
|
||||
- [Angular CLI](https://angular.io/cli) will be used to create modules, components and services |
|
||||
- [NGXS](https://ngxs.gitbook.io/ngxs/) will be used as the state management library |
|
||||
- [Ng Bootstrap](https://ng-bootstrap.github.io/#/home) will be used as the UI component library. |
|
||||
- [Visual Studio Code](https://code.visualstudio.com/) will be used as the code editor (you can use your favorite editor). |
|
||||
|
|
||||
#### Install NPM Packages |
|
||||
|
|
||||
Open a terminal window and go to `angular` folder and then run `yarn` command for installing NPM packages: |
|
||||
|
|
||||
``` |
|
||||
yarn |
|
||||
``` |
|
||||
|
|
||||
#### BooksModule |
|
||||
|
|
||||
Run the following command line to create a new module, named `BooksModule`: |
|
||||
|
|
||||
```bash |
|
||||
yarn ng generate module books --route books --module app.module |
|
||||
``` |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Run `yarn start`, wait Angular to run the application and open `http://localhost:4200/books` on a browser: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
#### Routing |
|
||||
|
|
||||
Open the `app-routing.module.ts` and replace `books` as shown below: |
|
||||
|
|
||||
```js |
|
||||
import { ApplicationLayoutComponent } from '@abp/ng.theme.basic'; |
|
||||
|
|
||||
//... |
|
||||
{ |
|
||||
path: 'books', |
|
||||
component: ApplicationLayoutComponent, |
|
||||
loadChildren: () => import('./books/books.module').then(m => m.BooksModule), |
|
||||
data: { |
|
||||
routes: { |
|
||||
name: 'Books', |
|
||||
} as ABP.Route, |
|
||||
}, |
|
||||
}, |
|
||||
``` |
|
||||
|
|
||||
`ApplicationLayoutComponent` configuration sets the application layout to the new page. If you would like to see your route on the navigation bar (main menu) you must also add the `data` object with `name` property in your route. |
|
||||
|
|
||||
 |
|
||||
|
|
||||
#### Book List Component |
|
||||
|
|
||||
First, replace the `books.component.html` to the following line to place the router-outlet: |
|
||||
|
|
||||
```html |
|
||||
<router-outlet></router-outlet> |
|
||||
``` |
|
||||
|
|
||||
Then run the command below on the terminal in the root folder to generate a new component, named book-list: |
|
||||
|
|
||||
```bash |
|
||||
yarn ng generate component books/book-list |
|
||||
``` |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Import the `SharedModule` to the `BooksModule` to reuse some components and services defined in: |
|
||||
|
|
||||
```js |
|
||||
import { SharedModule } from '../shared/shared.module'; |
|
||||
|
|
||||
@NgModule({ |
|
||||
//... |
|
||||
imports: [ |
|
||||
//... |
|
||||
SharedModule, |
|
||||
], |
|
||||
}) |
|
||||
export class BooksModule {} |
|
||||
``` |
|
||||
|
|
||||
Then, update the `routes` in the `books-routing.module.ts` to add the new book-list component: |
|
||||
|
|
||||
```js |
|
||||
import { BookListComponent } from './book-list/book-list.component'; |
|
||||
|
|
||||
const routes: Routes = [ |
|
||||
{ |
|
||||
path: '', |
|
||||
component: BooksComponent, |
|
||||
children: [{ path: '', component: BookListComponent }], |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
@NgModule({ |
|
||||
imports: [RouterModule.forChild(routes)], |
|
||||
exports: [RouterModule], |
|
||||
}) |
|
||||
export class BooksRoutingModule {} |
|
||||
``` |
|
||||
|
|
||||
 |
|
||||
|
|
||||
#### Create BooksState |
|
||||
|
|
||||
Run the following command in the terminal to create a new state, named `BooksState`: |
|
||||
|
|
||||
```shell |
|
||||
yarn ng generate ngxs-schematic:state books |
|
||||
``` |
|
||||
|
|
||||
This command creates several new files and edits `app.modules.ts` to import the `NgxsModule` with the new state: |
|
||||
|
|
||||
```js |
|
||||
// app.module.ts |
|
||||
|
|
||||
import { BooksState } from './store/states/books.state'; |
|
||||
|
|
||||
@NgModule({ |
|
||||
imports: [ |
|
||||
//... |
|
||||
NgxsModule.forRoot([BooksState]), |
|
||||
], |
|
||||
//... |
|
||||
}) |
|
||||
export class AppModule {} |
|
||||
``` |
|
||||
|
|
||||
#### Get Books Data from Backend |
|
||||
|
|
||||
First, create data types to map data returning from the backend (you can check swagger UI or your backend API to know the data format). |
|
||||
|
|
||||
Modify the `books.ts` as shown 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 `Book` interface that represents a book object and `BookType` enum represents a book category. |
|
||||
|
|
||||
#### BooksService |
|
||||
|
|
||||
Now, create a new service, named `BooksService` to perform HTTP calls to the server: |
|
||||
|
|
||||
```bash |
|
||||
yarn ng generate service books/shared/books |
|
||||
``` |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Modify `books.service.ts` as shown 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 the `get` method to get the list of books by performing an HTTP request to the related endpoint. |
|
||||
|
|
||||
Replace `books.actions.ts` content as shown below: |
|
||||
|
|
||||
```js |
|
||||
export class GetBooks { |
|
||||
static readonly type = '[Books] Get'; |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### Implement the BooksState |
|
||||
|
|
||||
Open the `books.state.ts` and change the file as shown below: |
|
||||
|
|
||||
```js |
|
||||
import { State, Action, StateContext, Selector } from '@ngxs/store'; |
|
||||
import { GetBooks } from '../actions/books.actions'; |
|
||||
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 the `GetBooks` action that uses the `BookService` defined above to get the books and patch the state. |
|
||||
|
|
||||
> NGXS requires to return the observable without subscribing it, as done in this sample (in the get function). |
|
||||
|
|
||||
#### BookListComponent |
|
||||
|
|
||||
Modify the `book-list.component.ts` as shown 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; |
|
||||
|
|
||||
constructor(private store: Store) {} |
|
||||
|
|
||||
ngOnInit() { |
|
||||
this.loading = true; |
|
||||
this.store.dispatch(new GetBooks()).subscribe(() => { |
|
||||
this.loading = false; |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
> See the [Dispatching Actions](https://ngxs.gitbook.io/ngxs/concepts/store#dispatching-actions) and [Select](https://ngxs.gitbook.io/ngxs/concepts/select) on the NGXS documentation for more information on these NGXS features. |
|
||||
|
|
||||
Replace `book-list.component.html` content as shown below: |
|
||||
|
|
||||
```html |
|
||||
<div id="wrapper" class="card"> |
|
||||
<div class="card-header"> |
|
||||
<div class="row"> |
|
||||
<div class="col col-md-6"> |
|
||||
<h5 class="card-title"> |
|
||||
Books |
|
||||
</h5> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div class="card-body"> |
|
||||
<p-table [value]="books$ | async" [loading]="loading" [paginator]="true" [rows]="10"> |
|
||||
<ng-template pTemplate="header"> |
|
||||
<tr> |
|
||||
<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>{%{{{ data.name }}}%}</td> |
|
||||
<td>{%{{{ booksType[data.type] }}}%}</td> |
|
||||
<td>{%{{{ data.publishDate | date }}}%}</td> |
|
||||
<td>{%{{{ data.price }}}%}</td> |
|
||||
</tr> |
|
||||
</ng-template> |
|
||||
</p-table> |
|
||||
</div> |
|
||||
</div> |
|
||||
``` |
|
||||
|
|
||||
> We've used [PrimeNG table](https://www.primefaces.org/primeng/#/table) in this component. |
|
||||
|
|
||||
The resulting books page is shown below: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
And this is the folder & file structure by the end of this tutorial: |
|
||||
|
|
||||
<img src="images/bookstore-angular-file-tree.png" height="75%"> |
|
||||
|
|
||||
> This tutorial follows the [Angular Style Guide](https://angular.io/guide/styleguide#file-tree). |
|
||||
|
|
||||
### Next Part |
|
||||
|
|
||||
See the [next part](Part-II.md) of this tutorial. |
|
||||
|
|||||
@ -1,587 +1,6 @@ |
|||||
## Angular Tutorial - Part II |
# Tutorials |
||||
|
|
||||
### About this Tutorial |
## Application Development |
||||
|
|
||||
This is the second part of the Angular tutorial series. See all parts: |
* [With ASP.NET Core MVC / Razor Pages UI](../Part-1?UI=MVC) |
||||
|
* [With Angular UI](../Part-1?UI=NG) |
||||
- [Part I: Create the project and a book list page](Part-I.md) |
|
||||
- **Part II: Create, Update and Delete books (this tutorial)** |
|
||||
- [Part III: Integration Tests](Part-III.md) |
|
||||
|
|
||||
You can access to the **source code** of the application from the [GitHub repository](https://github.com/abpframework/abp/tree/dev/samples/BookStore-Angular-MongoDb). |
|
||||
|
|
||||
### 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 |
|
||||
|
|
||||
Create an interface, named `CreateUpdateBookInput` in the `books.ts` as shown below: |
|
||||
|
|
||||
```js |
|
||||
export namespace Books { |
|
||||
//... |
|
||||
export interface CreateUpdateBookInput { |
|
||||
name: string; |
|
||||
type: BookType; |
|
||||
publishDate: string; |
|
||||
price: number; |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
`CreateUpdateBookInput` interface matches the `CreateUpdateBookDto` in the backend. |
|
||||
|
|
||||
#### Service Method |
|
||||
|
|
||||
Open the `books.service.ts` and add a new method, named `create` to perform an HTTP POST request to the server: |
|
||||
|
|
||||
```js |
|
||||
create(createBookInput: Books.CreateUpdateBookInput): Observable<Books.Book> { |
|
||||
return this.restService.request<Books.CreateUpdateBookInput, Books.Book>({ |
|
||||
method: 'POST', |
|
||||
url: '/api/app/book', |
|
||||
body: createBookInput |
|
||||
}); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
- `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 |
|
||||
|
|
||||
Add the `CreateUpdateBook` action to the `books.actions.ts` as shown below: |
|
||||
|
|
||||
```js |
|
||||
import { Books } from '../models'; |
|
||||
|
|
||||
export class CreateUpdateBook { |
|
||||
static readonly type = '[Books] Create Update Book'; |
|
||||
constructor(public payload: Books.CreateUpdateBookInput) {} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
Open `books.state.ts` and define the `save` method that will listen to a `CreateUpdateBook` action to create a book: |
|
||||
|
|
||||
```js |
|
||||
import { ... , CreateUpdateBook } from '../actions/books.actions'; |
|
||||
import { ... , switchMap } from 'rxjs/operators'; |
|
||||
//... |
|
||||
@Action(CreateUpdateBook) |
|
||||
save(ctx: StateContext<Books.State>, action: CreateUpdateBook) { |
|
||||
return this.booksService |
|
||||
.create(action.payload) |
|
||||
.pipe(switchMap(() => ctx.dispatch(new GetBooks()))); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
When the `SaveBook` action dispatched, the save method is executed. It call `create` method of the `BooksService` defined before. After the service call, `BooksState` dispatches the `GetBooks` action to get books again from the server to refresh the page. |
|
||||
|
|
||||
#### Add a Modal to BookListComponent |
|
||||
|
|
||||
Open the `book-list.component.html` and add the `abp-modal` to show/hide the modal to create a new book. |
|
||||
|
|
||||
```html |
|
||||
<abp-modal [(visible)]="isModalOpen"> |
|
||||
<ng-template #abpHeader> |
|
||||
<h3>New Book</h3> |
|
||||
</ng-template> |
|
||||
|
|
||||
<ng-template #abpBody> </ng-template> |
|
||||
|
|
||||
<ng-template #abpFooter> |
|
||||
<button type="button" class="btn btn-secondary" #abpClose> |
|
||||
Cancel |
|
||||
</button> |
|
||||
</ng-template> |
|
||||
</abp-modal> |
|
||||
``` |
|
||||
|
|
||||
`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. |
|
||||
|
|
||||
Add a button, labeled `New book` to show the modal: |
|
||||
|
|
||||
```html |
|
||||
<div class="row"> |
|
||||
<div class="col col-md-6"> |
|
||||
<h5 class="card-title"> |
|
||||
Books |
|
||||
</h5> |
|
||||
</div> |
|
||||
<div class="text-right col col-md-6"> |
|
||||
<button id="create-role" class="btn btn-primary" type="button" (click)="createBook()"> |
|
||||
<i class="fa fa-plus mr-1"></i> <span>New book</span> |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
``` |
|
||||
|
|
||||
Open the `book-list.component.ts` and add `isModalOpen` variable and `createBook` method to show/hide the modal. |
|
||||
|
|
||||
```js |
|
||||
isModalOpen = false; |
|
||||
|
|
||||
//... |
|
||||
|
|
||||
createBook() { |
|
||||
this.isModalOpen = true; |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
 |
|
||||
|
|
||||
#### 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. |
|
||||
|
|
||||
Add a `form` variable and inject a `FormBuilder` service to the `book-list.component.ts` as shown below (remember add the import statement). |
|
||||
|
|
||||
```js |
|
||||
import { FormGroup, FormBuilder, Validators } from '@angular/forms'; |
|
||||
|
|
||||
form: FormGroup; |
|
||||
|
|
||||
constructor( |
|
||||
//... |
|
||||
private fb: FormBuilder |
|
||||
) {} |
|
||||
``` |
|
||||
|
|
||||
> 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. |
|
||||
|
|
||||
Add the `buildForm` method to create book form. |
|
||||
|
|
||||
```js |
|
||||
buildForm() { |
|
||||
this.form = this.fb.group({ |
|
||||
name: ['', Validators.required], |
|
||||
type: [null, Validators.required], |
|
||||
publishDate: [null, Validators.required], |
|
||||
price: [null, Validators.required], |
|
||||
}); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
- The `group` method of `FormBuilder` (`fb`) creates a `FormGroup`. |
|
||||
- Added `Validators.required` static method that validates the related form element. |
|
||||
|
|
||||
Modify the `createBook` method as shown below: |
|
||||
|
|
||||
```js |
|
||||
createBook() { |
|
||||
this.buildForm(); |
|
||||
this.isModalOpen = true; |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### Create the DOM Elements of the Form |
|
||||
|
|
||||
Open `book-list.component.html` and add the form in the body template of the modal. |
|
||||
|
|
||||
```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 |
|
||||
|
|
||||
You need to import `NgbDatepickerModule` to the `books.module.ts`: |
|
||||
|
|
||||
```js |
|
||||
import { NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; |
|
||||
|
|
||||
@NgModule({ |
|
||||
imports: [ |
|
||||
// ... |
|
||||
NgbDatepickerModule, |
|
||||
], |
|
||||
}) |
|
||||
export class BooksModule {} |
|
||||
``` |
|
||||
|
|
||||
Then open the `book-list.component.ts` and add `providers` as shown below: |
|
||||
|
|
||||
```js |
|
||||
import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'; |
|
||||
|
|
||||
@Component({ |
|
||||
// ... |
|
||||
providers: [{ provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }], |
|
||||
}) |
|
||||
export class BookListComponent implements OnInit { |
|
||||
// ... |
|
||||
``` |
|
||||
|
|
||||
> The `NgbDateAdapter` converts Datepicker value to `Date` type. See the [datepicker adapters](https://ng-bootstrap.github.io/#/components/datepicker/overview) for more details. |
|
||||
|
|
||||
#### Create the Book Type Array |
|
||||
|
|
||||
Open the `book-list.component.ts` and then create an array, named `bookTypeArr`: |
|
||||
|
|
||||
```js |
|
||||
//... |
|
||||
booksType = Books.BookType; |
|
||||
|
|
||||
bookTypeArr = Object.keys(Books.BookType).filter( |
|
||||
bookType => typeof this.booksType[bookType] === 'number' |
|
||||
); |
|
||||
``` |
|
||||
|
|
||||
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). |
|
||||
|
|
||||
|
|
||||
 |
|
||||
|
|
||||
#### Saving the Book |
|
||||
|
|
||||
Open the `book-list.component.html` and add an `abp-button` to save the form. |
|
||||
|
|
||||
```html |
|
||||
<ng-template #abpFooter> |
|
||||
<button type="button" class="btn btn-secondary" #abpClose> |
|
||||
Cancel |
|
||||
</button> |
|
||||
<button class="btn btn-primary" (click)="save()"> |
|
||||
<i class="fa fa-check mr-1"></i> |
|
||||
Save |
|
||||
</button> |
|
||||
</ng-template> |
|
||||
``` |
|
||||
|
|
||||
This adds a save button to the bottom area of the modal: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Then define a `save` method in the `BookListComponent`: |
|
||||
|
|
||||
```js |
|
||||
//... |
|
||||
import { ..., CreateUpdateBook } from '../../store/actions'; |
|
||||
//... |
|
||||
save() { |
|
||||
if (this.form.invalid) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
this.store.dispatch(new CreateUpdateBook(this.form.value)).subscribe(() => { |
|
||||
this.isModalOpen = false; |
|
||||
this.form.reset(); |
|
||||
}); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### Updating An Existing Book |
|
||||
|
|
||||
#### BooksService |
|
||||
|
|
||||
Open the `books.service.ts` and then 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` and add `id` parameter to the `CreateUpdateBook` action: |
|
||||
|
|
||||
```js |
|
||||
export class CreateUpdateBook { |
|
||||
static readonly type = '[Books] Create Update Book'; |
|
||||
constructor(public payload: Books.CreateUpdateBookInput, public id?: string) {} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
Open `books.state.ts` and modify the `save` method as show below: |
|
||||
|
|
||||
```js |
|
||||
@Action(CreateUpdateBook) |
|
||||
save(ctx: StateContext<Books.State>, action: CreateUpdateBook) { |
|
||||
let request; |
|
||||
|
|
||||
if (action.id) { |
|
||||
request = this.booksService.update(action.payload, action.id); |
|
||||
} else { |
|
||||
request = this.booksService.create(action.payload); |
|
||||
} |
|
||||
|
|
||||
return request.pipe(switchMap(() => ctx.dispatch(new GetBooks()))); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### BookListComponent |
|
||||
|
|
||||
Inject `BooksService` dependency by adding it to the `book-list.component.ts` constructor and add a variable named `selectedBook`. |
|
||||
|
|
||||
```js |
|
||||
import { BooksService } from '../shared/books.service'; |
|
||||
//... |
|
||||
selectedBook = {} as Books.Book; |
|
||||
|
|
||||
constructor( |
|
||||
//... |
|
||||
private booksService: BooksService |
|
||||
) |
|
||||
``` |
|
||||
|
|
||||
`booksService` is used to get the editing book to prepare the form. Modify the `buildForm` method to reuse the same form while editing a book. |
|
||||
|
|
||||
```js |
|
||||
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, |
|
||||
}); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
Add the `editBook` method as shown below: |
|
||||
|
|
||||
```js |
|
||||
editBook(id: string) { |
|
||||
this.booksService.getById(id).subscribe(book => { |
|
||||
this.selectedBook = book; |
|
||||
this.buildForm(); |
|
||||
this.isModalOpen = true; |
|
||||
}); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
Added `editBook` method to get the editing book, build the form and show the modal. |
|
||||
|
|
||||
Now, add the `selectedBook` definition to `createBook` method to reuse the same form while creating a new book: |
|
||||
|
|
||||
```js |
|
||||
createBook() { |
|
||||
this.selectedBook = {} as Books.Book; |
|
||||
//... |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
Modify the `save` method to pass the id of the selected book as shown below: |
|
||||
|
|
||||
```js |
|
||||
save() { |
|
||||
if (this.form.invalid) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
this.store.dispatch(new CreateUpdateBook(this.form.value, this.selectedBook.id)) |
|
||||
.subscribe(() => { |
|
||||
this.isModalOpen = false; |
|
||||
this.form.reset(); |
|
||||
}); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### Add "Actions" Dropdown to the Table |
|
||||
|
|
||||
Open the `book-list.component.html` and add modify the `p-table` as shown below: |
|
||||
|
|
||||
```html |
|
||||
<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> |
|
||||
``` |
|
||||
|
|
||||
- Added a `th` for the "Actions" column. |
|
||||
- Added `button` with `ngbDropdownToggle` to open actions when clicked the button. |
|
||||
|
|
||||
> We've used to [NgbDropdown](https://ng-bootstrap.github.io/#/components/dropdown/examples) for the dropdown menu of actions. |
|
||||
|
|
||||
The final UI looks like: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Update the modal header to change the title based on the current operation: |
|
||||
|
|
||||
```html |
|
||||
<ng-template #abpHeader> |
|
||||
<h3>{%{{{ selectedBook.id ? 'Edit' : 'New Book' }}}%}</h3> |
|
||||
</ng-template> |
|
||||
``` |
|
||||
|
|
||||
 |
|
||||
|
|
||||
### Deleting an Existing Book |
|
||||
|
|
||||
#### BooksService |
|
||||
|
|
||||
Open `books.service.ts` and add a `delete` method to delete a book with the `id` by performing an HTTP request to the related endpoint: |
|
||||
|
|
||||
```js |
|
||||
delete(id: string): Observable<void> { |
|
||||
return this.restService.request<void, void>({ |
|
||||
method: 'DELETE', |
|
||||
url: `/api/app/book/${id}` |
|
||||
}); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
#### DeleteBook Action |
|
||||
|
|
||||
Add an action named `DeleteBook` to `books.actions.ts`: |
|
||||
|
|
||||
```js |
|
||||
export class DeleteBook { |
|
||||
static readonly type = '[Books] Delete'; |
|
||||
constructor(public id: string) {} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
Open the `books.state.ts` and add the `delete` method that will listen to the `DeleteBook` action to delete a book: |
|
||||
|
|
||||
```js |
|
||||
import { ... , DeleteBook } from '../actions/books.actions'; |
|
||||
//... |
|
||||
@Action(DeleteBook) |
|
||||
delete(ctx: StateContext<Books.State>, action: DeleteBook) { |
|
||||
return this.booksService.delete(action.id).pipe(switchMap(() => ctx.dispatch(new GetBooks()))); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
- Added `DeleteBook` to the import list. |
|
||||
- Uses `bookService` to delete the book. |
|
||||
|
|
||||
#### Add a Delete Button |
|
||||
|
|
||||
Open `book-list.component.html` and modify the `ngbDropdownMenu` to add the delete button as shown below: |
|
||||
|
|
||||
```html |
|
||||
<div ngbDropdownMenu> |
|
||||
... |
|
||||
<button ngbDropdownItem (click)="delete(data.id, data.name)"> |
|
||||
Delete |
|
||||
</button> |
|
||||
</div> |
|
||||
``` |
|
||||
|
|
||||
The final actions dropdown UI looks like below: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
#### Delete Confirmation Dialog |
|
||||
|
|
||||
Open `book-list.component.ts` and inject the `ConfirmationService`. |
|
||||
|
|
||||
```js |
|
||||
import { ConfirmationService } from '@abp/ng.theme.shared'; |
|
||||
//... |
|
||||
constructor( |
|
||||
//... |
|
||||
private confirmationService: ConfirmationService |
|
||||
) |
|
||||
``` |
|
||||
|
|
||||
> `ConfirmationService` is a simple service provided by ABP framework that internally uses the PrimeNG. |
|
||||
|
|
||||
Add a delete method to the `BookListComponent`: |
|
||||
|
|
||||
```js |
|
||||
import { ... , DeleteBook } from '../../store/actions'; |
|
||||
import { ... , Toaster } from '@abp/ng.theme.shared'; |
|
||||
//... |
|
||||
delete(id: string, name: string) { |
|
||||
this.confirmationService |
|
||||
.error(`${name} will be deleted. Do you confirm that?`, 'Are you sure?') |
|
||||
.subscribe(status => { |
|
||||
if (status === Toaster.Status.confirm) { |
|
||||
this.store.dispatch(new DeleteBook(id)); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
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: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
### Next Part |
|
||||
|
|
||||
See the [next part](Part-III.md) of this tutorial. |
|
||||
|
|||||
@ -1,178 +1,6 @@ |
|||||
## Angular Tutorial - Part III |
# Tutorials |
||||
|
|
||||
### About this Tutorial |
## Application Development |
||||
|
|
||||
This is the third part of the Angular tutorial series. See all parts: |
* [With ASP.NET Core MVC / Razor Pages UI](../Part-1?UI=MVC) |
||||
|
* [With Angular UI](../Part-1?UI=NG) |
||||
- [Part I: Create the project and a book list page](Part-I.md) |
|
||||
- [Part II: Create, Update and Delete books](Part-II.md) |
|
||||
- **Part III: Integration Tests (this tutorial)** |
|
||||
|
|
||||
This part covers the **server side** tests. You can access to the **source code** of the application from the [GitHub repository](https://github.com/abpframework/abp/tree/dev/samples/BookStore-Angular-MongoDb). |
|
||||
|
|
||||
### Test Projects in the Solution |
|
||||
|
|
||||
There are multiple test projects in the solution: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Each project is used to test the related application project. Test projects use the following libraries for testing: |
|
||||
|
|
||||
* [xunit](https://xunit.github.io/) as the main test framework. |
|
||||
* [Shoudly](http://shouldly.readthedocs.io/en/latest/) as an assertion library. |
|
||||
* [NSubstitute](http://nsubstitute.github.io/) as a mocking library. |
|
||||
|
|
||||
### Adding Test Data |
|
||||
|
|
||||
Startup template contains the `BookStoreTestDataSeedContributor` class in the `Acme.BookStore.TestBase` project that creates some data to run tests on. |
|
||||
|
|
||||
Change the `BookStoreTestDataSeedContributor` class as show below: |
|
||||
|
|
||||
````C# |
|
||||
using System; |
|
||||
using System.Threading.Tasks; |
|
||||
using Volo.Abp.Data; |
|
||||
using Volo.Abp.DependencyInjection; |
|
||||
using Volo.Abp.Domain.Repositories; |
|
||||
using Volo.Abp.Guids; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookStoreTestDataSeedContributor |
|
||||
: IDataSeedContributor, ITransientDependency |
|
||||
{ |
|
||||
private readonly IRepository<Book, Guid> _bookRepository; |
|
||||
private readonly IGuidGenerator _guidGenerator; |
|
||||
|
|
||||
public BookStoreTestDataSeedContributor( |
|
||||
IRepository<Book, Guid> bookRepository, |
|
||||
IGuidGenerator guidGenerator) |
|
||||
{ |
|
||||
_bookRepository = bookRepository; |
|
||||
_guidGenerator = guidGenerator; |
|
||||
} |
|
||||
|
|
||||
public async Task SeedAsync(DataSeedContext context) |
|
||||
{ |
|
||||
await _bookRepository.InsertAsync( |
|
||||
new Book |
|
||||
{ |
|
||||
Id = _guidGenerator.Create(), |
|
||||
Name = "Test book 1", |
|
||||
Type = BookType.Fantastic, |
|
||||
PublishDate = new DateTime(2015, 05, 24), |
|
||||
Price = 21 |
|
||||
} |
|
||||
); |
|
||||
|
|
||||
await _bookRepository.InsertAsync( |
|
||||
new Book |
|
||||
{ |
|
||||
Id = _guidGenerator.Create(), |
|
||||
Name = "Test book 2", |
|
||||
Type = BookType.Science, |
|
||||
PublishDate = new DateTime(2014, 02, 11), |
|
||||
Price = 15 |
|
||||
} |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* Injected `IRepository<Book, Guid>` and used it in the `SeedAsync` to create two book entities as the test data. |
|
||||
* Used `IGuidGenerator` service to create GUIDs. While `Guid.NewGuid()` would perfectly work for testing, `IGuidGenerator` has additional features especially important while using real databases (see the [Guid generation document](../../Guid-Generation.md) for more). |
|
||||
|
|
||||
### Testing the BookAppService |
|
||||
|
|
||||
Create a test class named `BookAppService_Tests` in the `Acme.BookStore.Application.Tests` project: |
|
||||
|
|
||||
````C# |
|
||||
using System.Threading.Tasks; |
|
||||
using Shouldly; |
|
||||
using Volo.Abp.Application.Dtos; |
|
||||
using Xunit; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookAppService_Tests : BookStoreApplicationTestBase |
|
||||
{ |
|
||||
private readonly IBookAppService _bookAppService; |
|
||||
|
|
||||
public BookAppService_Tests() |
|
||||
{ |
|
||||
_bookAppService = GetRequiredService<IBookAppService>(); |
|
||||
} |
|
||||
|
|
||||
[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 == "Test book 1"); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* `Should_Get_List_Of_Books` test simply uses `BookAppService.GetListAsync` method to get and check the list of users. |
|
||||
|
|
||||
Add a new test that creates a valid new book: |
|
||||
|
|
||||
````C# |
|
||||
[Fact] |
|
||||
public async Task Should_Create_A_Valid_Book() |
|
||||
{ |
|
||||
//Act |
|
||||
var result = await _bookAppService.CreateAsync( |
|
||||
new CreateUpdateBookDto |
|
||||
{ |
|
||||
Name = "New test book 42", |
|
||||
Price = 10, |
|
||||
PublishDate = DateTime.Now, |
|
||||
Type = BookType.ScienceFiction |
|
||||
} |
|
||||
); |
|
||||
|
|
||||
//Assert |
|
||||
result.Id.ShouldNotBe(Guid.Empty); |
|
||||
result.Name.ShouldBe("New test book 42"); |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
Add a new test that tries to create an invalid book and fails: |
|
||||
|
|
||||
````C# |
|
||||
[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(mem => mem == "Name")); |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* Since the `Name` is empty, ABP throws an `AbpValidationException`. |
|
||||
|
|
||||
Open the **Test Explorer Window** (use Test -> Windows -> Test Explorer menu if it is not visible) and **Run All** tests: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Congratulations, green icons show that tests have been successfully passed! |
|
||||
|
|||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@ -1,476 +1,6 @@ |
|||||
## ASP.NET Core MVC Tutorial - Part I |
# Tutorials |
||||
|
|
||||
### About this Tutorial |
## Application Development |
||||
|
|
||||
In this tutorial series, you will build an application that is used to manage a list of books & their authors. **Entity Framework Core** (EF Core) will be used as the ORM provider as it is the default database provider. |
* [With ASP.NET Core MVC / Razor Pages UI](../Part-1?UI=MVC) |
||||
|
* [With Angular UI](../Part-1?UI=NG) |
||||
This is the first part of the ASP.NET Core MVC tutorial series. See all parts: |
|
||||
|
|
||||
- **Part I: Create the project and a book list page (this tutorial)** |
|
||||
- [Part II: Create, Update and Delete books](Part-II.md) |
|
||||
- [Part III: Integration Tests](Part-III.md) |
|
||||
|
|
||||
You can access to the **source code** of the application from [the GitHub repository](https://github.com/abpframework/abp/tree/master/samples/BookStore). |
|
||||
|
|
||||
> 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. |
|
||||
|
|
||||
### Creating the Project |
|
||||
|
|
||||
Create a new project named `Acme.BookStore`, create the database and run the application by following the [Getting Started document](../../Getting-Started-AspNetCore-MVC-Template.md). |
|
||||
|
|
||||
### Solution Structure |
|
||||
|
|
||||
This is how the layered solution structure looks after it's created: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
> You can see the [Application template document](../../Startup-Templates/Application.md) to understand the solution structure in details. However, you will understand the basics with this tutorial. |
|
||||
|
|
||||
### Create the Book Entity |
|
||||
|
|
||||
Domain layer in the startup template is separated into two projects: |
|
||||
|
|
||||
- `Acme.BookStore.Domain` contains your [entities](../../Entities.md), [domain services](../../Domain-Services.md) and other core domain objects. |
|
||||
- `Acme.BookStore.Domain.Shared` contains constants, enums or other domain related objects those can be shared with clients. |
|
||||
|
|
||||
Define [entities](../../Entities.md) in the **domain layer** (`Acme.BookStore.Domain` project) of the solution. The main entity of the application is the `Book`. Create a class, named `Book`, in the `Acme.BookStore.Domain` project as shown below: |
|
||||
|
|
||||
````C# |
|
||||
using System; |
|
||||
using Volo.Abp.Domain.Entities.Auditing; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class Book : AuditedAggregateRoot<Guid> |
|
||||
{ |
|
||||
public string Name { get; set; } |
|
||||
|
|
||||
public BookType Type { get; set; } |
|
||||
|
|
||||
public DateTime PublishDate { get; set; } |
|
||||
|
|
||||
public float Price { get; set; } |
|
||||
|
|
||||
protected Book() |
|
||||
{ |
|
||||
|
|
||||
} |
|
||||
|
|
||||
public Book(Guid id, string name, BookType type, DateTime publishDate, float price) |
|
||||
:base(id) |
|
||||
{ |
|
||||
Name = name; |
|
||||
Type = type; |
|
||||
PublishDate = publishDate; |
|
||||
Price = price; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* ABP has two fundamental base classes for entities: `AggregateRoot` and `Entity`. **Aggregate Root** is one of the **Domain Driven Design (DDD)** concepts. See [entity document](../../Entities.md) for details and best practices. |
|
||||
* `Book` entity inherits `AuditedAggregateRoot` which adds some auditing properties (`CreationTime`, `CreatorId`, `LastModificationTime`... etc.) on top of the `AggregateRoot` class. |
|
||||
* `Guid` is the **primary key type** of the `Book` entity. |
|
||||
|
|
||||
#### BookType Enum |
|
||||
|
|
||||
Define the `BookType` enum in the `Acme.BookStore.Domain.Shared` project: |
|
||||
|
|
||||
````C# |
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public enum BookType |
|
||||
{ |
|
||||
Undefined, |
|
||||
Adventure, |
|
||||
Biography, |
|
||||
Dystopia, |
|
||||
Fantastic, |
|
||||
Horror, |
|
||||
Science, |
|
||||
ScienceFiction, |
|
||||
Poetry |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
#### Add Book Entity to Your DbContext |
|
||||
|
|
||||
EF Core requires you to relate entities with your DbContext. The easiest way to do this is to add a `DbSet` property to the `BookStoreDbContext` class in the `Acme.BookStore.EntityFrameworkCore` project, as shown below: |
|
||||
|
|
||||
````C# |
|
||||
public class BookStoreDbContext : AbpDbContext<BookStoreDbContext> |
|
||||
{ |
|
||||
public DbSet<Book> Books { get; set; } |
|
||||
... |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
#### Configure Your Book Entity |
|
||||
|
|
||||
Open `BookStoreDbContextModelCreatingExtensions.cs` file in the `Acme.BookStore.EntityFrameworkCore` project and add following code to the end of the `ConfigureBookStore` method to configure the Book entity: |
|
||||
|
|
||||
````C# |
|
||||
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 New Migration & Update the Database |
|
||||
|
|
||||
The Startup template uses [EF Core Code First Migrations](https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/) to create and maintain the database schema. Open the **Package Manager Console (PMC)** (under the *Tools/Nuget Package Manager* menu), select the `Acme.BookStore.EntityFrameworkCore.DbMigrations` as the **default project** and execute the following command: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
This will create a new migration class inside the `Migrations` folder. Then execute the `Update-Database` command to update the database schema: |
|
||||
|
|
||||
```` |
|
||||
PM> Update-Database |
|
||||
```` |
|
||||
|
|
||||
#### Add Sample Data |
|
||||
|
|
||||
`Update-Database` command created the `AppBooks` table in the database. Open your database and enter a few sample rows, so you can show them on the page: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
### Create the Application Service |
|
||||
|
|
||||
The next step is to create an [application service](../../Application-Services.md) to manage (create, list, update, delete...) the books. Application layer in the startup template is separated into two projects: |
|
||||
|
|
||||
* `Acme.BookStore.Application.Contracts` mainly contains your DTOs and application service interfaces. |
|
||||
* `Acme.BookStore.Application` contains the implementations of your application services. |
|
||||
|
|
||||
#### BookDto |
|
||||
|
|
||||
Create a DTO class named `BookDto` into the `Acme.BookStore.Application.Contracts` project: |
|
||||
|
|
||||
````C# |
|
||||
using System; |
|
||||
using Volo.Abp.Application.Dtos; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookDto : AuditedEntityDto<Guid> |
|
||||
{ |
|
||||
public string Name { get; set; } |
|
||||
|
|
||||
public BookType Type { get; set; } |
|
||||
|
|
||||
public DateTime PublishDate { get; set; } |
|
||||
|
|
||||
public float Price { get; set; } |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* **DTO** classes are used to **transfer data** between the *presentation layer* and the *application layer*. See the [Data Transfer Objects document](../../Data-Transfer-Objects.md) for more details. |
|
||||
* `BookDto` is used to transfer book data to the presentation layer in order to show the book information on the UI. |
|
||||
* `BookDto` is derived from the `AuditedEntityDto<Guid>` which has audit properties just like the `Book` class defined above. |
|
||||
|
|
||||
It will be needed to convert `Book` entities to `BookDto` objects while returning books to the presentation layer. [AutoMapper](https://automapper.org) library can automate this conversion when you define the proper mapping. Startup template comes with AutoMapper configured, so you can just define the mapping in the `BookStoreApplicationAutoMapperProfile` class in the `Acme.BookStore.Application` project: |
|
||||
|
|
||||
````csharp |
|
||||
using AutoMapper; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookStoreApplicationAutoMapperProfile : Profile |
|
||||
{ |
|
||||
public BookStoreApplicationAutoMapperProfile() |
|
||||
{ |
|
||||
CreateMap<Book, BookDto>(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
#### CreateUpdateBookDto |
|
||||
|
|
||||
Create a DTO class named `CreateUpdateBookDto` into the `Acme.BookStore.Application.Contracts` project: |
|
||||
|
|
||||
````c# |
|
||||
using System; |
|
||||
using System.ComponentModel.DataAnnotations; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class CreateUpdateBookDto |
|
||||
{ |
|
||||
[Required] |
|
||||
[StringLength(128)] |
|
||||
public string Name { get; set; } |
|
||||
|
|
||||
[Required] |
|
||||
public BookType Type { get; set; } = BookType.Undefined; |
|
||||
|
|
||||
[Required] |
|
||||
public DateTime PublishDate { get; set; } |
|
||||
|
|
||||
[Required] |
|
||||
public float Price { get; set; } |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* This DTO class is used to get book information from the user interface while creating or updating a book. |
|
||||
* It defines data annotation attributes (like `[Required]`) to define validations for the properties. DTOs are [automatically validated](../../Validation.md) by the ABP framework. |
|
||||
|
|
||||
Next, add a mapping in `BookStoreApplicationAutoMapperProfile` from the `CreateUpdateBookDto` object to the `Book` entity: |
|
||||
|
|
||||
````csharp |
|
||||
CreateMap<CreateUpdateBookDto, Book>(); |
|
||||
```` |
|
||||
|
|
||||
#### IBookAppService |
|
||||
|
|
||||
Define an interface named `IBookAppService` in the `Acme.BookStore.Application.Contracts` project: |
|
||||
|
|
||||
````C# |
|
||||
using System; |
|
||||
using Volo.Abp.Application.Dtos; |
|
||||
using Volo.Abp.Application.Services; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public interface IBookAppService : |
|
||||
ICrudAppService< //Defines CRUD methods |
|
||||
BookDto, //Used to show books |
|
||||
Guid, //Primary key of the book entity |
|
||||
PagedAndSortedResultRequestDto, //Used for paging/sorting on getting a list of books |
|
||||
CreateUpdateBookDto, //Used to create a new book |
|
||||
CreateUpdateBookDto> //Used to update a book |
|
||||
{ |
|
||||
|
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* Defining interfaces for application services is <u>not required</u> by the framework. However, it's suggested as a best practice. |
|
||||
* `ICrudAppService` defines common **CRUD** methods: `GetAsync`, `GetListAsync`, `CreateAsync`, `UpdateAsync` and `DeleteAsync`. It's not required to extend it. Instead, you could inherit from the empty `IApplicationService` interface and define your own methods manually. |
|
||||
* There are some variations of the `ICrudAppService` where you can use separated DTOs for each method. |
|
||||
|
|
||||
#### BookAppService |
|
||||
|
|
||||
Implement the `IBookAppService` as named `BookAppService` in the `Acme.BookStore.Application` project: |
|
||||
|
|
||||
````C# |
|
||||
using System; |
|
||||
using Volo.Abp.Application.Dtos; |
|
||||
using Volo.Abp.Application.Services; |
|
||||
using Volo.Abp.Domain.Repositories; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookAppService : |
|
||||
CrudAppService<Book, BookDto, Guid, PagedAndSortedResultRequestDto, |
|
||||
CreateUpdateBookDto, CreateUpdateBookDto>, |
|
||||
IBookAppService |
|
||||
{ |
|
||||
public BookAppService(IRepository<Book, Guid> repository) |
|
||||
: base(repository) |
|
||||
{ |
|
||||
|
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* `BookAppService` is derived from `CrudAppService<...>` which implements all the CRUD methods defined above. |
|
||||
* `BookAppService` injects `IRepository<Book, Guid>` which is the default repository for the `Book` entity. ABP automatically creates default repositories for each aggregate root (or entity). See the [repository document](../../Repositories.md). |
|
||||
* `BookAppService` uses `IObjectMapper` to convert `Book` objects to `BookDto` objects and `CreateUpdateBookDto` objects to `Book` objects. The Startup template uses the [AutoMapper](http://automapper.org/) library as the object mapping provider. You defined the mappings before, so it will work as expected. |
|
||||
|
|
||||
### Auto API Controllers |
|
||||
|
|
||||
You normally create **Controllers** to expose application services as **HTTP API** endpoints. Thus allowing browser or 3rd-party clients to call them via AJAX. ABP can [**automagically**](../../AspNetCore/Auto-API-Controllers.md) configures your application services as MVC API Controllers by convention. |
|
||||
|
|
||||
#### Swagger UI |
|
||||
|
|
||||
The startup template is configured to run the [swagger UI](https://swagger.io/tools/swagger-ui/) using the [Swashbuckle.AspNetCore](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) library. Run the application and enter `https://localhost:XXXX/swagger/` (replace XXXX by your own port) as URL on your browser. |
|
||||
|
|
||||
You will see some built-in service endpoints as well as the `Book` service and its REST-style endpoints: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Swagger has a nice UI to test APIs. You can try to execute the `[GET] /api/app/book` API to get a list of books. |
|
||||
|
|
||||
### Dynamic JavaScript Proxies |
|
||||
|
|
||||
It's common to call HTTP API endpoints via AJAX from the **JavaScript** side. You can use `$.ajax` or another tool to call the endpoints. However, ABP offers a better way. |
|
||||
|
|
||||
ABP **dynamically** creates JavaScript **proxies** for all API endpoints. So, you can use any **endpoint** just like calling a **JavaScript function**. |
|
||||
|
|
||||
#### Testing in the Browser Developer Console |
|
||||
|
|
||||
You can easily test the JavaScript proxies using your favorite browser's **Developer Console** now. Run the application, open your browser's **developer tools** (shortcut: F12), switch to the **Console** tab, type the following code and press enter: |
|
||||
|
|
||||
````js |
|
||||
acme.bookStore.book.getList({}).done(function (result) { console.log(result); }); |
|
||||
```` |
|
||||
|
|
||||
* `acme.bookStore` is the namespace of the `BookAppService` converted to [camelCase](https://en.wikipedia.org/wiki/Camel_case). |
|
||||
* `book` is the conventional name for the `BookAppService` (removed AppService postfix and converted to camelCase). |
|
||||
* `getList` is the conventional name for the `GetListAsync` method defined in the `AsyncCrudAppService` base class (removed Async postfix and converted to camelCase). |
|
||||
* `{}` argument is used to send an empty object to the `GetListAsync` method which normally expects an object of type `PagedAndSortedResultRequestDto` that is used to send paging and sorting options to the server (all properties are optional, so you can send an empty object). |
|
||||
* `getList` function returns a `promise`. So, you can pass a callback to the `done` (or `then`) function to get the result from the server. |
|
||||
|
|
||||
Running this code produces the following output: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
You can see the **book list** returned from the server. You can also check the **network** tab of the developer tools to see the client to server communication: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Let's **create a new book** using the `create` function: |
|
||||
|
|
||||
````js |
|
||||
acme.bookStore.book.create({ name: 'Foundation', type: 7, publishDate: '1951-05-24', price: 21.5 }).done(function (result) { console.log('successfully created the book with id: ' + result.id); }); |
|
||||
```` |
|
||||
|
|
||||
You should see a message in the console something like that: |
|
||||
|
|
||||
```` |
|
||||
successfully created the book with id: f3f03580-c1aa-d6a9-072d-39e75c69f5c7 |
|
||||
```` |
|
||||
|
|
||||
Check the `Books` table in the database to see the new book row. You can try `get`, `update` and `delete` functions yourself. |
|
||||
|
|
||||
### Create the Books Page |
|
||||
|
|
||||
It's time to create something visible and usable! Instead of classic MVC, we will use the new [Razor Pages UI](https://docs.microsoft.com/en-us/aspnet/core/tutorials/razor-pages/razor-pages-start) approach which is recommended by Microsoft. |
|
||||
|
|
||||
Create a new `Books` folder under the `Pages` folder of the `Acme.BookStore.Web` project and add a new Razor Page named `Index.cshtml`: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Open the `Index.cshtml` and change the content as shown below: |
|
||||
|
|
||||
````html |
|
||||
@page |
|
||||
@using Acme.BookStore.Web.Pages.Books |
|
||||
@inherits Acme.BookStore.Web.Pages.BookStorePage |
|
||||
@model IndexModel |
|
||||
|
|
||||
<h2>Books</h2> |
|
||||
```` |
|
||||
|
|
||||
* This code changes the default inheritance of the Razor View Page Model so it **inherits** from the `BookStorePage` class (instead of `PageModel`). The `BookStorePage` class which comes with the startup template and provides some shared properties/methods used by all pages. |
|
||||
* Ensure that the `IndexModel` (*Index.cshtml.cs)* has the `Acme.BookStore.Web.Pages.Books` namespace, or update it in the `Index.cshtml`. |
|
||||
|
|
||||
#### Add Books Page to the Main Menu |
|
||||
|
|
||||
Open the `BookStoreMenuContributor` class in the `Menus` folder and add the following code to the end of the `ConfigureMainMenuAsync` method: |
|
||||
|
|
||||
````c# |
|
||||
context.Menu.AddItem( |
|
||||
new ApplicationMenuItem("BooksStore", l["Menu:BookStore"]) |
|
||||
.AddItem(new ApplicationMenuItem("BooksStore.Books", l["Menu:Books"], url: "/Books")) |
|
||||
); |
|
||||
```` |
|
||||
|
|
||||
#### Localizing the Menu Items |
|
||||
|
|
||||
Localization texts are located under the `Localization/BookStore` folder of the `Acme.BookStore.Domain.Shared` project: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Open the `en.json` file and add localization texts for `Menu:BookStore` and `Menu:Books` keys to the end of the file: |
|
||||
|
|
||||
````json |
|
||||
{ |
|
||||
"culture": "en", |
|
||||
"texts": { |
|
||||
"Menu:BookStore": "Book Store", |
|
||||
"Menu:Books": "Books" |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* ABP's localization system is built on [ASP.NET Core's standard localization](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization) system and extends it in many ways. See the [localization document](../../Localization.md) for details. |
|
||||
* Localization key names are arbitrary. You can set any name. We prefer to add `Menu:` prefix for menu items to distinguish from other texts. If a text is not defined in the localization file, it **fallbacks** to the localization key (ASP.NET Core's standard behavior). |
|
||||
|
|
||||
Run the application and see the new menu item has been added to the top bar: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
When you click to the Books menu item, you are redirected to the new Books page. |
|
||||
|
|
||||
#### Book List |
|
||||
|
|
||||
We will use the [Datatables.net](https://datatables.net/) JQuery plugin to show list of tables on the page. Datatables can completely work via AJAX, it is fast and provides a good user experience. Datatables plugin is configured in the startup template, so you can directly use it in any page without including any style or script file to your page. |
|
||||
|
|
||||
##### Index.cshtml |
|
||||
|
|
||||
Change the `Pages/Books/Index.cshtml` as following: |
|
||||
|
|
||||
````html |
|
||||
@page |
|
||||
@inherits Acme.BookStore.Web.Pages.BookStorePage |
|
||||
@model Acme.BookStore.Web.Pages.Books.IndexModel |
|
||||
@section scripts |
|
||||
{ |
|
||||
<abp-script src="/Pages/Books/index.js" /> |
|
||||
} |
|
||||
<abp-card> |
|
||||
<abp-card-header> |
|
||||
<h2>@L["Books"]</h2> |
|
||||
</abp-card-header> |
|
||||
<abp-card-body> |
|
||||
<abp-table striped-rows="true" id="BooksTable"> |
|
||||
<thead> |
|
||||
<tr> |
|
||||
<th>@L["Name"]</th> |
|
||||
<th>@L["Type"]</th> |
|
||||
<th>@L["PublishDate"]</th> |
|
||||
<th>@L["Price"]</th> |
|
||||
<th>@L["CreationTime"]</th> |
|
||||
</tr> |
|
||||
</thead> |
|
||||
</abp-table> |
|
||||
</abp-card-body> |
|
||||
</abp-card> |
|
||||
```` |
|
||||
|
|
||||
* `abp-script` [tag helper](https://docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro) is used to add external **scripts** to the page. It has many additional features compared to standard `script` tag. It handles **minification** and **versioning** for example. See the [bundling & minification document](../../AspNetCore/Bundling-Minification.md) for details. |
|
||||
* `abp-card` and `abp-table` are **tag helpers** for Twitter Bootstrap's [card component](http://getbootstrap.com/docs/4.1/components/card/). There are many tag helpers in ABP to easily use most of the [bootstrap](https://getbootstrap.com/) components. You can also use regular HTML tags instead of these tag helpers, but using tag helpers reduces HTML code and prevents errors by help of the intellisense and compile time type checking. See the [tag helpers document](../../AspNetCore/Tag-Helpers/Index.md). |
|
||||
* You can **localize** the column names in the localization file as you did for the menu items above. |
|
||||
|
|
||||
##### Add a Script File |
|
||||
|
|
||||
Create `index.js` JavaScript file under the `Pages/Books/` folder: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
`index.js` content is shown below: |
|
||||
|
|
||||
````js |
|
||||
$(function () { |
|
||||
var dataTable = $('#BooksTable').DataTable(abp.libs.datatables.normalizeConfiguration({ |
|
||||
ajax: abp.libs.datatables.createAjax(acme.bookStore.book.getList), |
|
||||
columnDefs: [ |
|
||||
{ data: "name" }, |
|
||||
{ data: "type" }, |
|
||||
{ data: "publishDate" }, |
|
||||
{ data: "price" }, |
|
||||
{ data: "creationTime" } |
|
||||
] |
|
||||
})); |
|
||||
}); |
|
||||
```` |
|
||||
|
|
||||
* `abp.libs.datatables.createAjax` is a helper function to adapt ABP's dynamic JavaScript API proxies to Datatable's format. |
|
||||
* `abp.libs.datatables.normalizeConfiguration` is another helper function. There's no requirement to use it, but it simplifies the datatables configuration by providing conventional values for missing options. |
|
||||
* `acme.bookStore.book.getList` is the function to get list of books (you have seen it before). |
|
||||
* See [Datatable's documentation](https://datatables.net/manual/) for more configuration options. |
|
||||
|
|
||||
The final UI is shown below: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
### Next Part |
|
||||
|
|
||||
See the [next part](Part-II.md) of this tutorial. |
|
||||
|
|||||
@ -1,432 +1,6 @@ |
|||||
## ASP.NET Core MVC Tutorial - Part II |
# Tutorials |
||||
|
|
||||
### About this Tutorial |
## Application Development |
||||
|
|
||||
This is the second part of the ASP.NET Core MVC tutorial series. See all parts: |
* [With ASP.NET Core MVC / Razor Pages UI](../Part-1?UI=MVC) |
||||
|
* [With Angular UI](../Part-1?UI=NG) |
||||
* [Part I: Create the project and a book list page](Part-I.md) |
|
||||
* **Part II: Create, Update and Delete books (this tutorial)** |
|
||||
* [Part III: Integration Tests](Part-III.md) |
|
||||
|
|
||||
You can access to the **source code** of the application from [the GitHub repository](https://github.com/volosoft/abp/tree/master/samples/BookStore). |
|
||||
|
|
||||
> 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. |
|
||||
|
|
||||
### Creating a New Book |
|
||||
|
|
||||
In this section, you will learn how to create a new modal dialog form to create a new book. The result dialog will be like that: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
#### Create the Modal Form |
|
||||
|
|
||||
Create a new razor page, named `CreateModal.cshtml` under the `Pages/Books` folder of the `Acme.BookStore.Web` project: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
##### 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 those can be used by your page model classes. |
|
||||
* `[BindProperty]` attribute on the `Book` property binds post request data to this property. |
|
||||
* This class simply injects the `IBookAppService` in its 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 `CreateBookViewModel` class. |
|
||||
* `abp-model` attribute indicates the model object, the `Book` property in this case. |
|
||||
* `data-ajaxForm` attribute makes the form submitting via AJAX, instead of a classic page post. |
|
||||
* `abp-form-content` tag helper is a placeholder to render the form controls (this is optional and needed only if you 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 change the `abp-card-header` tag as shown 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> |
|
||||
```` |
|
||||
|
|
||||
Just added a **New book** button to the **top right** of the table: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
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 open and 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 An Existing Book |
|
||||
|
|
||||
Create a new razor page, named `EditModal.cshtml` under the `Pages/Books` folder of the `Acme.BookStore.Web` project: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
#### 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. Used `SupportsGet` to be able to get Id value from query string parameter of the request. |
|
||||
* Mapped `BookDto` (received from the `BookAppService.GetAsync`) to `CreateUpdateBookDto` in the `GetAsync` method. |
|
||||
* The `OnPostAsync` simply uses `BookAppService.UpdateAsync` to update the entity. |
|
||||
|
|
||||
#### BookDto to CreateUpdateBookDto Mapping |
|
||||
|
|
||||
In order to perform `BookDto` to `CreateUpdateBookDto` object mapping, 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>(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* Just added `CreateMap<BookDto, CreateUpdateBookDto>();` as the mapping definition. |
|
||||
|
|
||||
#### 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 ("Actions") for each row of the table. The final UI looks like this: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Open the `Pages/Books/Index.cshtml` page and change the 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> |
|
||||
```` |
|
||||
|
|
||||
* Just added a new `th` tag for the "Actions". |
|
||||
|
|
||||
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. |
|
||||
|
|
||||
### Deleting an Existing 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`. |
|
||||
* Used `acme.bookStore.book.delete` javascript proxy function to perform an AJAX request to delete a book. |
|
||||
* `abp.notify.info` is used to show a toastr notification just after the deletion. |
|
||||
|
|
||||
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 line: |
|
||||
|
|
||||
````json |
|
||||
"BookDeletionConfirmationMessage": "Are you sure to delete the book {0}?", |
|
||||
"SuccessfullyDeleted": "Successfully deleted" |
|
||||
```` |
|
||||
|
|
||||
Run the application and try to delete a book. |
|
||||
|
|
||||
### Next Part |
|
||||
|
|
||||
See the [next part](Part-III.md) of this tutorial. |
|
||||
|
|||||
@ -1,166 +1,6 @@ |
|||||
## ASP.NET Core MVC Tutorial - Part III |
# Tutorials |
||||
|
|
||||
### About this Tutorial |
## Application Development |
||||
|
|
||||
This is the third part of the ASP.NET Core MVC tutorial series. See all parts: |
* [With ASP.NET Core MVC / Razor Pages UI](../Part-1?UI=MVC) |
||||
|
* [With Angular UI](../Part-1?UI=NG) |
||||
- [Part I: Create the project and a book list page](Part-I.md) |
|
||||
- [Part II: Create, Update and Delete books](Part-II.md) |
|
||||
- **Part III: Integration Tests (this tutorial)** |
|
||||
|
|
||||
You can access to the **source code** of the application from [the GitHub repository](https://github.com/volosoft/abp/tree/master/samples/BookStore). |
|
||||
|
|
||||
> 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. |
|
||||
|
|
||||
### Test Projects in the Solution |
|
||||
|
|
||||
There are multiple test projects in the solution: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Each project is used to test the related application project. Test projects use the following libraries for testing: |
|
||||
|
|
||||
* [xunit](https://xunit.github.io/) as the main test framework. |
|
||||
* [Shoudly](http://shouldly.readthedocs.io/en/latest/) as an assertion library. |
|
||||
* [NSubstitute](http://nsubstitute.github.io/) as a mocking library. |
|
||||
|
|
||||
### Adding Test Data |
|
||||
|
|
||||
Startup template contains the `BookStoreTestDataSeedContributor` class in the `Acme.BookStore.TestBase` project that creates some data to run tests on. |
|
||||
|
|
||||
Change the `BookStoreTestDataSeedContributor` class as show below: |
|
||||
|
|
||||
````C# |
|
||||
using System; |
|
||||
using System.Threading.Tasks; |
|
||||
using Volo.Abp.Data; |
|
||||
using Volo.Abp.DependencyInjection; |
|
||||
using Volo.Abp.Domain.Repositories; |
|
||||
using Volo.Abp.Guids; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookStoreTestDataSeedContributor |
|
||||
: IDataSeedContributor, ITransientDependency |
|
||||
{ |
|
||||
private readonly IRepository<Book, Guid> _bookRepository; |
|
||||
private readonly IGuidGenerator _guidGenerator; |
|
||||
|
|
||||
public BookStoreTestDataSeedContributor( |
|
||||
IRepository<Book, Guid> bookRepository, |
|
||||
IGuidGenerator guidGenerator) |
|
||||
{ |
|
||||
_bookRepository = bookRepository; |
|
||||
_guidGenerator = guidGenerator; |
|
||||
} |
|
||||
|
|
||||
public async Task SeedAsync(DataSeedContext context) |
|
||||
{ |
|
||||
await _bookRepository.InsertAsync( |
|
||||
new Book(_guidGenerator.Create(), "Test book 1", BookType.Fantastic, new DateTime(2015, 05, 24), 21) |
|
||||
); |
|
||||
|
|
||||
await _bookRepository.InsertAsync( |
|
||||
new Book(_guidGenerator.Create(), "Test book 2", BookType.Science, new DateTime(2014, 02, 11), 15) |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* Injected `IRepository<Book, Guid>` and used it in the `SeedAsync` to create two book entities as the test data. |
|
||||
* Used `IGuidGenerator` service to create GUIDs. While `Guid.NewGuid()` would perfectly work for testing, `IGuidGenerator` has additional features especially important while using real databases (see the [Guid generation document](../../Guid-Generation.md) for more). |
|
||||
|
|
||||
### Testing the BookAppService |
|
||||
|
|
||||
Create a test class named `BookAppService_Tests` in the `Acme.BookStore.Application.Tests` project: |
|
||||
|
|
||||
````C# |
|
||||
using System.Threading.Tasks; |
|
||||
using Shouldly; |
|
||||
using Volo.Abp.Application.Dtos; |
|
||||
using Xunit; |
|
||||
|
|
||||
namespace Acme.BookStore |
|
||||
{ |
|
||||
public class BookAppService_Tests : BookStoreApplicationTestBase |
|
||||
{ |
|
||||
private readonly IBookAppService _bookAppService; |
|
||||
|
|
||||
public BookAppService_Tests() |
|
||||
{ |
|
||||
_bookAppService = GetRequiredService<IBookAppService>(); |
|
||||
} |
|
||||
|
|
||||
[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 == "Test book 1"); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* `Should_Get_List_Of_Books` test simply uses `BookAppService.GetListAsync` method to get and check the list of users. |
|
||||
|
|
||||
Add a new test that creates a valid new book: |
|
||||
|
|
||||
````C# |
|
||||
[Fact] |
|
||||
public async Task Should_Create_A_Valid_Book() |
|
||||
{ |
|
||||
//Act |
|
||||
var result = await _bookAppService.CreateAsync( |
|
||||
new CreateUpdateBookDto |
|
||||
{ |
|
||||
Name = "New test book 42", |
|
||||
Price = 10, |
|
||||
PublishDate = DateTime.Now, |
|
||||
Type = BookType.ScienceFiction |
|
||||
} |
|
||||
); |
|
||||
|
|
||||
//Assert |
|
||||
result.Id.ShouldNotBe(Guid.Empty); |
|
||||
result.Name.ShouldBe("New test book 42"); |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
Add a new test that tries to create an invalid book and fails: |
|
||||
|
|
||||
````C# |
|
||||
[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(mem => mem == "Name")); |
|
||||
} |
|
||||
```` |
|
||||
|
|
||||
* Since the `Name` is empty, ABP throws an `AbpValidationException`. |
|
||||
|
|
||||
Open the **Test Explorer Window** (use Test -> Windows -> Test Explorer menu if it is not visible) and **Run All** tests: |
|
||||
|
|
||||
 |
|
||||
|
|
||||
Congratulations, green icons show that tests have been successfully passed! |
|
||||
|
|||||
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 810 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 13 KiB |