diff --git a/docs/en/Developing-Step-By-Step-React-Adding-AddPhone-DeletePhone-Methods.md b/docs/en/Developing-Step-By-Step-React-Adding-AddPhone-DeletePhone-Methods.md new file mode 100644 index 00000000..522b347f --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Adding-AddPhone-DeletePhone-Methods.md @@ -0,0 +1,114 @@ +# Adding AddPhone and DeletePhone Methods + +We are adding two more methods to IPersonAppService interface as shown +below: + +```csharp +Task DeletePhone(EntityDto input); +Task AddPhone(AddPhoneInput input); +``` + +We could create a new, separated IPhoneAppService. It's your choice. +But, we can consider Person as an aggregate and add phone related +methods here. AddPhoneInput DTO is shown below: + +```csharp +public class AddPhoneInput +{ + [Range(1, int.MaxValue)] + public int PersonId { get; set; } + + [Required] + public PhoneType Type { get; set; } + + [Required] + [MaxLength(PhoneConsts.MaxNumberLength)] + public string Number { get; set; } +} +``` + +We used PhoneConsts.MaxNumberLength for Number field. You should create +this consts in **.Core.Shared**. + +```csharp +public class PhoneConsts +{ + public const int MaxNumberLength = 16; +} +``` + +Now, we can implement these methods: + +```csharp +private readonly IRepository _personRepository; +private readonly IRepository _phoneRepository; + +public PersonAppService(IRepository personRepository, IRepository phoneRepository) +{ + _personRepository = personRepository; + _phoneRepository = phoneRepository; +} + +[AbpAuthorize(AppPermissions.Pages_Administration_PhoneBook_DeletePhone)] +public async Task DeletePhone(EntityDto input) +{ + await _phoneRepository.DeleteAsync(input.Id); +} + +[AbpAuthorize(AppPermissions.Pages_Administration_PhoneBook_AddPhone)] +public async Task AddPhone(AddPhoneInput input) +{ + var person = _personRepository.Get(input.PersonId); + await _personRepository.EnsureCollectionLoadedAsync(person, p => p.Phones); + + var phone = ObjectMapper.Map(input); + person.Phones.Add(phone); + + //Get auto increment Id of the new Phone by saving to database + await CurrentUnitOfWork.SaveChangesAsync(); + + return ObjectMapper.Map(phone); +} +``` + +Then we add configuration for AutoMapper into CustomDtoMapper.cs like below: + +```csharp +configuration.CreateMap(); +``` + +A permission should have a unique name. We define permission names as constant strings in **AppPermissions** class. It's a simple constant string: + +```csharp +public const string Pages_Administration_PhoneBook_DeletePhone = "Pages.Administration.DeletePhone"; +public const string Pages_Administration_PhoneBook_AddPhone = "Pages.Administration.AddPhone"; +``` + +Go to **AppAuthorizationProvider** class in the server side and add a new permission as shown below (you can add just below the dashboard permission): + +```csharp +phoneBook.CreateChildPermission(AppPermissions.Pages_Administration_PhoneBook_DeletePhone, L("DeletePhone")); +phoneBook.CreateChildPermission(AppPermissions.Pages_Administration_PhoneBook_AddPhone, L("AddPhone")); +``` + +**DeletePhone** method is simple. It only deletes phone with given id. + +**AddPhone** method **gets** the person from database and add new phone +to person.Phones collection. Then is **save changes**. Saving changes +causes inserting new added phone to database and get its **Id**. +Because, we are returning a DTO that contains newly created phone +informations including Id. So, it should be assigned before mapping in +the last line. (Notice that; normally it's not needed to call +CurrentUnitOfWork.SaveChangesAsync. It's automatically called at the end +of the method. We called it in the method since we need to save entity +and get its Id immediately. See [UOW +document](https://aspnetboilerplate.com/Pages/Documents/Unit-Of-Work#DocAutoSaveChanges) +for more.) + +There may be different approaches for AddPhone method. You can directly +work with a **phone repository** to insert new phone. They all have +different pros and cons. It's your choice. + +## Next + +- [Edit Mode for Phone Numbers](Developing-Step-By-Step-React-Edit-Mode-Phone-Numbers) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Adding-New-Menu-Item.md b/docs/en/Developing-Step-By-Step-React-Adding-New-Menu-Item.md new file mode 100644 index 00000000..178bbd7c --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Adding-New-Menu-Item.md @@ -0,0 +1,73 @@ +# Adding a New Menu Item + +Let's begin from UI and create a new page named "**Phone book**". + +## Defining a Menu Item + +Open **src\\lib\\navigation\\appNavigation.tsx** in the client side which defines menu items in the application. Create new menu item as shown below (You can add it right after the dashboard menu item). + +```typescript +export const buildRawMenu = (): AppMenuItem[] => [ + { + id: "PhoneBook", + title: L("PhoneBook"), + icon: "book", + route: "/app/admin/phonebook", + }, +]; +``` + +**PhoneBook** is the menu name, **book** is just an arbitrary icon class (from [this set](http://keenthemes.com/metronic/preview/?page=components/icons/flaticon&demo=default)) and **/phonebook** is the React route. + +If you run the application, you will see a new menu item on the left menu, but it won't work (it redirects to default route) If you click to the menu item, since we haven't defined the React route yet. + +## Localize Menu Item Display Name + +Localization strings are defined in **XML** files in **.Core** project in server side as shown below: + +Localization files + +Open PhoneBookDemo.xml (the **default**, **English** localization dictionary) and add the following line: + +```xml +Phone Book +Phone Book Details +``` + +If we don't define "PhoneBook"s value for other localization dictionaries, default value is shown in all languages. For example, we can define it also for Turkish in `PhoneBookDmo-tr.xml` file: + +```xml +Telefon Rehberi +Telefon Rehberi Detayları +``` + +Note: Any change in server side (including change localization texts) requires recycle of the server application. We suggest to use Ctrl+F5 if you don't need to debugging for a faster startup. In that case, it's enough to make a re-build to recycle the application. + +## React Route + +React has a powerful URL routing system. ASP.NET Zero has defined routes in a few places (for modularity, see [main menu & layout](Features-React-Main-Menu-Layout.md)). We want to add phone book page to the admin component. So, open **src\\routes\\AppRouter.tsx** in the client side and add a new route just below to the dashboard: + +```typescript +const AppRouter = () => { + return ( + }> + + + }> + }> + {/* Other Routes */} + } /> + + + + + + ); +}; +``` + +We get an error since we haven't defined PhonebookPage yet. Also, we ignored permission for now (will implement later). + +## Next + +- [Creating the PhoneBook Component](Developing-Step-By-Step-React-Creating-PhoneBook-Component) diff --git a/docs/en/Developing-Step-By-Step-React-Adding-Phone-Numbers.md b/docs/en/Developing-Step-By-Step-React-Adding-Phone-Numbers.md new file mode 100644 index 00000000..dd46a00f --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Adding-Phone-Numbers.md @@ -0,0 +1,8 @@ +# Adding Phone Numbers + +Until now, we have not even mentioned phone numbers. It's time to +extend our domain to support **multiple phone numbers** for a person. + +## Next + +- [Creating Phone Entity](Developing-Step-By-Step-React-Creating-Phone-Entity) diff --git a/docs/en/Developing-Step-By-Step-React-Authorization-PhoneBook.md b/docs/en/Developing-Step-By-Step-React-Authorization-PhoneBook.md new file mode 100644 index 00000000..7c6d855c --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Authorization-PhoneBook.md @@ -0,0 +1,155 @@ +# Authorization For Phone Book + +At this point, anyone can enter phone book page since no authorization +defined. We will define two permission: + +- A permission to **enter phone book page**. +- A permission to **create new person** (which is a child permission + of first one, as naturally). + +## Permission for Entering Phone Book Page + +### Define the permission + +A permission should have a unique name. We define permission names as constant strings in **AppPermissions** class. It's a simple constant string: + +```csharp +public const string Pages_Administration_PhoneBook = "Pages.Administration.PhoneBook"; +``` + +Go to **AppAuthorizationProvider** class in the server side and add a new permission as shown below (you can add just below the dashboard permission): + +```csharp +pages.CreateChildPermission(AppPermissions.Pages_Administration_PhoneBook, L("PhoneBook")); +``` + +Unique name of this permission is "**Pages.Administration.PhoneBook**". While you can set any string (as long as it's unique), it's suggested to use that convention. A permission can have a localizable display name: "**PhoneBook**" here. (See "Adding a New Page" section for more about localization, since it's very similar). Lastly, we set this as a **administration** level permission. + +### Add AbpAuthorize attribute + +**AbpAuthorize** attribute can be used as **class level** or **method level** to protect an application service or service method from unauthorized users. Since all server side code is located in `PersonAppService` class, we can declare a class level attribute as shown below: + +```csharp +[AbpAuthorize(AppPermissions.Pages_Administration_PhoneBook)] +public class PersonAppService : PhoneBookAppServiceBase, IPersonAppService +{ + //... +} +``` + +Admin role has every static permission by default but those permissions can be reversible on user interface for this role. Go to Roles page, edit role named "admin", go to Permissions tab and revoke "Phone Book" permission and save. + +Now, let's try to enter Phone Book page by clicking the menu item without required permission: + +Permission error + +We get an error message. This exception is thrown when any method of `PersonAppService` is called without required permission. + +### Hide Unauthorized Menu Item + +While user can not enter to the page, the menu item still there! We should also **hide** the Phone book **menu item**. It's easy, open **src/lib/navigation/appNavigation.tsx** and add change PhoneBook menu definition as shown below: + +```typescript +{ + id: "PhoneBook", + title: L("PhoneBook"), + permissionName: "Pages.Administration.PhoneBook", + icon: "book", + route: "/app/admin/phonebook", +}, +``` + +### Grant permission + +So, how we can enter the page now? Simple, go to **Role Management** page and edit **admin** role: + +Role permissions + +We see that a **new permission** named "**Phone book**" added to **permissions** tab. So, we can check it and save the role. After saving, we need to **refresh** the whole page to refresh permissions for the current user. We could also grant this permission to a specific user. Now, we can enter the Phone book page again. + +## Permission for Create New Person + +While a permission for a page is useful and probably always needed, we may want to define additional permissions to perform some **specific actions** on a page, like creating a new person. + +### Define the Permission + +First permission was defined before. In the second line, we are creating a child permission of first one. Remember to create a constant in `AppPermissions` class: + +```csharp +public const string Pages_Administration_PhoneBook_CreatePerson = "Pages.Administration.PhoneBook.CreatePerson"; +``` + +Defining a permission is similar (in the `AppAuthorizationProvider` class): + +```csharp +var phoneBook = pages.CreateChildPermission(AppPermissions.Pages_Administration_PhoneBook, L("PhoneBook")); +phoneBook.CreateChildPermission(AppPermissions.Pages_Administration_PhoneBook_CreatePerson, L("CreateNewPerson")); +``` + +### Add AbpAuthorize Attribute + +This time, we're declaring **AbpAuthorize** attribute just for **CreatePerson** method: + +```csharp +[AbpAuthorize(AppPermissions.Pages_Administration_PhoneBook_CreatePerson)] +public async Task CreatePerson(CreatePersonInput input) +{ + //... +} +``` + +### Hide Unauthorized Button + +If we run the application and try to create a person, we get an authorization error after clicking the save button. But, it's good to **completely hide Create New Person button** if we don't have the permission. It's very simple: + +Open the **index.tsx** view and add the permission **Pages.Administration.PhoneBook.CreatePerson** condition as shown below: + +```typescript +import { usePermissions } from "@/hooks/usePermissions"; +import { + PersonListDto, + PersonServiceProxy, + useServiceProxy, +} from "@/api/service-proxy-factory"; +import CreatePersonModal from "./CreatePersonModal"; + +const PhoneBookPage: React.FC = () => { + const { isGranted } = usePermissions(); + // Other codes + + return ( + <> + {/* Other Codes */} +
+ {isGranted("Pages.Administration.PhoneBook.CreatePerson") && ( + + )} +
+ {/* Other Codes */} + + ); +}; + +export default PhoneBookPage; +``` + + +In this way, the "Create New Person" button is not rendered in server and user can not see this button. + +### Grant permission + +To see the button again, we can go to role or user manager and grant related permission as shown below: + +Role specific permissions + +As shown above, **Create new person** permission is a child permission of the **Phone book**. Remember to refresh page to get permissions updated. + +## Next + +- [Deleting a Person](Developing-Step-By-Step-React-Deleting-Person) diff --git a/docs/en/Developing-Step-By-Step-React-Changing-GetPeople-Method.md b/docs/en/Developing-Step-By-Step-React-Changing-GetPeople-Method.md new file mode 100644 index 00000000..3f324802 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Changing-GetPeople-Method.md @@ -0,0 +1,64 @@ +# Changing GetPeople Method + +We're changing **PersonAppService.GetPeople** method to **include** +phone numbers of people into return value. + +First, we're changing **PersonListDto** to contain a list of phones: + +```csharp +public class PersonListDto : FullAuditedEntityDto +{ + public string Name { get; set; } + + public string Surname { get; set; } + + public string EmailAddress { get; set; } + + public Collection Phones { get; set; } +} + +public class PhoneInPersonListDto : CreationAuditedEntityDto +{ + public PhoneType Type { get; set; } + + public string Number { get; set; } +} +``` + +Then we add configuration for AutoMapper into CustomDtoMapper.cs like below: + +```csharp +configuration.CreateMap(); +``` + +So, added also a DTO to transfer phone numbers and mapped from Phone +entity. Now, we can change GetPeople method to get Phones from database: + +```csharp +public ListResultDto GetPeople(GetPeopleInput input) +{ + var persons = _personRepository + .GetAll() + .Include(p => p.Phones) + .WhereIf( + !input.Filter.IsNullOrEmpty(), + p => p.Name.Contains(input.Filter) || + p.Surname.Contains(input.Filter) || + p.EmailAddress.Contains(input.Filter) + ) + .OrderBy(p => p.Name) + .ThenBy(p => p.Surname) + .ToList(); + + return new ListResultDto(ObjectMapper.Map>(persons)); +} +``` + +We only added **Include** extension method to the query. Rest of the +codes remains same. Furthermore, it would work without adding this, but +much slower (since it will lazy load phone numbers for every person +separately). + +## Next + +- [Adding AddPhone and DeletePhone Methods](Developing-Step-By-Step-React-Adding-AddPhone-DeletePhone-Methods) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Conclusion.md b/docs/en/Developing-Step-By-Step-React-Conclusion.md new file mode 100644 index 00000000..c7fc9e67 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Conclusion.md @@ -0,0 +1,26 @@ +# Conclusion + +In this document, we built a complete example that covers most parts of +the ASP.NET Zero system. We hope that it will help you to build your own +application. + +We intentionally used different approaches for similar tasks to show you +different styles of development. ASP.NET Zero provides an architecture +but does not restrict you. You can make your own style development. + + + +## Code Generation + +Using new **ASP.NET Zero Power Tools**, you can speed up your development. + +See [documentation](https://aspnetzero.com/Documents/Development-Guide-Rad-Tool) to learn how to use it. + + + +### Source Code + +You should [purchase](https://aspnetzero.com/Pricing) ASP.NET Zero in order to get **source +code**. After purchasing, you can get the sample project from private +Github repository: + diff --git a/docs/en/Developing-Step-By-Step-React-Creating-New-Person.md b/docs/en/Developing-Step-By-Step-React-Creating-New-Person.md new file mode 100644 index 00000000..ec5d1f44 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Creating-New-Person.md @@ -0,0 +1,76 @@ +# Creating a New Person + +Next step is to create a modal to add a new item to phone book. + +## Add a CreatePerson Method to PersonAppService + +We first define **CreatePerson** method in **IPersonAppService** +interface: + +```csharp +Task CreatePerson(CreatePersonInput input); +``` + +Then we create **CreatePersonInput** DTO that defines parameters of the +method: + +```csharp +public class CreatePersonInput +{ + [Required] + [MaxLength(PersonConsts.MaxNameLength)] + public string Name { get; set; } + + [Required] + [MaxLength(PersonConsts.MaxSurnameLength)] + public string Surname { get; set; } + + [EmailAddress] + [MaxLength(PersonConsts.MaxEmailAddressLength)] + public string EmailAddress { get; set; } +} +``` + +Then we add configuration for AutoMapper into CustomDtoMapper.cs like below: + +```csharp +configuration.CreateMap(); +``` + +**CreatePersonInput** is mapped to **Person** entity (comment out +related line in CustomDtoMapper.cs and we will use mapping below). +All properties are decorated with **data annotation attributes** +to provide automatic +**[validation](https://aspnetboilerplate.com/Pages/Documents/Validating-Data-Transfer-Objects)**. +Notice that we use same consts defined in **PersonConsts.cs** in +**.Core.Shared** project for **MaxLength** properties. After adding this +class, you can remove consts from **Person** entity and use this new +consts class. + +```csharp +public class PersonConsts +{ + public const int MaxNameLength = 32; + public const int MaxSurnameLength = 32; + public const int MaxEmailAddressLength = 255; +} +``` + +Here, the implementation of CreatePerson method: + +```csharp +public async Task CreatePerson(CreatePersonInput input) +{ + var person = ObjectMapper.Map(input); + await _personRepository.InsertAsync(person); +} +``` + +A Person entity is created by mapping given input, then inserted to +database. We used **async/await** pattern here. All methods in ASP.NET +Zero startup project is **async**. It's advised to use async/await +wherever possible. + +## Next + +- [Testing CreatePerson Method](Developing-Step-By-Step-React-Creating-Testing-Create-Person-Method) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Creating-Person-Application-Service.md b/docs/en/Developing-Step-By-Step-React-Creating-Person-Application-Service.md new file mode 100644 index 00000000..8760ed35 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Creating-Person-Application-Service.md @@ -0,0 +1,102 @@ +# Creating Person Application Service + +An Application Service is used from client (presentation layer) to perform operations (use cases) of the application. + +Application service interface and DTOs are located in **.Application.Shared** project. We are creating an application service to get people from the server. So, we're first creating an **interface** to define the person application service (while this interface is optional, we suggest you to create it): + +```csharp +using Abp.Application.Services; +using Abp.Application.Services.Dto; + +namespace Acme.PhoneBookDemo.PhoneBook; + +public interface IPersonAppService : IApplicationService +{ + ListResultDto GetPeople(GetPeopleInput input); +} +``` + +An application service method gets/returns **DTO**s. **ListResultDto** is a pre-build helper DTO to return a list of another DTO. **GetPeopleInput** is a DTO to pass request parameters to **GetPeople** method. So, GetPeopleIntput and PersonListDto are defined as shown below: + +```csharp +public class GetPeopleInput +{ + public string Filter { get; set; } +} + +public class PersonListDto : FullAuditedEntityDto +{ + public string Name { get; set; } + + public string Surname { get; set; } + + public string EmailAddress { get; set; } +} +``` + +**CustomDtoMapper.cs** is used to create mapping from **Person** to **PersonListDto**. **FullAuditedEntityDto** is inherited to implement audit properties automatically. See [application service](https://aspnetboilerplate.com/Pages/Documents/Application-Services) and [DTO](https://aspnetboilerplate.com/Pages/Documents/Data-Transfer-Objects) documentations for more information. We are adding the following mappings. + +```csharp +... +// PhoneBook (we will comment out other lines when the new DTOs are added) +configuration.CreateMap(); +//configuration.CreateMap(); +//configuration.CreateMap(); +//configuration.CreateMap(); +//configuration.CreateMap(); +``` + +After defining interface, we can implement it as shown below: (in **.Application** project) + +```csharp +using Abp.Application.Services.Dto; +using Abp.Collections.Extensions; +using Abp.Domain.Repositories; +using System.Collections.Generic; +using System.Linq; + +namespace Acme.PhoneBookDemo.PhoneBook; + +public class PersonAppService : PhoneBookDemoAppServiceBase, IPersonAppService +{ + private readonly IRepository _personRepository; + + public PersonAppService(IRepository personRepository) + { + _personRepository = personRepository; + } + + public ListResultDto GetPeople(GetPeopleInput input) + { + var people = _personRepository + .GetAll() + .WhereIf( + !input.Filter.IsNullOrEmpty(), + p => p.Name.Contains(input.Filter) || + p.Surname.Contains(input.Filter) || + p.EmailAddress.Contains(input.Filter) + ) + .OrderBy(p => p.Name) + .ThenBy(p => p.Surname) + .ToList(); + + return new ListResultDto(ObjectMapper.Map>(people)); + } +} +``` + +We're injecting **person repository** (it's automatically created by ABP) and using it to filter and get people from database. + +**WhereIf** is an extension method here (defined in Abp.Linq.Extensions namespace). It performs Where condition, only if filter is not null or empty. **IsNullOrEmpty** is also an extension method (defined in Abp.Extensions namespace). ABP has many similar shortcut extension methods. **ObjectMapper.Map** method automatically converts list of Person entities to list of PersonListDto objects with using configurations in **CustomDtoMapper.cs** in **.Application** project. + +### Connection & Transaction Management + +We don't manually open database connection or start/commit transactions manually. It's automatically done with ABP framework's Unit Of Work system. See [UOW documentation](https://aspnetboilerplate.com/Pages/Documents/Unit-Of-Work) for more. + +### Exception Handling + +We don't handle exceptions manually (using a try-catch block). Because ABP framework automatically handles all exceptions on the web layer and returns appropriate error messages to the client. It then handles errors on the client and shows needed error information to the user. See [exception handling document](https://aspnetboilerplate.com/Pages/Documents/Handling-Exceptions) for more. + +## Next + +- [Using GetPeople Method From React Component](Developing-Step-By-Step-React-Using-GetPeople-Method-from-React) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Creating-Person-Entity.md b/docs/en/Developing-Step-By-Step-React-Creating-Person-Entity.md new file mode 100644 index 00000000..501d33e0 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Creating-Person-Entity.md @@ -0,0 +1,53 @@ +# Creating Person Entity + +We define entities in **.Core** (domain) project (in server side). We +can define a **Person** entity (mapped to **PbPersons** table in +database) to represent a person in phone book as shown below (I created +in a new folder/namespace named PhoneBook): + +```csharp +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Abp.Domain.Entities.Auditing; + +namespace Acme.PhoneBookDemo.PhoneBook; + +[Table("PbPersons")] +public class Person : FullAuditedEntity +{ + public const int MaxNameLength = 32; + public const int MaxSurnameLength = 32; + public const int MaxEmailAddressLength = 255; + + [Required] + [MaxLength(MaxNameLength)] + public virtual string Name { get; set; } + + [Required] + [MaxLength(MaxSurnameLength)] + public virtual string Surname { get; set; } + + [MaxLength(MaxEmailAddressLength)] + public virtual string EmailAddress { get; set; } +} +``` + +Person's **primary key** type is **int** (as default). It inherits +**FullAuditedEntity** that contains **creation**, **modification** and +**deletion** audit properties. It's also a **soft-delete** entity. When we delete a person, it's not deleted from database but marked as deleted (see [entity](https://aspnetboilerplate.com/Pages/Documents/Entities) and [data filters](https://aspnetboilerplate.com/Pages/Documents/Data-Filters) +documentations for more information). We created consts for **MaxLength** properties. This is a good practice since we will use same values later. + +We add a DbSet property for Person entity to **PhoneBookDemoDbContext** class defined in **.EntityFrameworkCore** project. + +```csharp +public class PhoneBookDemoDbContext : AbpZeroDbContext +{ + public virtual DbSet Persons { get; set; } + + //...other code +} +``` + +## Next + +- [Database Migrations for Person](Developing-Step-By-Step-React-Database-Migrations-Person-Entity) diff --git a/docs/en/Developing-Step-By-Step-React-Creating-Phone-Entity.md b/docs/en/Developing-Step-By-Step-React-Creating-Phone-Entity.md new file mode 100644 index 00000000..fa0f2fbb --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Creating-Phone-Entity.md @@ -0,0 +1,60 @@ +# Creating Phone Entity + +Let's start by creating a new Entity, **Phone** in **.Core** project: + +```csharp +[Table("PbPhones")] +public class Phone : CreationAuditedEntity +{ + public const int MaxNumberLength = 16; + + [ForeignKey("PersonId")] + public virtual Person Person { get; set; } + public virtual int PersonId { get; set; } + + [Required] + public virtual PhoneType Type { get; set; } + + [Required] + [MaxLength(MaxNumberLength)] + public virtual string Number { get; set; } +} +``` + +Phone entities are stored in **PbPhones** table. Its primary key is +**long** and it inherits creation auditing properties. It has a reference +to **Person** entity which owns the phone number. + +We added a **Phones** collection to the People: + +```csharp +[Table("PbPersons")] +public class Person : FullAuditedEntity +{ + //...other properties + + public virtual ICollection Phones { get; set; } +} +``` + +We have a **PhoneType** enum as shown below: (in **.Core.Shared** +project) + +```csharp +public enum PhoneType : byte +{ + Mobile, + Home, + Business +} +``` + +Lastly, we're also adding a DbSet property for Phone to our DbContext: + +```csharp +public virtual DbSet Phones { get; set; } +``` + +## Next + +- [Database Migration of Phone Entity](Developing-Step-By-Step-React-Migrations-Phone-Entity) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Creating-PhoneBook-Component.md b/docs/en/Developing-Step-By-Step-React-Creating-PhoneBook-Component.md new file mode 100644 index 00000000..14f5f766 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Creating-PhoneBook-Component.md @@ -0,0 +1,44 @@ +# Creating the PhoneBook Component + +Create a **phonebook** folder inside **src/pages/admin** folder and add a new typescript file (**index.tsx**) in the phonebook folder as shown below: + +```typescript +import L from "@/lib/L"; +import React from "react"; +import PageHeader from "../components/common/PageHeader"; +import { useTheme } from "@/hooks/useTheme"; + +const PhoneBookPage: React.FC = () => { + const { containerClass } = useTheme(); + + return ( + <> + +
+
+
+

