mirror of https://github.com/abpframework/abp.git
3 changed files with 691 additions and 0 deletions
@ -0,0 +1,691 @@ |
|||
# How to Design Multi-Lingual Entity |
|||
|
|||
## Introduction |
|||
|
|||
If you want to open up to the global market these days, end-to-end localization is a must. ABP provides an already established infrastructure for static texts. However, this may not be sufficient for many applications. You may need to fully customize your app for a particular language and region. |
|||
|
|||
|
|||
Let's take a look at a few quotes from Christian Arno's article "[How Foreign-Language Internet Strategies Boost Sales](https://www.mediapost.com/publications/article/155250/how-foreign-language-internet-strategies-boost-sal.html)" to better understand the impact of this: |
|||
|
|||
- 82% of European consumers are less likely to buy online if the site is not in their native tongue ([Eurobarometer survey](http://europa.eu/rapid/pressReleasesAction.do?reference=IP/11/556)). |
|||
- 72.4% of global consumers are more likely to buy a product if the information is available in their own language ([Common Sense Advisory](http://www.commonsenseadvisory.com/)). |
|||
- The English language currently only accounts for 31% of all online use, and more than half of all searches are in languages other than English.ss |
|||
- Today, 42% of all Internet users are in Asia, while almost one-quarter are in Europe and just over 10% are in Latin America. |
|||
|
|||
- Foreign languages have experienced exponential growth in online usage in the past decade -- with Chinese now officially the [second-most-prominent-language](http://english.peopledaily.com.cn/90001/90776/90882/7438489.html) on the Web. [Arabic](http://www.internetworldstats.com/stats7.htm) has increased by a whopping 2500%, while English has only risen by 204% |
|||
|
|||
If you are looking for ways to expand your market share by fully customizing your application for a particular language and region, in this article I will explain how you can do it with ABP framework. |
|||
|
|||
### Source Code |
|||
|
|||
You can find the source code of the application at [abpframework/abp-samples](https://github.com/abpframework/abp-samples/tree/master/AcmeBookStoreMultiLingual). |
|||
|
|||
### Demo of the Final Application |
|||
|
|||
At the end of this article, we will have created an application same as in the gif below. |
|||
|
|||
 |
|||
|
|||
## Development |
|||
|
|||
In order to be free of unnecessary information in the article, we will try to make the Book entity in the application Multi-Lingual, which is the result of the "[Web Application Development Tutorial](https://docs.abp.io/en/abp/latest/Tutorials/Part-1?UI=MVC&DB=EF)" in the documents of the ABP framework. If you do not want to finish this tutorial, you can find the resulting application [here](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore). |
|||
|
|||
### Determining the data model |
|||
|
|||
We need a robust, maintainable, and efficient database model to store content in multiple languages. |
|||
|
|||
> I read many articles to determine the data model correctly, and as a result, I decided to use one of the many approaches that suits us. |
|||
> However, as in everything, there is a trade-off here. If you are wondering about the advantages and disadvantages of the model we will implement compared to other models, I recommend you to read [this article](https://vertabelo.com/blog/data-modeling-for-multiple-languages-how-to-design-a-localization-ready-system/). |
|||
|
|||
 |
|||
|
|||
As a result of the tutorial, we already have the `Book` and `Author` entities, as an extra, we will just add the `BookTranslation`. |
|||
|
|||
> In the article, we will make the Name property of the Book entity multi-lingual, but the article is independent of the Book entity, you can make the entity you want multi-lingual with similar codes according to your requirements. |
|||
|
|||
#### Acme.BookStore.Domain.Shared |
|||
|
|||
Create a folder named `MultiLingualObjects` and create the following interfaces in its contents. |
|||
|
|||
We will use the `IObjectTranslation` interface to mark the translation of a multi-lingual entity. |
|||
|
|||
```csharp |
|||
public interface IObjectTranslation |
|||
{ |
|||
string Language { get; set; } |
|||
} |
|||
``` |
|||
|
|||
We will use the `IMultiLingualObject<TTranslation>` interface to mark multi-lingual entities. |
|||
|
|||
```csharp |
|||
public interface IMultiLingualObject<TTranslation> |
|||
where TTranslation : class, IObjectTranslation |
|||
{ |
|||
ICollection<TTranslation> Translations { get; set; } |
|||
} |
|||
``` |
|||
|
|||
#### Acme.BookStore.Domain |
|||
|
|||
In the `Books` folder, create the `BookTranslation` class as follows. |
|||
|
|||
```csharp |
|||
public class BookTranslation : Entity, IObjectTranslation |
|||
{ |
|||
public Guid BookId { get; set; } |
|||
|
|||
public string Name { get; set; } |
|||
|
|||
public string Language { get; set; } |
|||
|
|||
public override object[] GetKeys() |
|||
{ |
|||
return new object[] {BookId, Language}; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
`BookTranslation` contains the `Language` property, which contains a language code for translation and a reference to the multi-lingual entity. We also have the `BookId` foreign key to help us know which book is translated. |
|||
|
|||
Implement `IMultiLingualObject` in the `Book` class as follows. |
|||
|
|||
```csharp |
|||
public class Book : AuditedAggregateRoot<Guid>, IMultiLingualObject<BookTranslation> |
|||
{ |
|||
public Guid AuthorId { get; set; } |
|||
|
|||
public string Name { get; set; } |
|||
|
|||
public BookType Type { get; set; } |
|||
|
|||
public DateTime PublishDate { get; set; } |
|||
|
|||
public float Price { get; set; } |
|||
|
|||
public ICollection<BookTranslation> Translations { get; set; } |
|||
} |
|||
``` |
|||
|
|||
Create a folder named `MultiLingualObjects` and create the following class inside. |
|||
|
|||
```csharp |
|||
public class MultiLingualObjectManager : ITransientDependency |
|||
{ |
|||
protected const int MaxCultureFallbackDepth = 5; |
|||
|
|||
public async Task<TTranslation> FindTranslationAsync<TMultiLingual, TTranslation>( |
|||
TMultiLingual multiLingual, |
|||
string culture = null, |
|||
bool fallbackToParentCultures = true) |
|||
where TMultiLingual : IMultiLingualObject<TTranslation> |
|||
where TTranslation : class, IObjectTranslation |
|||
{ |
|||
culture ??= CultureInfo.CurrentUICulture.Name; |
|||
|
|||
if (multiLingual.Translations.IsNullOrEmpty()) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var translation = multiLingual.Translations.FirstOrDefault(pt => pt.Language == culture); |
|||
if (translation != null) |
|||
{ |
|||
return translation; |
|||
} |
|||
|
|||
if (fallbackToParentCultures) |
|||
{ |
|||
translation = GetTranslationBasedOnCulturalRecursive( |
|||
CultureInfo.CurrentUICulture.Parent, |
|||
multiLingual.Translations, |
|||
0 |
|||
); |
|||
|
|||
if (translation != null) |
|||
{ |
|||
return translation; |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
protected TTranslation GetTranslationBasedOnCulturalRecursive<TTranslation>( |
|||
CultureInfo culture, ICollection<TTranslation> translations, int currentDepth) |
|||
where TTranslation : class, IObjectTranslation |
|||
{ |
|||
if (culture == null || |
|||
culture.Name.IsNullOrWhiteSpace() || |
|||
translations.IsNullOrEmpty() || |
|||
currentDepth > MaxCultureFallbackDepth) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var translation = translations.FirstOrDefault(pt => pt.Language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)); |
|||
return translation ?? GetTranslationBasedOnCulturalRecursive(culture.Parent, translations, currentDepth + 1); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
With `MultiLingualObjectManager`'s `FindTranslationAsync` method, we get the translated version of the book according to `CurrentUICulture`. If no translation of culture is found, we return null. |
|||
|
|||
> Every thread in .NET has `CurrentCulture` and `CurrentUICulture` objects. |
|||
|
|||
#### Acme.BookStore.EntityFrameworkCore |
|||
|
|||
In the `OnModelCreating` method of the `BookStoreDbContext` class, configure the `BookTranslation` as follows. |
|||
|
|||
```csharp |
|||
builder.Entity<BookTranslation>(b => |
|||
{ |
|||
b.ToTable(BookStoreConsts.DbTablePrefix + "BookTranslations", |
|||
BookStoreConsts.DbSchema); |
|||
|
|||
b.ConfigureByConvention(); |
|||
|
|||
b.HasKey(x => new {x.BookId, x.Language}); |
|||
}); |
|||
``` |
|||
|
|||
> I haven't explicitly set up a one-to-many relationship between `Book` and `BookTranslation` here, but the entity framework will do it for us. |
|||
|
|||
After that, you can just run the following command in a command-line terminal to add a new database migration (in the directory of the `EntityFrameworkCore` project): |
|||
|
|||
```bash |
|||
dotnet ef migrations add Added_BookTranslation |
|||
``` |
|||
|
|||
This will add a new migration class to your project. You can then run the following command (or run the `.DbMigrator` application) to apply changes to the database: |
|||
|
|||
```bash |
|||
dotnet ef database update |
|||
``` |
|||
|
|||
Add the following code to the `ConfigureServices` method of the `BookStoreEntityFrameworkCoreModule`. |
|||
|
|||
```csharp |
|||
Configure<AbpEntityOptions>(options => |
|||
{ |
|||
options.Entity<Book>(bookOptions => |
|||
{ |
|||
bookOptions.DefaultWithDetailsFunc = query => query.Include(o => o.Translations); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
Now we can use `WithDetailsAsync` without any parameters on `BookAppService` knowing that `Translations` will be included. |
|||
|
|||
#### Acme.BookStore.Application.Contracts |
|||
|
|||
Implement `IObjectTranslation` in the `BookDto` class in the `Books` folder as follows. |
|||
|
|||
```csharp |
|||
public class BookDto : AuditedEntityDto<Guid>, IObjectTranslation |
|||
{ |
|||
public Guid AuthorId { get; set; } |
|||
|
|||
public string AuthorName { get; set; } |
|||
|
|||
public string Name { get; set; } |
|||
|
|||
public BookType Type { get; set; } |
|||
|
|||
public DateTime PublishDate { get; set; } |
|||
|
|||
public float Price { get; set; } |
|||
|
|||
public string Language { get; set; } |
|||
} |
|||
``` |
|||
|
|||
`Language` property is required to understand which language the translated book name belongs to in the UI. |
|||
|
|||
Create the `AddBookTranslationDto` class in `Books` folder as follows. |
|||
|
|||
```csharp |
|||
public class AddBookTranslationDto : IObjectTranslation |
|||
{ |
|||
[Required] |
|||
public string Language { get; set; } |
|||
|
|||
[Required] |
|||
public string Name { get; set; } |
|||
} |
|||
``` |
|||
|
|||
Add the `AddTranslationsAsync` method to `IBookAppService` as follows. |
|||
|
|||
```csharp |
|||
public interface IBookAppService : |
|||
ICrudAppService< |
|||
BookDto, |
|||
Guid, |
|||
PagedAndSortedResultRequestDto, |
|||
CreateUpdateBookDto> |
|||
{ |
|||
Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync(); |
|||
|
|||
Task AddTranslationsAsync(Guid id, AddBookTranslationDto input); // added this line |
|||
} |
|||
``` |
|||
|
|||
#### Acme.BookStore.Application |
|||
|
|||
Now we need to implement the `AddTranslationsAsync` method in `BookAppService` and include `Translations` in the `Book` entity, for this you can change the `BookAppService` as follows. |
|||
|
|||
```csharp |
|||
[Authorize(BookStorePermissions.Books.Default)] |
|||
public class BookAppService : |
|||
CrudAppService< |
|||
Book, //The Book entity |
|||
BookDto, //Used to show books |
|||
Guid, //Primary key of the book entity |
|||
PagedAndSortedResultRequestDto, //Used for paging/sorting |
|||
CreateUpdateBookDto>, //Used to create/update a book |
|||
IBookAppService //implement the IBookAppService |
|||
{ |
|||
private readonly IAuthorRepository _authorRepository; |
|||
|
|||
public BookAppService( |
|||
IRepository<Book, Guid> repository, |
|||
IAuthorRepository authorRepository) |
|||
: base(repository) |
|||
{ |
|||
_authorRepository = authorRepository; |
|||
GetPolicyName = BookStorePermissions.Books.Default; |
|||
GetListPolicyName = BookStorePermissions.Books.Default; |
|||
CreatePolicyName = BookStorePermissions.Books.Create; |
|||
UpdatePolicyName = BookStorePermissions.Books.Edit; |
|||
DeletePolicyName = BookStorePermissions.Books.Create; |
|||
} |
|||
|
|||
public override async Task<BookDto> GetAsync(Guid id) |
|||
{ |
|||
//Get the IQueryable<Book> from the repository |
|||
var queryable = await Repository.WithDetailsAsync(); // this line changed |
|||
|
|||
//Prepare a query to join books and authors |
|||
var query = from book in queryable |
|||
join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id |
|||
where book.Id == id |
|||
select new { book, author }; |
|||
|
|||
//Execute the query and get the book with author |
|||
var queryResult = await AsyncExecuter.FirstOrDefaultAsync(query); |
|||
if (queryResult == null) |
|||
{ |
|||
throw new EntityNotFoundException(typeof(Book), id); |
|||
} |
|||
|
|||
var bookDto = ObjectMapper.Map<Book, BookDto>(queryResult.book); |
|||
bookDto.AuthorName = queryResult.author.Name; |
|||
return bookDto; |
|||
} |
|||
|
|||
public override async Task<PagedResultDto<BookDto>> GetListAsync(PagedAndSortedResultRequestDto input) |
|||
{ |
|||
//Get the IQueryable<Book> from the repository |
|||
var queryable = await Repository.WithDetailsAsync(); // this line changed |
|||
|
|||
//Prepare a query to join books and authors |
|||
var query = from book in queryable |
|||
join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id |
|||
select new {book, author}; |
|||
|
|||
//Paging |
|||
query = query |
|||
.OrderBy(NormalizeSorting(input.Sorting)) |
|||
.Skip(input.SkipCount) |
|||
.Take(input.MaxResultCount); |
|||
|
|||
//Execute the query and get a list |
|||
var queryResult = await AsyncExecuter.ToListAsync(query); |
|||
|
|||
//Convert the query result to a list of BookDto objects |
|||
var bookDtos = queryResult.Select(x => |
|||
{ |
|||
var bookDto = ObjectMapper.Map<Book, BookDto>(x.book); |
|||
bookDto.AuthorName = x.author.Name; |
|||
return bookDto; |
|||
}).ToList(); |
|||
|
|||
//Get the total count with another query |
|||
var totalCount = await Repository.GetCountAsync(); |
|||
|
|||
return new PagedResultDto<BookDto>( |
|||
totalCount, |
|||
bookDtos |
|||
); |
|||
} |
|||
|
|||
public async Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync() |
|||
{ |
|||
var authors = await _authorRepository.GetListAsync(); |
|||
|
|||
return new ListResultDto<AuthorLookupDto>( |
|||
ObjectMapper.Map<List<Author>, List<AuthorLookupDto>>(authors) |
|||
); |
|||
} |
|||
|
|||
public async Task AddTranslationsAsync(Guid id, AddBookTranslationDto input) |
|||
{ |
|||
var queryable = await Repository.WithDetailsAsync(); |
|||
|
|||
var book = await AsyncExecuter.FirstOrDefaultAsync(queryable, x => x.Id == id); |
|||
|
|||
if (book.Translations.Any(x => x.Language == input.Language)) |
|||
{ |
|||
throw new UserFriendlyException($"Translation already available for {input.Language}"); |
|||
} |
|||
|
|||
book.Translations.Add(new BookTranslation |
|||
{ |
|||
BookId = book.Id, |
|||
Name = input.Name, |
|||
Language = input.Language |
|||
}); |
|||
|
|||
await Repository.UpdateAsync(book); |
|||
} |
|||
|
|||
private static string NormalizeSorting(string sorting) |
|||
{ |
|||
if (sorting.IsNullOrEmpty()) |
|||
{ |
|||
return $"book.{nameof(Book.Name)}"; |
|||
} |
|||
|
|||
if (sorting.Contains("authorName", StringComparison.OrdinalIgnoreCase)) |
|||
{ |
|||
return sorting.Replace( |
|||
"authorName", |
|||
"author.Name", |
|||
StringComparison.OrdinalIgnoreCase |
|||
); |
|||
} |
|||
|
|||
return $"book.{sorting}"; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Create the `MultiLingualBookObjectMapper` class as follows. |
|||
|
|||
```csharp |
|||
public class MultiLingualBookObjectMapper : IObjectMapper<Book, BookDto>, ITransientDependency |
|||
{ |
|||
private readonly MultiLingualObjectManager _multiLingualObjectManager; |
|||
|
|||
private readonly ISettingProvider _settingProvider; |
|||
|
|||
public MultiLingualBookObjectMapper( |
|||
MultiLingualObjectManager multiLingualObjectManager, |
|||
ISettingProvider settingProvider) |
|||
{ |
|||
_multiLingualObjectManager = multiLingualObjectManager; |
|||
_settingProvider = settingProvider; |
|||
} |
|||
|
|||
public BookDto Map(Book source) |
|||
{ |
|||
var translation = AsyncHelper.RunSync(() => |
|||
_multiLingualObjectManager.FindTranslationAsync<Book, BookTranslation>(source)); |
|||
|
|||
return new BookDto |
|||
{ |
|||
Id = source.Id, |
|||
AuthorId = source.AuthorId, |
|||
Type = source.Type, |
|||
Name = translation?.Name ?? source.Name, |
|||
PublishDate = source.PublishDate, |
|||
Price = source.Price, |
|||
Language = translation?.Language ?? AsyncHelper.RunSync(() => _settingProvider.GetOrNullAsync(LocalizationSettingNames.DefaultLanguage)), |
|||
CreationTime = source.CreationTime, |
|||
CreatorId = source.CreatorId, |
|||
LastModificationTime = source.LastModificationTime, |
|||
LastModifierId = source.LastModifierId |
|||
}; |
|||
} |
|||
|
|||
public BookDto Map(Book source, BookDto destination) |
|||
{ |
|||
return default; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
To map the multilingual `Book` entity to `BookDto`, we implement custom mapping using the `IObjectMapper<TSource, TDestination>` interface. If no translation is found, default values are returned. |
|||
|
|||
So far we have created the entire infrastructure. We don't need to change anything in the UI, if there is a translation according to the language chosen by the user, the list view will change. However, I want to create a simple modal where we can add new translations to an existing book in order to see what we have done. |
|||
|
|||
#### Acme.BookStore.Web |
|||
|
|||
Create a new razor page named `AddTranslationModal` in the `Books` folder as below. |
|||
|
|||
**View** |
|||
|
|||
```html |
|||
@page |
|||
@using Microsoft.AspNetCore.Mvc.TagHelpers |
|||
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal |
|||
@model Acme.BookStore.Web.Pages.Books.AddTranslationModal |
|||
|
|||
@{ |
|||
Layout = null; |
|||
} |
|||
|
|||
<form asp-page="/Books/AddTranslationModal"> |
|||
<abp-modal> |
|||
<abp-modal-header>Translations</abp-modal-header> |
|||
<abp-modal-body> |
|||
<abp-input asp-for="Id"></abp-input> |
|||
<abp-select asp-for="@Model.TranslationViewModel.Language" asp-items="Model.Languages" class="form-select"> |
|||
<option selected value="">Pick a language</option> |
|||
</abp-select> |
|||
<abp-input asp-for="TranslationViewModel.Name"></abp-input> |
|||
</abp-modal-body> |
|||
<abp-modal-footer buttons="@(AbpModalButtons.Cancel | AbpModalButtons.Save)"></abp-modal-footer> |
|||
</abp-modal> |
|||
</form> |
|||
``` |
|||
|
|||
**Model** |
|||
|
|||
```csharp |
|||
public class AddTranslationModal : BookStorePageModel |
|||
{ |
|||
[HiddenInput] |
|||
[BindProperty(SupportsGet = true)] |
|||
public Guid Id { get; set; } |
|||
|
|||
public List<SelectListItem> Languages { get; set; } |
|||
|
|||
[BindProperty] |
|||
public BookTranslationViewModel TranslationViewModel { get; set; } |
|||
|
|||
private readonly IBookAppService _bookAppService; |
|||
private readonly ILanguageProvider _languageProvider; |
|||
|
|||
public AddTranslationModalModel( |
|||
IBookAppService bookAppService, |
|||
ILanguageProvider languageProvider) |
|||
{ |
|||
_bookAppService = bookAppService; |
|||
_languageProvider = languageProvider; |
|||
} |
|||
|
|||
public async Task OnGetAsync() |
|||
{ |
|||
Languages = await GetLanguagesSelectItem(); |
|||
|
|||
TranslationViewModel = new BookTranslationViewModel(); |
|||
} |
|||
|
|||
public async Task<IActionResult> OnPostAsync() |
|||
{ |
|||
await _bookAppService.AddTranslationsAsync(Id, ObjectMapper.Map<BookTranslationViewModel, AddBookTranslationDto>(TranslationViewModel)); |
|||
|
|||
return NoContent(); |
|||
} |
|||
|
|||
private async Task<List<SelectListItem>> GetLanguagesSelectItem() |
|||
{ |
|||
var result = await _languageProvider.GetLanguagesAsync(); |
|||
|
|||
return result.Select( |
|||
languageInfo => new SelectListItem |
|||
{ |
|||
Value = languageInfo.CultureName, |
|||
Text = languageInfo.DisplayName + " (" + languageInfo.CultureName + ")" |
|||
} |
|||
).ToList(); |
|||
} |
|||
|
|||
public class BookTranslationViewModel |
|||
{ |
|||
[Required] |
|||
[SelectItems(nameof(Languages))] |
|||
public string Language { get; set; } |
|||
|
|||
[Required] |
|||
public string Name { get; set; } |
|||
|
|||
} |
|||
} |
|||
``` |
|||
|
|||
Finally, change the content of `index.js` in the `Books` folder as follows for `Add Translation` action. |
|||
|
|||
```javascript |
|||
$(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 addTranslationModal = new abp.ModalManager(abp.appPath + 'Books/AddTranslationModal'); // added this line |
|||
|
|||
var dataTable = $('#BooksTable').DataTable( |
|||
abp.libs.datatables.normalizeConfiguration({ |
|||
serverSide: true, |
|||
paging: true, |
|||
order: [[1, "asc"]], |
|||
searching: false, |
|||
scrollX: true, |
|||
ajax: abp.libs.datatables.createAjax(acme.bookStore.books.book.getList), |
|||
columnDefs: [ |
|||
{ |
|||
title: l('Actions'), |
|||
rowAction: { |
|||
items: |
|||
[ |
|||
{ |
|||
text: l('Edit'), |
|||
visible: abp.auth.isGranted('BookStore.Books.Edit'), |
|||
action: function (data) { |
|||
editModal.open({ id: data.record.id }); |
|||
} |
|||
}, |
|||
{ |
|||
text: l('Add Translation'), // added this action |
|||
visible: abp.auth.isGranted('BookStore.Books.Edit'), |
|||
action: function (data) { |
|||
addTranslationModal.open({ id: data.record.id }); |
|||
} |
|||
}, |
|||
{ |
|||
text: l('Delete'), |
|||
visible: abp.auth.isGranted('BookStore.Books.Delete'), |
|||
confirmMessage: function (data) { |
|||
return l( |
|||
'BookDeletionConfirmationMessage', |
|||
data.record.name |
|||
); |
|||
}, |
|||
action: function (data) { |
|||
acme.bookStore.books.book |
|||
.delete(data.record.id) |
|||
.then(function() { |
|||
abp.notify.info( |
|||
l('SuccessfullyDeleted') |
|||
); |
|||
dataTable.ajax.reload(); |
|||
}); |
|||
} |
|||
} |
|||
] |
|||
} |
|||
}, |
|||
{ |
|||
title: l('Name'), |
|||
data: "name" |
|||
}, |
|||
{ |
|||
title: l('Author'), |
|||
data: "authorName" |
|||
}, |
|||
{ |
|||
title: l('Type'), |
|||
data: "type", |
|||
render: function (data) { |
|||
return l('Enum:BookType:' + data); |
|||
} |
|||
}, |
|||
{ |
|||
title: l('PublishDate'), |
|||
data: "publishDate", |
|||
render: function (data) { |
|||
return luxon |
|||
.DateTime |
|||
.fromISO(data, { |
|||
locale: abp.localization.currentCulture.name |
|||
}).toLocaleString(); |
|||
} |
|||
}, |
|||
{ |
|||
title: l('Price'), |
|||
data: "price" |
|||
}, |
|||
{ |
|||
title: l('CreationTime'), |
|||
data: "creationTime", |
|||
render: function (data) { |
|||
return luxon |
|||
.DateTime |
|||
.fromISO(data, { |
|||
locale: abp.localization.currentCulture.name |
|||
}).toLocaleString(luxon.DateTime.DATETIME_SHORT); |
|||
} |
|||
} |
|||
] |
|||
}) |
|||
); |
|||
|
|||
createModal.onResult(function () { |
|||
dataTable.ajax.reload(); |
|||
}); |
|||
|
|||
editModal.onResult(function () { |
|||
dataTable.ajax.reload(); |
|||
}); |
|||
|
|||
$('#NewBookButton').click(function (e) { |
|||
e.preventDefault(); |
|||
createModal.open(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
Then, we can open the `BookStoreWebAutoMapperProfile class and define the required mapping as follows: |
|||
|
|||
```csharp |
|||
CreateMap<AddTranslationModal.BookTranslationViewModel, AddBookTranslationDto>(); |
|||
``` |
|||
|
|||
## Conclusion |
|||
|
|||
With a multi-lingual application, you can expand your market share, but if not designed well, may your application will be unusable. So, I've tried to explain how to design a sustainable multi-lingual entity in this article. |
|||
|
|||
### Source Code |
|||
|
|||
You can find source of the example solution used in this article [here](https://github.com/abpframework/abp-samples/tree/master/AcmeBookStoreMultiLingual). |
|||
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
Loading…
Reference in new issue