PHONE BOOK CONTENT COMES HERE!

+
+
+
+ + ); +}; + +export default PhoneBookPage; +``` + +**L** (upper case 'L') function comes from **lib/L.tsx** and used to easily localize texts. + +Now, we can refresh the page to see the new added page: + +Phonebook empty + +Note: React automatically re-compiles and refreshes the page when any changes made to any file in the application. + +## Next + +- [Creating Person Entity](Developing-Step-By-Step-React-Creating-Person-Entity) diff --git a/docs/en/Developing-Step-By-Step-React-Creating-Running-Project.md b/docs/en/Developing-Step-By-Step-React-Creating-Running-Project.md new file mode 100644 index 00000000..bfea8dec --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Creating-Running-Project.md @@ -0,0 +1,27 @@ +## Creating & Running The Project + +We're creating and downloading the solution named "**Acme.PhoneBookDemo**" as described in [Getting Started](Getting-Started-React) document. Please follow the getting started document, run the application, login as default tenant +admin (select `Default` as tenancy name, use `admin` as username and `123qwe` as the password) and see the dashboard below: + +Dashboard + +Logout from the application for now. We will make our application **single-tenant** (we will convert it to multi-tenant later). So, we open **PhoneBookDemoConsts** class in the **Acme.PhoneBookDemo.Core.Shared** project and disable multi-tenancy as shown below: + +```c# +public class PhoneBookDemoConsts +{ + public const string LocalizationSourceName = "PhoneBookDemo"; + + public const string ConnectionStringName = "Default"; + + public const bool MultiTenancyEnabled = false; + + public const int PaymentCacheDurationInMinutes = 30; +} +``` + +**Note:** If you log in before changing **MultiTenancyEnabled** to false, you might get a login error. If you face this problem, you need to remove cookies. + +## Next + +- [Adding a New Menu Item](Developing-Step-By-Step-React-Adding-New-Menu-Item) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Creating-Testing-Create-Person-Method.md b/docs/en/Developing-Step-By-Step-React-Creating-Testing-Create-Person-Method.md new file mode 100644 index 00000000..6f168080 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Creating-Testing-Create-Person-Method.md @@ -0,0 +1,71 @@ +# Testing CreatePerson Method + +You can skip this section if you don't interest in **automated +testing**. + +We can create a unit test method to test CreatePerson method as shown +below: + +```csharp +[Fact] +public async Task Should_Create_Person_With_Valid_Arguments() +{ + //Act + await _personAppService.CreatePerson( + new CreatePersonInput + { + Name = "John", + Surname = "Nash", + EmailAddress = "john.nash@abeautifulmind.com" + }); + + //Assert + UsingDbContext( + context => + { + var john = context.Persons.FirstOrDefault(p => p.EmailAddress == "john.nash@abeautifulmind.com"); + john.ShouldNotBe(null); + john.Name.ShouldBe("John"); + }); +} +``` + +Test method also written using **async/await** pattern since calling +method is async. We called CreatePerson method, then checked if given +person is in the database. **UsingDbContext** method is a helper method +of **AppTestBase** class (which we inherited this unit test class from). +It's used to easily get a reference to DbContext and use it directly to +perform database operations. + +This method successfully works since all required fields are supplied. +Let's try to create a test for **invalid arguments**: + +```csharp +[Fact] +public async Task Should_Not_Create_Person_With_Invalid_Arguments() +{ + //Act and Assert + await Assert.ThrowsAsync( + async () => + { + await _personAppService.CreatePerson( + new CreatePersonInput + { + Name = "John" + }); + }); +} +``` + +We did not set **Surname** property of CreatePersonInput despite it being +**required**. So, it throws **AbpValidationException** automatically. +Also, we can not send null to CreatePerson method since validation +system also checks it. This test calls CreatePerson with invalid +arguments and asserts that it throws AbpValidationException. See +[validation +document](https://aspnetboilerplate.com/Pages/Documents/Validating-Data-Transfer-Objects) +for more information. + +## Next + +- [Creating Modal for New Person](Developing-Step-By-Step-React-Creating-Testing-Creating-Modal-New-Person) diff --git a/docs/en/Developing-Step-By-Step-React-Creating-Testing-Creating-Modal-New-Person.md b/docs/en/Developing-Step-By-Step-React-Creating-Testing-Creating-Modal-New-Person.md new file mode 100644 index 00000000..8cad3678 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Creating-Testing-Creating-Modal-New-Person.md @@ -0,0 +1,365 @@ +# Creating Modal for New Person + +We will create an Ant Design Modal to create a new person. ASP.NET Zero React version uses [Ant Design](https://ant.design/) library to create modals. Final modal will be like below: + +![Create Person Dialog](images/phonebook-create-person-dialog-2.png) + +First of all, we should use **nswag/refresh.bat** to re-generate service-proxies. This will generate the code that is needed to call PersonAppService.**CreatePerson** method from client side. Notice that you should rebuild & run the server side application before re-generating the proxy scripts. + +We are starting from creating a new component, named **CreatePersonModal.tsx** into client side phonebook folder: + +```typescript +import React, { useState, useEffect } from "react"; +import { Modal } from "antd"; +import { useForm } from "react-hook-form"; +import { + PersonServiceProxy, + CreatePersonInput, +} from "@/api/service-proxy-factory"; +import { useServiceProxy } from "@/api/service-proxy-factory"; +import L from "@/lib/L"; + +interface Props { + isVisible: boolean; + onClose: () => void; + onSave: () => void; +} + +type FormValues = { + name: string; + surname: string; + emailAddress?: string; +}; + +const CreatePersonModal: React.FC = ({ isVisible, onClose, onSave }) => { + const { + register, + handleSubmit, + reset, + setFocus, + formState: { errors, isValid }, + } = useForm({ mode: "onChange" }); + const [saving, setSaving] = useState(false); + const personService = useServiceProxy(PersonServiceProxy, []); + + useEffect(() => { + if (isVisible) { + reset({ name: "", surname: "", emailAddress: "" }); + } + }, [isVisible, reset]); + + const onSubmit = async (values: FormValues) => { + setSaving(true); + try { + const input = new CreatePersonInput({ + name: values.name, + surname: values.surname, + emailAddress: values.emailAddress, + }); + await personService.createPerson(input); + abp.notify.success(L("SavedSuccessfully")); + onSave(); + onClose(); + } finally { + setSaving(false); + } + }; + + return ( + { + if (opened) { + setTimeout(() => setFocus("name"), 0); + } + }} + footer={[ + , + , + ]} + > +
+
+ + + {errors.name && ( +
{L("ThisFieldIsRequired")}
+ )} +
+ +
+ + + {errors.surname && ( +
{L("ThisFieldIsRequired")}
+ )} +
+ +
+ + + {errors.emailAddress && ( +
+ {errors.emailAddress.message || L("InvalidEmailAddress")} +
+ )} +
+
+
+ ); +}; + +export default CreatePersonModal; +``` + +Let me explain some parts of this component: + +- It accepts three **props**: `isVisible` to control modal visibility, `onClose` to handle modal close, and `onSave` to notify parent component when person is saved. +- Uses **react-hook-form** for form management with real-time validation (`mode: "onChange"`). +- Uses **useServiceProxy** hook to get an instance of **PersonServiceProxy** to call server side methods. +- Uses **useState** hook for managing the `saving` state during API calls. +- Uses **useEffect** to reset form values when modal becomes visible. +- **Auto-focuses** the name input field when modal opens using `setFocus` from react-hook-form. +- Implements **form validation** with required fields and maxLength constraints. +- Uses **ABP notification** system (`abp.notify.success`) to show success messages. + +Open PhoneBookDemo.xml (the **default**, **English** localization dictionary) and add the following line: + +```xml +Create New Person +``` + +Now we need to integrate this modal into the phonebook page. Update **index.tsx** as shown below: + +```typescript +import L from "@/lib/L"; +import React, { useEffect, useState } from "react"; +import { Table } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import PageHeader from "../components/common/PageHeader"; +import { useTheme } from "@/hooks/useTheme"; +import { + PersonListDto, + PersonServiceProxy, + useServiceProxy, +} from "@/api/service-proxy-factory"; +import CreatePersonModal from "./CreatePersonModal"; + +const PhoneBookPage: React.FC = () => { + const { containerClass } = useTheme(); + const personService = useServiceProxy(PersonServiceProxy); + + const [people, setPeople] = useState([]); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(""); + const [isCreateModalVisible, setIsCreateModalVisible] = + useState(false); + + useEffect(() => { + getPeople(); + }, []); + + const getPeople = async (): Promise => { + setLoading(true); + try { + const result = await personService.getPeople(filter); + setPeople(result.items || []); + } finally { + setLoading(false); + } + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + getPeople(); + }; + + const handleCreatePerson = () => { + setIsCreateModalVisible(true); + }; + + const handleModalClose = () => { + setIsCreateModalVisible(false); + }; + + const handleModalSave = () => { + getPeople(); + }; + + const columns: ColumnsType = [ + { + title: L("Name"), + dataIndex: "name", + key: "name", + sorter: true, + width: 150, + }, + { + title: L("Surname"), + dataIndex: "surname", + key: "surname", + sorter: true, + width: 150, + }, + { + title: L("EmailAddress"), + dataIndex: "emailAddress", + key: "emailAddress", + sorter: true, + width: 250, + }, + ]; + + return ( + <> + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ setFilter(e.target.value)} + /> + +
+
+
+
+
+ + record.id!} + size="small" + bordered + columns={columns} + loading={loading} + dataSource={people} + pagination={false} + scroll={{ x: true }} + /> + + + + + + + + ); +}; + +export default PhoneBookPage; +``` + +Key changes made in the phonebook page: + +- **Imported** the `CreatePersonModal` component +- Added **state** for modal visibility using `useState` hook: `isCreateModalVisible` +- Created handler functions: + - `handleCreatePerson` - opens the modal + - `handleModalClose` - closes the modal + - `handleModalSave` - refreshes the people list after successful save +- Added a **"Create New Person"** button in the card header that triggers `handleCreatePerson` +- Placed the **CreatePersonModal** component at the bottom of the JSX, passing the required props + +The modal is now fully integrated! When you click the "Create New Person" button, the modal will open. After successfully creating a person, the modal closes and the people list automatically refreshes to show the new entry. + +## Next + +- [Authorization For Phone Book](Developing-Step-By-Step-React-Authorization-PhoneBook) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Creating-Unit-Tests-for-Person-Application-Service.md b/docs/en/Developing-Step-By-Step-React-Creating-Unit-Tests-for-Person-Application-Service.md new file mode 100644 index 00000000..5bb5b92e --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Creating-Unit-Tests-for-Person-Application-Service.md @@ -0,0 +1,106 @@ +# Creating Unit Tests for Person Application Service + +You can skip this section if you are not interested in **automated +testing**. + +By writing unit test, we can test **PersonAppService.GetPeople** method +without creating a user interface. We write unit test in .**Tests** +project in the solution. + +## Multi Tenancy In Tests + +Since we disabled multitenancy, we should disable it for unit tests too. +Open **PhoneBookDemoConsts** class in the Acme.PhoneBook.Core project +and set "**MultiTenancyEnabled**" to false. After a rebuild and run unit +tests, you will see that some tests are skipped (those are related to +multitenancy). + +Let's create first test to verify getting people without any filter: + +```csharp +using Acme.PhoneBookDemo.People; +using Acme.PhoneBookDemo.People.Dtos; +using Shouldly; +using Xunit; + +namespace Acme.PhoneBookDemo.Tests.People; + +public class PersonAppService_Tests : AppTestBase + { + private readonly IPersonAppService _personAppService; + + public PersonAppService_Tests() + { + _personAppService = Resolve(); + } + + [Fact] + public void Should_Get_All_People_Without_Any_Filter() + { + //Act + var persons = _personAppService.GetPeople(new GetPeopleInput()); + + //Assert + persons.Items.Count.ShouldBe(2); + } + } +``` + +We derived test class from **AppTestBase**. AppTestBase class +initializes all system, creates an in-memory fake database, seeds +initial data (that we created before) to database and logins to +application as admin. So, this is actually an **integration test** since +it tests all server-side code from entity framework mapping to +application services, validation and authorization. + +In constructor, we get (resolve) an **IPersonAppService** from +**dependency injection** container. It creates the **PersonAppService** +class with all dependencies. Then we can use it in test methods. + +Since we're using [xUnit](https://xunit.net/), we add **Fact** +attribute to each test method. In the test method, we called +**GetPeople** method and checked if there are **two people** in the +returned list as we know that there were 2 people in **initial** +database. + +Let's run the **all unit tests** in Test Explorer and see if it works: + +xUnit unit test success + +As you see, it worked **successfully**. Now, we know that +PersonAppService works properly without any filter. Let's add a new unit +test to get filtered people: + +```csharp +[Fact] +public void Should_Get_People_With_Filter() +{ + //Act + var persons = _personAppService.GetPeople( + new GetPeopleInput + { + Filter = "adams" + }); + + //Assert + persons.Items.Count.ShouldBe(1); + persons.Items[0].Name.ShouldBe("Douglas"); + persons.Items[0].Surname.ShouldBe("Adams"); +} +``` + +Again, since we know initial database, we can check returned results +easily. Here, initial test data is important. When we change initial +data, our test may fail even if our services are correct. So, it's +better to write unit tests independent of initial data as much as +possible. We could check incoming data to see if every people contains +"adams" in his/her name, surname or email. Thus, if we add new people to +initial data, our tests remain working. + +There are many techniques on unit testing, I kept it simple here. But +ASP.NET Zero template makes very easy to write unit and integration +tests by base classes and pre-build test codes.  + +## Next + +- [Creating Person Application Service](Developing-Step-By-Step-React-Creating-Person-Application-Service) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Database-Migrations-Person-Entity.md b/docs/en/Developing-Step-By-Step-React-Database-Migrations-Person-Entity.md new file mode 100644 index 00000000..12cca3f9 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Database-Migrations-Person-Entity.md @@ -0,0 +1,129 @@ +# Database Migrations for Person + +We use **EntityFramework Code-First migrations** to migrate database schema. Since we added **Person entity**, our DbContext model is changed. So, we should create a **new migration** to create the new table in the database. + +Open **Package Manager Console**, run the **Add-Migration "Added\_Persons\_Table"** command as shown below: + +Entity Framework Code First Migration + +This command will add a **migration class** named "**Added\_Persons\_Table**" as shown below: + +```csharp +public partial class Added_Persons_Table : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PbPersons", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Surname = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + EmailAddress = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: true), + CreationTime = table.Column(type: "datetime2", nullable: false), + CreatorUserId = table.Column(type: "bigint", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "bigint", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: false), + DeleterUserId = table.Column(type: "bigint", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PbPersons", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PbPersons"); + } +} +``` + +We don't have to know so much about format and rules of this file. But, it's suggested to have a basic understanding of migrations. In the same Package Manager Console, we write **Update-Database** command in order to apply the new migration to database. After updating, we can see that **PbPersons table** is added to database. + +Phonebook tables + +But this new table is empty. In ASP.NET Zero, there are some classes to +fill initial data for users and settings: + +Seed folders + +So, we can add a separated class to fill some people to database as +shown below: + +```csharp +namespace Acme.PhoneBookDemo.Migrations.Seed.Host; + +public class InitialPeopleCreator +{ + private readonly PhoneBookDemoDbContext _context; + + public InitialPeopleCreator(PhoneBookDemoDbContext context) + { + _context = context; + } + + public void Create() + { + var douglas = _context.Persons.FirstOrDefault(p => p.EmailAddress == "douglas.adams@fortytwo.com"); + if (douglas == null) + { + _context.Persons.Add( + new Person + { + Name = "Douglas", + Surname = "Adams", + EmailAddress = "douglas.adams@fortytwo.com" + }); + } + + var asimov = _context.Persons.FirstOrDefault(p => p.EmailAddress == "isaac.asimov@foundation.org"); + if (asimov == null) + { + _context.Persons.Add( + new Person + { + Name = "Isaac", + Surname = "Asimov", + EmailAddress = "isaac.asimov@foundation.org" + }); + } + } +} +``` + +These type of default data is good since we can also use these data in +**unit tests**. Surely, we should be careful about seed data since this +code will always be executed in each **PostInitialize** of your +PhoneBookEntityFrameworkCoreModule. This class (InitialPeopleCreator) is +created and called in **InitialHostDbBuilder** class. This is not so +important, just for a good code organization (see source codes). + +```csharp +public class InitialHostDbBuilder +{ + //existing codes... + + public void Create() + { + //existing code... + new InitialPeopleCreator(_context).Create(); + + _context.SaveChanges(); + } +} +``` + +We run our project again, it runs seed and adds two people to PbPersons +table: + +Persons initial data + +## Next + +- [Creating Unit Tests for Person Application Service](Developing-Step-By-Step-React-Creating-Unit-Tests-for-Person-Application-Service) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Deleting-Person.md b/docs/en/Developing-Step-By-Step-React-Deleting-Person.md new file mode 100644 index 00000000..582e7e16 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Deleting-Person.md @@ -0,0 +1,229 @@ +# Deleting a Person + +Let's add a delete button in people list as shown below: + +![Delete person](images/phonebook-people-delete-button-react.png) + +We're starting from UI in this case. + +## View + +We're changing **index.tsx** to add a delete button in the Actions column. First, we need to add the Ant Design `App` hook for modal confirmations and update the columns definition: + +```typescript +import L from "@/lib/L"; +import React, { useEffect, useState } from "react"; +import { Table, App } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import PageHeader from "../components/common/PageHeader"; +import { useTheme } from "@/hooks/useTheme"; +import { usePermissions } from "@/hooks/usePermissions"; +import { + PersonListDto, + PersonServiceProxy, + useServiceProxy, +} from "@/api/service-proxy-factory"; +import CreatePersonModal from "./CreatePersonModal"; + +const PhoneBookPage: React.FC = () => { + const { modal } = App.useApp(); // Add this line for modal.confirm + const personService = useServiceProxy(PersonServiceProxy); + + // Other Code + + const deletePerson = (person: PersonListDto) => { + modal.confirm({ + title: L("AreYouSure"), + content: L("PersonDeleteWarningMessage", person.name), + okText: L("Yes"), + cancelText: L("Cancel"), + onOk: async () => { + await personService.deletePerson(person.id); + abp.notify.success(L("SuccessfullyDeleted")); + setPeople((prevPeople) => prevPeople.filter((p) => p.id !== person.id)); + }, + }); + }; + + const columns: ColumnsType = [ + { + title: L("Name"), + dataIndex: "name", + key: "name", + sorter: true, + width: 150, + }, + { + title: L("Surname"), + dataIndex: "surname", + key: "surname", + sorter: true, + width: 150, + }, + { + title: L("EmailAddress"), + dataIndex: "emailAddress", + key: "emailAddress", + sorter: true, + width: 250, + }, + { + title: L("Actions"), + key: "actions", + width: 150, + render: (_text: string, record: PersonListDto) => ( + <> + {isGranted("Pages.Administration.PhoneBook.DeletePerson") && ( + + )} + + ), + }, + ]; + + // Rest of The Component +}; + +export default PhoneBookPage; +``` + +We simply added an Actions column with a delete button that calls **deletePerson** method when clicked. The button is conditionally rendered based on the delete permission. + +## Style + +React components in this project use inline styles or CSS classes from Bootstrap and Metronic theme. No separate CSS file is needed for this feature. + +If you need custom styles, you can create a **styles.css** file in the phonebook folder: + +```css +/* Custom styles for phonebook */ +.phonebook-actions { + display: flex; + gap: 8px; +} +``` + +And import it in your component: + +```typescript +import "./styles.css"; +``` + +## Application Service + +Let's leave the client side and add a DeletePerson method to the server side. We are adding it to the service interface, **IPersonAppService**: + +```csharp +Task DeletePerson(EntityDto input); +``` + +**EntityDto** is a shortcut of ABP if we only get an id value. Implementation (in **PersonAppService**) is very simple: + +```csharp +[AbpAuthorize(AppPermissions.Pages_Administration_PhoneBook_DeletePerson)] +public async Task DeletePerson(EntityDto input) +{ + await _personRepository.DeleteAsync(input.Id); +} +``` + +### Define the Delete Permission + +In this example, we are creating a child permission under an existing phoneBook permission. This allows for more granular control, such as managing delete operations separately. + +A permission should have a unique name. We define permission names as constant strings in **AppPermissions** class. It's a simple constant string: + +```csharp +public const string Pages_Administration_PhoneBook_DeletePerson = "Pages.Administration.PhoneBook.DeletePerson"; +``` + +To define delete permission, use the `AppAuthorizationProvider` class as shown below: + +```csharp +phoneBook.CreateChildPermission(AppPermissions.Pages_Administration_PhoneBook_DeletePerson, L("DeletePerson")); +``` + +## Service Proxy Generation + +Since we changed server side services, we should re-generate the client side service proxies via NSwag. Make server side running and use **refresh.bat** (located in `nswag/` folder) as we did before. + +```bash +cd nswag +refresh.bat +``` + +This will update the `src/api/generated/service-proxies.ts` file with the new `deletePerson` method. + +## React Component Implementation + +The key parts of the React implementation: + +### Using Ant Design Modal for Confirmation + +```typescript +const { modal } = App.useApp(); +``` + +We use Ant Design's `App.useApp()` hook to access the modal context, which provides a cleaner way to show confirmation dialogs. + +### Delete Function + +```typescript +const deletePerson = (person: PersonListDto) => { + modal.confirm({ + title: L("AreYouSure"), + content: L("PersonDeleteWarningMessage", person.name), + okText: L("Yes"), + cancelText: L("Cancel"), + onOk: async () => { + await personService.deletePerson(person.id); + abp.notify.success(L("SuccessfullyDeleted")); + setPeople((prevPeople) => prevPeople.filter((p) => p.id !== person.id)); + }, + }); +}; +``` + +This function: +- Shows a confirmation modal with the person's name +- If confirmed, calls the backend `deletePerson` method +- On success, shows a notification and removes the person from the state +- Handles errors gracefully + +### State Update + +We use React's functional state update: + +```typescript +setPeople((prevPeople) => prevPeople.filter((p) => p.id !== person.id)); +``` + +This ensures the state update is based on the latest state value. + +## Localization + +Open **PhoneBookDemo.xml** (the **default**, **English** localization dictionary) in your server-side project and add the following line: + +```xml +Are you sure you want to delete {0}? This action cannot be undone. +``` + +The `{0}` placeholder will be replaced with the person's name when calling `L("PersonDeleteWarningMessage", person.name)`. + +## Confirmation Dialog + +When you click the delete button, it shows a confirmation message: + +![Confirmation message](images/confirmation-delete-person-react.png) + +If you click OK, it calls the **deletePerson** method of **PersonServiceProxy** and shows a success notification. The person is also removed from the table immediately without requiring a page refresh. + +## Next + +- [Filtering People](Developing-Step-By-Step-React-Filtering-People) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Edit-Mode-People.md b/docs/en/Developing-Step-By-Step-React-Edit-Mode-People.md new file mode 100644 index 00000000..b231ab8d --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Edit-Mode-People.md @@ -0,0 +1,367 @@ +# Edit Mode For People + +Now we want to edit name, surname and e-mail of people: + +Edit Person + +First of all, we create the necessary DTOs to transfer people's id, name, +surname and e-mail. We can optionally configure auto-mapper, but this is not necessary because all properties match automatically. Then we create the functions in PersonAppService for +editing people: + +```csharp +Task GetPersonForEdit(EntityDto input); +Task EditPerson(EditPersonInput input); +``` + +```csharp +[AbpAuthorize(AppPermissions.Pages_Administration_PhoneBook_EditPerson)] +public async Task GetPersonForEdit( EntityDto input) +{ + var person = await _personRepository.GetAsync(input.Id); + return ObjectMapper.Map(person); +} + +[AbpAuthorize(AppPermissions.Pages_Administration_PhoneBook_EditPerson)] +public async Task EditPerson(EditPersonInput input) +{ + var person = await _personRepository.FirstOrDefaultAsync(input.Id); + ObjectMapper.Map(input, person); +} + +public class GetPersonForEditOutput : EntityDto +{ + public string Name { get; set; } + public string Surname { get; set; } + public string EmailAddress { get; set; } +} + +public class EditPersonInput : EntityDto +{ + public string Name { get; set; } + public string Surname { get; set; } + public string EmailAddress { get; set; } +} +``` + +The previous documents showed how to add `Pages_Administration_PhoneBook_EditPerson`. You can add it in the same way. + +Then we add configuration for AutoMapper into `CustomDtoMapper.cs` like below: + +```csharp +configuration.CreateMap(); +configuration.CreateMap().ReverseMap(); +``` + +## Edit Modal Implementation + +### `EditPersonModal.tsx` + +Create the edit person modal component: + +```typescript +import React, { useEffect, useState } from "react"; +import { Modal, Spin } from "antd"; +import { useForm } from "react-hook-form"; +import L from "@/lib/L"; +import { + EditPersonInput, + GetPersonForEditOutput, + PersonServiceProxy, + useServiceProxy, +} from "@/api/service-proxy-factory"; + +interface EditPersonModalProps { + personId: number | null; + isVisible: boolean; + onClose: () => void; + onSave: () => void; +} + +interface EditPersonFormData { + name: string; + surname: string; + emailAddress: string; +} + +const EditPersonModal: React.FC = ({ + personId, + isVisible, + onClose, + onSave, +}) => { + const personService = useServiceProxy(PersonServiceProxy); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + reset, + setValue, + setFocus, + } = useForm({ + mode: "onChange", + }); + + useEffect(() => { + if (isVisible && personId) { + loadPerson(); + } + }, [isVisible, personId]); + + const loadPerson = async () => { + if (!personId) return; + + setLoading(true); + try { + const result = await personService.getPersonForEdit(personId); + setValue("name", result.name || ""); + setValue("surname", result.surname || ""); + setValue("emailAddress", result.emailAddress || ""); + setTimeout(() => setFocus("name"), 100); + } finally { + setLoading(false); + } + }; + + const onSubmit = async (data: EditPersonFormData) => { + if (!personId) return; + + setSaving(true); + try { + const input = new EditPersonInput({ + id: personId, + name: data.name, + surname: data.surname, + emailAddress: data.emailAddress, + }); + + await personService.editPerson(input); + abp.notify.success(L("SavedSuccessfully")); + handleClose(); + onSave(); + } finally { + setSaving(false); + } + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + return ( + + {L("Cancel")} + , + , + ]} + destroyOnClose + > + {loading ? ( +
+ +
+ ) : ( +
+
+ + + {errors.name && ( +
+ {errors.name.message} +
+ )} +
+ +
+ + + {errors.surname && ( +
+ {errors.surname.message} +
+ )} +
+ +
+ + + {errors.emailAddress && ( +
+ {errors.emailAddress.message} +
+ )} +
+ + )} +
+ ); +}; + +export default EditPersonModal; +``` + +### `index.tsx` Integration + +Update the phonebook `index.tsx` file to include edit functionality: + +#### Import EditPersonModal + +```typescript +import EditPersonModal from "./EditPersonModal"; +``` + +#### Add State for Edit Modal + +```typescript +const [isEditModalVisible, setIsEditModalVisible] = useState(false); +const [editingPersonId, setEditingPersonId] = useState(null); +``` + +#### Add Edit Person Handlers + +```typescript +const handleEditPerson = (personId: number) => { + setEditingPersonId(personId); + setIsEditModalVisible(true); +}; + +const handleEditModalClose = () => { + setIsEditModalVisible(false); + setEditingPersonId(null); +}; + +const handleEditModalSave = () => { + getPeople(); +}; +``` + +#### Add Edit Button to Actions Column + +```typescript +{ + title: L("Actions"), + key: "actions", + width: 150, + render: (_text: string, record: PersonListDto) => ( + <> + {isGranted("Pages.Administration.PhoneBook.EditPerson") && ( + + )} + {isGranted("Pages.Administration.PhoneBook.DeletePerson") && ( + + )} + {/* ... phone button ... */} + + ), +} +``` + +#### Add EditPersonModal Component + +```typescript + +``` + +## Localization + +Add these localization strings to your PhoneBookDemo.xml file: + +```xml +Edit Person +Field should not exceed max length: {0} +``` + +## Next + +- [Multi Tenancy](Developing-Step-By-Step-React-Multi-Tenancy) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Edit-Mode-Phone-Numbers.md b/docs/en/Developing-Step-By-Step-React-Edit-Mode-Phone-Numbers.md new file mode 100644 index 00000000..2c66d610 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Edit-Mode-Phone-Numbers.md @@ -0,0 +1,234 @@ +# Edit Mode For Phone Numbers + +Final UI is shown below: + +Phone book edit mode + +When we click the **phone icon** for a person, its row is expanded and all phone numbers are shown. Then we can delete any phone by clicking the delete icon. We can add a new phone from the inputs at the bottom of the expanded area. + +## PhoneBook Component + +The main changes are in the `index.tsx` file. We added expandable rows to the Ant Design Table component to show phone numbers for each person. + +### State Management + +```typescript +const [expandedRowKeys, setExpandedRowKeys] = useState([]); +const [newPhoneNumber, setNewPhoneNumber] = useState(""); +const [newPhoneType, setNewPhoneType] = useState(PhoneType.Mobile); +``` + +### Actions Column + +The Actions column includes the phone button that toggles row expansion: + +```typescript +{ + title: L("Actions"), + key: "actions", + width: 150, + render: (_text: string, record: PersonListDto) => ( + <> + {isGranted("Pages.Administration.PhoneBook.DeletePerson") && ( + + )} + + + ), +} +``` + +### Expanded Row Render + +The expanded row displays the phone list and add phone form: + +```typescript +const expandedRowRender = (record: PersonListDto) => { + return ( +
+
+ {L("PhoneNumbers")}: +
    + {record.phones && record.phones.length > 0 ? ( + record.phones.map((phone: PhoneInPersonListDto) => ( +
  • + + {getPhoneTypeLabel(phone.type!)} + + {phone.number} + +
  • + )) + ) : ( +
  • {L("NoPhoneNumbersFound")}
  • + )} +
+
+ +
+
+ setNewPhoneNumber(e.target.value)} + placeholder={L("PhoneNumber")!} + className="form-control" + /> +
+
+ +
+
+ +
+
+
+ ); +}; +``` + +### Table Configuration + +The Table component is configured with the expandable prop: + +```typescript +
record.id!} + size="small" + bordered + columns={columns} + loading={loading} + dataSource={people} + pagination={false} + scroll={{ x: true }} + expandable={{ + expandedRowKeys: expandedRowKeys, + expandedRowRender: expandedRowRender, + showExpandColumn: false, + onExpand: (expanded, record) => { + const keys = expanded + ? [...expandedRowKeys, record.id!] + : expandedRowKeys.filter((k) => k !== record.id); + setExpandedRowKeys(keys); + }, + }} +/> +``` + +### Phone Management Functions + +Functions to add and delete phone numbers: + +```typescript +const addPhone = async (personId: number) => { + if (!newPhoneNumber.trim()) { + abp.notify.warn(L("PhoneNumberRequired")); + return; + } + + const input = new AddPhoneInput({ + personId: personId, + number: newPhoneNumber, + type: newPhoneType, + }); + await personService.addPhone(input); + abp.notify.success(L("PhoneSuccessfullyAdded")); + setNewPhoneNumber(""); + setNewPhoneType(PhoneType.Mobile); + setExpandedRowKeys([]); + getPeople(); +}; + +const deletePhone = (phoneId: number, personId: number) => { + modal.confirm({ + title: L("AreYouSure"), + content: L("PhoneDeleteWarningMessage"), + okText: L("Yes"), + cancelText: L("Cancel"), + onOk: async () => { + await personService.deletePhone(phoneId); + abp.notify.success(L("SuccessfullyDeleted")); + setPeople((prevPeople) => + prevPeople.map((p) => { + if (p.id === personId && p.phones) { + p.phones = p.phones.filter((phone) => phone.id !== phoneId); + } + return p; + }) + ); + }, + }); +}; + +const getPhoneTypeLabel = (type: PhoneType): string => { + switch (type) { + case PhoneType.Mobile: + return L("PhoneType_Mobile"); + case PhoneType.Home: + return L("PhoneType_Home"); + case PhoneType.Business: + return L("PhoneType_Business"); + default: + return ""; + } +}; +``` + + +## Localization + +Add these localization strings to your PhoneBookDemo.xml file: + +```xml +Add Phone +Phone Numbers +This person has no phone numbers. +Are you sure you want to delete this phone number? +Phone number successfully added +Phone number is required +Mobile +Home +Business +``` + + +## Next + +- [Edit Mode For People](Developing-Step-By-Step-React-Edit-Mode-People) diff --git a/docs/en/Developing-Step-By-Step-React-Filtering-People.md b/docs/en/Developing-Step-By-Step-React-Filtering-People.md new file mode 100644 index 00000000..76bf8dcc --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Filtering-People.md @@ -0,0 +1,63 @@ +# Filtering People + +Now, we will implement **search** functionality of **GetPeople** method. UI is shown below: + +![Searching people](images/search-people-react.png) + +First, add filter state to manage the search input: + +```typescript +const [filter, setFilter] = useState(""); +``` + +Then, update getPeople method to use the filter parameter: + +```typescript +const getPeople = async (): Promise => { + setLoading(true); + try { + const result = await personService.getPeople(filter); + setPeople(result.items || []); + } finally { + setLoading(false); + } +}; +``` + +Finally, add search form with input and submit handler: + +```typescript +const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + getPeople(); +}; + +
+
+
+
+
+ setFilter(e.target.value)} + /> + +
+
+
+
+ +``` + +Since we have already defined and used the filter property and the server-side implementation is ready, this code immediately works! + +## Next + +- [Adding Phone Numbers](Developing-Step-By-Step-React-Adding-Phone-Numbers) diff --git a/docs/en/Developing-Step-By-Step-React-Introduction.md b/docs/en/Developing-Step-By-Step-React-Introduction.md new file mode 100644 index 00000000..d1fb796d --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Introduction.md @@ -0,0 +1,14 @@ +# Development with ASP.NET Zero & React + +The document is a step-by-step guide to develop a sample application with ASP.NET Zero (ASP.NET Core & React version). + +## Introduction + +In this guide, we will walk through the creation of a **Phonebook Application** using ASP.NET Zero (ASP.NET Core & React version) step by step. By the end, you'll have a fully functional, multi-tenant, localized, authorized, configurable, and testable application. + +Throughout the process, we'll focus on simplifying each development stage, making it easy and efficient to build, so you can develop powerful applications with minimal effort. + + +## Next + +- [Creating & Running the Project](Developing-Step-By-Step-React-Creating-Running-Project) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Migrations-Phone-Entity.md b/docs/en/Developing-Step-By-Step-React-Migrations-Phone-Entity.md new file mode 100644 index 00000000..5eb688df --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Migrations-Phone-Entity.md @@ -0,0 +1,112 @@ +# Database Migration of Phone Entity + +Our entity model has changed, so we need to add a new migration. Run +this command in the .EntityFramework project's directory: + +Entity Framework Migration + +This will create a new code based migration file to create **PbPhones** +table: + +```csharp +public partial class Added_Phone : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PbPhones", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PersonId = table.Column(type: "int", nullable: false), + Type = table.Column(type: "tinyint", nullable: false), + Number = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + CreationTime = table.Column(type: "datetime2", nullable: false), + CreatorUserId = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PbPhones", x => x.Id); + table.ForeignKey( + name: "FK_PbPhones_PbPersons_PersonId", + column: x => x.PersonId, + principalTable: "PbPersons", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PbPhones_PersonId", + table: "PbPhones", + column: "PersonId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PbPhones"); + } +} +``` + +Before updating database, we can go to database +**InitialPeopleCreator**, rename it to **InitialPeopleAndPhoneCreator** +and add example **phone numbers** for example people (We renamed +InitialPeopleCreator.cs to InitialPeopleAndPhoneCreator.cs): + +```csharp +public class InitialPeopleAndPhoneCreator +{ + //... + + public void Create() + { + var douglas = _context.Persons.FirstOrDefault(p => p.EmailAddress == "douglas.adams@fortytwo.com"); + if (douglas == null) + { + _context.Persons.Add( + new Person + { + Name = "Douglas", + Surname = "Adams", + EmailAddress = "douglas.adams@fortytwo.com", + Phones = new List + { + new Phone {Type = PhoneType.Home, Number = "1112242"}, + new Phone {Type = PhoneType.Mobile, Number = "2223342"} + } + }); + } + + var asimov = _context.Persons.FirstOrDefault(p => p.EmailAddress == "isaac.asimov@foundation.org"); + if (asimov == null) + { + _context.Persons.Add( + new Person + { + Name = "Isaac", + Surname = "Asimov", + EmailAddress = "isaac.asimov@foundation.org", + Phones = new List + { + new Phone {Type = PhoneType.Home, Number = "8889977"} + } + }); + } + } +} +``` + +We added two phone numbers to Douglas, one phone number to Isaac. But if +we run our application now, phones are not inserted since this seed code +checks if person exists, and does not insert if it already exists. +Since we haven't deployed yet, we can delete database +(or remove entries from people table) and re-create it. + +Now, we are running our application to re-create database and seed it. +You can check database to see **PbPhones** table and rows. + +## Next + +- [Changing GetPeople Method](Developing-Step-By-Step-React-Changing-GetPeople-Method) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Multi-Tenancy.md b/docs/en/Developing-Step-By-Step-React-Multi-Tenancy.md new file mode 100644 index 00000000..2eea9aca --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Multi-Tenancy.md @@ -0,0 +1,68 @@ +# Multi Tenancy + +We have built a fully functional application until here. Now, we will +see how to convert it to a multi-tenant application easily. Logout from +the application before any change. + +## Enable Multi Tenancy + +We disabled multi-tenancy at the beginning of this document. Now, +re-enabling it in **PhoneBookDemoConsts** class: + +```csharp +public const bool MultiTenancyEnabled = true; +``` + +## Make Entities Multi Tenant + +In a multi-tenant application, a tenant's entities should be isolated by +other tenants. For this example project, every tenant should have own +phone book with isolated people and phone numbers. + +When we implement IMustHaveTenant interface, ABP automatically [filters +data](https://aspnetboilerplate.com/Pages/Documents/Data-Filters) based +on current Tenant, while retrieving entities from database. So, we +should declare that Person entity must have a tenant using +**IMustHaveTenant** interface: + +```csharp +public class Person : FullAuditedEntity, IMustHaveTenant +{ + public virtual int TenantId { get; set; } + + //...other properties +} +``` + +We may want to add IMustHaveTenant interface to also Phone entity. This +is needed if we directly use phone repository to get phones. In this +sample project, it's not needed. + +Since entities have changed, we should create a new database migration: + +``` +Add-Migration "Implemented_IMustHaveTenant_For_Person" +``` + +This command creates a new code-first database migration. The migration +class adds an annotation this is needed for automatic filtering. We +don't have to know what it is since it's done automatically. It also +adds a **TenantId** column to PbPersons table as shown below: + +```csharp +migrationBuilder.AddColumn(name: "TenantId",table: "PbPersons",nullable: false,defaultValue: 1); +``` + +I added **defaultValue as 1** to AddColumn options. Thus, current people +are automatically assigned to **default tenant** (default tenant's id is +always 1). + +Now, we can update the database again: + +``` +Update-Database +``` + +## Next + +- [Running the Application](Developing-Step-By-Step-React-Running-Application) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Running-Application.md b/docs/en/Developing-Step-By-Step-React-Running-Application.md new file mode 100644 index 00000000..b01bfc09 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Running-Application.md @@ -0,0 +1,34 @@ +# Running the Application + +**It's finished**! We can test the application. Run the project, +**login** as the **host admin** (click Change link and clear tenancy +name) shown below: + +Login as host + +After login, we see the **tenant list** which only contains a +**default** tenant. We can create a new tenant: + +Creating tenant + +I created a new tenant named **trio**. Now, tenant list has two tenants: + +Tenant management page + + I can **logout** and login as **trio** tenant **admin** (Change current +tenant to trio): + +Login as tenant admin + +After login, we see that phone book is empty: + +Empty phonebook of new tenant + +It's empty because trio tenant has a completely isolated people list. +You can add people here, logout and login as different tenants (you can +login as default tenant for example). You will see that each tenant has +an isolated phone book and can not see other's people. + +## Next + +- [Conclusion](Developing-Step-By-Step-React-Conclusion) \ No newline at end of file diff --git a/docs/en/Developing-Step-By-Step-React-Using-GetPeople-Method-from-React.md b/docs/en/Developing-Step-By-Step-React-Using-GetPeople-Method-from-React.md new file mode 100644 index 00000000..416310d1 --- /dev/null +++ b/docs/en/Developing-Step-By-Step-React-Using-GetPeople-Method-from-React.md @@ -0,0 +1,152 @@ +# Using GetPeople Method From React Component + +Now, we can switch to the client side and use GetPeople method to show a +list of people on the UI. + +## Service Proxy Generation + +First, run (prefer Ctrl+F5 to be faster) the server side application +(.Web.Host project). Then run **nswag/refresh.bat** file on the client +side to re-generate service proxy classes (they are used to call server +side service methods). + + +## PhoneBookPage Typescript File + +Change **index.tsx** as like below: + +```typescript +import L from "@/lib/L"; +import React, { useEffect, useState } from "react"; +import { Table } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import PageHeader from "../components/common/PageHeader"; +import { useTheme } from "@/hooks/useTheme"; +import { + PersonListDto, + PersonServiceProxy, + useServiceProxy, +} from "@/api/service-proxy-factory"; + +const PhoneBookPage: React.FC = () => { + const { containerClass } = useTheme(); + const personService = useServiceProxy(PersonServiceProxy); + + const [people, setPeople] = useState([]); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(""); + + useEffect(() => { + getPeople(); + }, []); + + const getPeople = async (): Promise => { + setLoading(true); + try { + const result = await personService.getPeople(filter); + setPeople(result.items || []); + } finally { + setLoading(false); + } + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + getPeople(); + }; + + const columns: ColumnsType = [ + { + title: L("Name"), + dataIndex: "name", + key: "name", + sorter: true, + width: 150, + }, + { + title: L("Surname"), + dataIndex: "surname", + key: "surname", + sorter: true, + width: 150, + }, + { + title: L("EmailAddress"), + dataIndex: "emailAddress", + key: "emailAddress", + sorter: true, + width: 250, + }, + ]; + + return ( + <> + +
+
+
+
+
+
+
+
+
+ setFilter(e.target.value)} + /> + +
+
+
+
+ + +
record.id!} + size="small" + bordered + columns={columns} + loading={loading} + dataSource={people} + pagination={false} + scroll={{ x: true }} + /> + + + + + + ); +}; + +export default PhoneBookPage; +``` + +We use **useServiceProxy** hook to get an instance of **PersonServiceProxy**, then call its **getPeople** method using async/await. We do this inside the **useEffect** hook which runs when the component mounts. The returned items are stored in the **people** state variable using the **setPeople** function. + + +Phonebook peoples + +We successfully retrieved list of people from database to the page. + +## About Showing Tabular Data + +We normally use a javascript based rich table/grid library to show tabular data, instead of manually rendering data like that. For example, we used [Ant Design Table library](https://ant.design/components/table/) to show users on the Users page of ASP.NET Zero. Always use such components since they make things much easier and provide a much better user experience. + +## Next + +- [Creating a New Person](Developing-Step-By-Step-React-Creating-New-Person) diff --git a/docs/en/images/confirmation-delete-person-react.png b/docs/en/images/confirmation-delete-person-react.png new file mode 100644 index 00000000..42d68d27 Binary files /dev/null and b/docs/en/images/confirmation-delete-person-react.png differ diff --git a/docs/en/images/create-tenant-react.png b/docs/en/images/create-tenant-react.png new file mode 100644 index 00000000..370f45cb Binary files /dev/null and b/docs/en/images/create-tenant-react.png differ diff --git a/docs/en/images/edit-person-core-react.png b/docs/en/images/edit-person-core-react.png new file mode 100644 index 00000000..1a276421 Binary files /dev/null and b/docs/en/images/edit-person-core-react.png differ diff --git a/docs/en/images/localization-files-6.png b/docs/en/images/localization-files-6.png new file mode 100644 index 00000000..7153b353 Binary files /dev/null and b/docs/en/images/localization-files-6.png differ diff --git a/docs/en/images/login-as-host-react.png b/docs/en/images/login-as-host-react.png new file mode 100644 index 00000000..eed35b7b Binary files /dev/null and b/docs/en/images/login-as-host-react.png differ diff --git a/docs/en/images/phone-book-add-phone-react.png b/docs/en/images/phone-book-add-phone-react.png new file mode 100644 index 00000000..37df4242 Binary files /dev/null and b/docs/en/images/phone-book-add-phone-react.png differ diff --git a/docs/en/images/phonebook-empty-react.png b/docs/en/images/phonebook-empty-react.png new file mode 100644 index 00000000..1b93c80d Binary files /dev/null and b/docs/en/images/phonebook-empty-react.png differ diff --git a/docs/en/images/phonebook-people-delete-button-react.png b/docs/en/images/phonebook-people-delete-button-react.png new file mode 100644 index 00000000..672bcc47 Binary files /dev/null and b/docs/en/images/phonebook-people-delete-button-react.png differ diff --git a/docs/en/images/phonebook-people-view-react.png b/docs/en/images/phonebook-people-view-react.png new file mode 100644 index 00000000..ac4a9efb Binary files /dev/null and b/docs/en/images/phonebook-people-view-react.png differ diff --git a/docs/en/images/phonebook-persons-table-initial-data-2.png b/docs/en/images/phonebook-persons-table-initial-data-2.png new file mode 100644 index 00000000..50d3a350 Binary files /dev/null and b/docs/en/images/phonebook-persons-table-initial-data-2.png differ diff --git a/docs/en/images/role-permissions-with-phonebook-react.png b/docs/en/images/role-permissions-with-phonebook-react.png new file mode 100644 index 00000000..e9028dd3 Binary files /dev/null and b/docs/en/images/role-permissions-with-phonebook-react.png differ diff --git a/docs/en/images/search-people-react.png b/docs/en/images/search-people-react.png new file mode 100644 index 00000000..0bca4c90 Binary files /dev/null and b/docs/en/images/search-people-react.png differ diff --git a/docs/en/images/user-permissions-phonebook-react.png b/docs/en/images/user-permissions-phonebook-react.png new file mode 100644 index 00000000..5fe03599 Binary files /dev/null and b/docs/en/images/user-permissions-phonebook-react.png differ