diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/en.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/en.json index 07aa50adf2..d0d829bbf3 100644 --- a/abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/en.json +++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/en.json @@ -13,8 +13,9 @@ "Permission:Edit": "Edit", "Permission:Delete": "Delete", "Permission:Create": "Create", - "Permission:Accounting": "Accounting", - "Permission:Accounting:Quotation": "Quotation", + "Permission:Accounting": "Accounting", + "Permission:Accounting:Quotation": "Quotation", + "Permission:Accounting:Invoice": "Invoice", "Menu:Organizations": "Organizations", "Menu:Accounting": "Accounting", "Menu:Packages": "Packages", @@ -101,13 +102,10 @@ "CompanyName": "Company name", "CompanyAddress": "Company address", "Price": "Price", -<<<<<<< Updated upstream "DiscountText": "Discount text", "DiscountQuantity": "Discount quantity", - "DiscountPrice": "Discount price", + "DiscountPrice": "Discount price", "Quotation": "Quotation", - "Generate": "Generate" -======= "ExtraText": "Extra Text", "ExtraAmount": "Extra Amount", "DownloadQuotation": "Download Quotation", @@ -117,9 +115,10 @@ "InvoiceDate": "Invoice Date", "Quantity": "Quantity", "AddProduct": "Add Product", - "NeedToAddProduct": "Need to add product to generate invoice !", + "AddProductWarning": "You need to add product!", "TotalPrice": "Total Price", - "Generate": "Generate" ->>>>>>> Stashed changes + "Generate": "Generate", + "MissingQuantityField": "The quantity field is required!", + "MissingPriceField": "The Price field is required!" } } \ No newline at end of file diff --git a/docs/en/Tutorials/Part-3.md b/docs/en/Tutorials/Part-3.md index 4cbefde69e..14822c9fe2 100644 --- a/docs/en/Tutorials/Part-3.md +++ b/docs/en/Tutorials/Part-3.md @@ -96,9 +96,9 @@ namespace Acme.BookStore ```` * `IRepository` is injected and used it in the `SeedAsync` to create two book entities as the test data. +* `IGuidGenerator` is injected to create GUIDs. While `Guid.NewGuid()` would perfectly work for testing, `IGuidGenerator` has additional features especially important while using real databases. Further information, see the [Guid generation document](../Guid-Generation.md). -### Testing the application service BookAppService -* `IGuidGenerator` is injected to create GUIDs. While `Guid.NewGuid()` would perfectly work for testing, `IGuidGenerator` has additional features especially important while using real databases. Further information, see the [Guid generation document](https://docs.abp.io/{{Document_Language_Code}}/abp/{{Document_Version}}/Guid-Generation). +### Testing the application service BookAppService Create a test class named `BookAppService_Tests` in the `Acme.BookStore.Application.Tests` project: diff --git a/docs/zh-Hans/Tutorials/Part-1.md b/docs/zh-Hans/Tutorials/Part-1.md index e427b290a6..43590faf2b 100644 --- a/docs/zh-Hans/Tutorials/Part-1.md +++ b/docs/zh-Hans/Tutorials/Part-1.md @@ -27,8 +27,8 @@ end ASP.NET Core {{UI_Value}} 系列教程包括三个3个部分: - **Part-1: 创建项目和书籍列表页面(本章)** -- [Part-2: 创建,编辑,删除书籍](Part-II.md) -- [Part-3: 集成测试](Part-III.md) +- [Part-2: 创建,编辑,删除书籍](Part-2.md) +- [Part-3: 集成测试](Part-3.md) > 你也可以观看由ABP社区成员为本教程录制的[视频课程](https://amazingsolutions.teachable.com/p/lets-build-the-bookstore-application). diff --git a/docs/zh-Hans/Tutorials/Part-2.md b/docs/zh-Hans/Tutorials/Part-2.md index d12e28290b..56dadcbbcd 100644 --- a/docs/zh-Hans/Tutorials/Part-2.md +++ b/docs/zh-Hans/Tutorials/Part-2.md @@ -1 +1,1349 @@ -TODO .... \ No newline at end of file +## ASP.NET Core {{UI_Value}} 教程 - 第二章 +````json +//[doc-params] +{ + "UI": ["MVC","NG"] +} +```` + +{{ +if UI == "MVC" + DB="ef" + DB_Text="Entity Framework Core" + UI_Text="mvc" +else if UI == "NG" + DB="mongodb" + DB_Text="MongoDB" + UI_Text="angular" +else + DB ="?" + UI_Text="?" +end +}} + +### 关于本教程 + +这是ASP.NET Core{{UI_Value}}系列教程的第二章. 共有三章: + +- [Part-1: 创建项目和书籍列表页面](Part-1.md) +- **Part 2: 创建,编辑,删除书籍(本章)** +- [Part-3: 集成测试](Part-3.md) + +> 你也可以观看由ABP社区成员为本教程录制的[视频课程](https://amazingsolutions.teachable.com/p/lets-build-the-bookstore-application). + +{{if UI == "MVC"}} + +### 新增 Book 实体 + +通过本节, 你将会了解如何创建一个 modal form 来实现新增书籍的功能. 最终成果如下图所示: + +![bookstore-create-dialog](./images/bookstore-create-dialog-2.png) + +#### 新建 modal form + +在 `Acme.BookStore.Web` 项目的 `Pages/Books` 目录下新建一个 `CreateModal.cshtml` Razor页面: + +![bookstore-add-create-dialog](./images/bookstore-add-create-dialog-v2.png) + +##### CreateModal.cshtml.cs + +打开 `CreateModal.cshtml.cs` 代码文件,用如下代码替换 `CreateModalModel` 类的实现: + +````C# +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace Acme.BookStore.Web.Pages.Books +{ + public class CreateModalModel : BookStorePageModel + { + [BindProperty] + public CreateUpdateBookDto Book { get; set; } + + private readonly IBookAppService _bookAppService; + + public CreateModalModel(IBookAppService bookAppService) + { + _bookAppService = bookAppService; + } + + public async Task OnPostAsync() + { + await _bookAppService.CreateAsync(Book); + return NoContent(); + } + } +} +```` + +* 该类派生于 `BookStorePageModel` 而非默认的 `PageModel`. `BookStorePageModel` 继承了 `PageModel` 并且添加了一些可以被你的page model类使用的通用属性和方法. +* `Book` 属性上的 `[BindProperty]` 特性将post请求提交上来的数据绑定到该属性上. +* 该类通过构造函数注入了 `IBookAppService` 应用服务,并且在 `OnPostAsync` 处理程序中调用了服务的 `CreateAsync` 方法. + +##### CreateModal.cshtml + +打开 `CreateModal.cshtml` 文件并粘贴如下代码: + +````html +@page +@inherits Acme.BookStore.Web.Pages.BookStorePage +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@model Acme.BookStore.Web.Pages.Books.CreateModalModel +@{ + Layout = null; +} + + + + + + + + + +```` + +* 这个 modal 使用 `abp-dynamic-form` Tag Helper 根据 `CreateBookViewModel` 类自动构建了表单. + * `abp-model` 指定了 `Book` 属性为模型对象. + * `data-ajaxForm` 设置了表单通过AJAX提交,而不是经典的页面回发. + * `abp-form-content` tag helper 作为表单控件渲染位置的占位符 (这是可选的,只有你在 `abp-dynamic-form` 中像本示例这样添加了其他内容才需要). + +#### 添加 "New book" 按钮 + +打开 `Pages/Books/Index.cshtml` 并按如下代码修改 `abp-card-header` : + +````html + + + +

@L["Books"]

+
+ + + +
+
+```` + +如下图所示,只是在表格 **右上方** 添加了 **New book** 按钮: + +![bookstore-new-book-button](./images/bookstore-new-book-button.png) + +打开 `Pages/books/index.js` 在 `datatable` 配置代码后面添加如下代码: + +````js +var createModal = new abp.ModalManager(abp.appPath + 'Books/CreateModal'); + +createModal.onResult(function () { + dataTable.ajax.reload(); +}); + +$('#NewBookButton').click(function (e) { + e.preventDefault(); + createModal.open(); +}); +```` + +* `abp.ModalManager` 是一个在客户端打开和管理modal的辅助类.它基于Twitter Bootstrap的标准modal组件通过简化的API抽象隐藏了许多细节. + +现在,你可以 **运行程序** 通过新的 modal form 来创建书籍了. + +### 编辑更新已存在的 Book 实体 + +在 `Acme.BookStore.Web` 项目的 `Pages/Books` 目录下新建一个名叫 `EditModal.cshtml` 的Razor页面: + +![bookstore-add-edit-dialog](./images/bookstore-add-edit-dialog.png) + +#### EditModal.cshtml.cs + +展开 `EditModal.cshtml`,打开 `EditModal.cshtml.cs` 文件( `EditModalModel` 类) 并替换成以下代码: + +````csharp +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace Acme.BookStore.Web.Pages.Books +{ + public class EditModalModel : BookStorePageModel + { + [HiddenInput] + [BindProperty(SupportsGet = true)] + public Guid Id { get; set; } + + [BindProperty] + public CreateUpdateBookDto Book { get; set; } + + private readonly IBookAppService _bookAppService; + + public EditModalModel(IBookAppService bookAppService) + { + _bookAppService = bookAppService; + } + + public async Task OnGetAsync() + { + var bookDto = await _bookAppService.GetAsync(Id); + Book = ObjectMapper.Map(bookDto); + } + + public async Task OnPostAsync() + { + await _bookAppService.UpdateAsync(Id, Book); + return NoContent(); + } + } +} +```` + +* `[HiddenInput]` 和 `[BindProperty]` 是标准的 ASP.NET Core MVC 特性.这里启用 `SupportsGet` 从Http请求的查询字符串中获取Id的值. +* 在 `OnGetAsync` 方法中,将 `BookAppService.GetAsync` 方法返回的 `BookDto` 映射成 `CreateUpdateBookDto` 并赋值给Book属性. +* `OnPostAsync` 方法直接使用 `BookAppService.UpdateAsync` 来更新实体. + +#### BookDto到CreateUpdateBookDto对象映射 + +为了执行`BookDto`到`CreateUpdateBookDto`对象映射,请打开`Acme.BookStore.Web`项目中的`BookStoreWebAutoMapperProfile.cs`并更改它,如下所示: + +````csharp +using AutoMapper; + +namespace Acme.BookStore.Web +{ + public class BookStoreWebAutoMapperProfile : Profile + { + public BookStoreWebAutoMapperProfile() + { + CreateMap(); + } + } +} +```` + +* 刚刚添加了`CreateMap();`作为映射定义. + +#### EditModal.cshtml + +将 `EditModal.cshtml` 页面内容替换成如下代码: + +````html +@page +@inherits Acme.BookStore.Web.Pages.BookStorePage +@using Acme.BookStore.Web.Pages.Books +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@model EditModalModel +@{ + Layout = null; +} + + + + + + + + + + +```` + +这个页面内容和 `CreateModal.cshtml` 非常相似,除了以下几点: + +* 它包含`id`属性的`abp-input`, 用于存储编辑书的 `id` (它是隐藏的Input) +* 此页面指定的post地址是`Books/EditModal`, 并用文本 *Update* 作为 modal 标题. + +#### 为表格添加 "操作(Actions)" 下拉菜单 + +我们将为表格每行添加下拉按钮 ("Actions") . 最终效果如下: + +![bookstore-books-table-actions](images/bookstore-books-table-actions.png) + +打开 `Pages/Books/Index.cshtml` 页面,并按下方所示修改表格部分的代码: + +````html + + + + @L["Actions"] + @L["Name"] + @L["Type"] + @L["PublishDate"] + @L["Price"] + @L["CreationTime"] + + + +```` + +* 只是为"Actions"增加了一个 `th` 标签. + +打开 `Pages/books/index.js` 并用以下内容进行替换: + +````js +$(function () { + + var l = abp.localization.getResource('BookStore'); + + var createModal = new abp.ModalManager(abp.appPath + 'Books/CreateModal'); + var editModal = new abp.ModalManager(abp.appPath + 'Books/EditModal'); + + var dataTable = $('#BooksTable').DataTable(abp.libs.datatables.normalizeConfiguration({ + processing: true, + serverSide: true, + paging: true, + searching: false, + autoWidth: false, + scrollCollapse: true, + order: [[1, "asc"]], + ajax: abp.libs.datatables.createAjax(acme.bookStore.book.getList), + columnDefs: [ + { + rowAction: { + items: + [ + { + text: l('Edit'), + action: function (data) { + editModal.open({ id: data.record.id }); + } + } + ] + } + }, + { data: "name" }, + { data: "type" }, + { data: "publishDate" }, + { data: "price" }, + { data: "creationTime" } + ] + })); + + createModal.onResult(function () { + dataTable.ajax.reload(); + }); + + editModal.onResult(function () { + dataTable.ajax.reload(); + }); + + $('#NewBookButton').click(function (e) { + e.preventDefault(); + createModal.open(); + }); +}); +```` + +* 通过 `abp.localization.getResource('BookStore')` 可以在客户端使用服务器端定义的相同的本地化语言文本. +* 添加了一个名为 `createModal` 的新的 `ModalManager` 来打开创建用的 modal 对话框. +* 添加了一个名为 `editModal` 的新的 `ModalManager` 来打开编辑用的 modal 对话框. +* 在 `columnDefs` 起始处新增一列用于显示 "Actions" 下拉按钮. +* "New Book"动作只需调用`createModal.open`来打开创建对话框. +* "Edit" 操作只是简单调用 `editModal.open` 来打开编辑对话框. + +现在,你可以运行程序,通过编辑操作来更新任一个book实体. + +### 删除一个已有的Book实体 + +打开 `Pages/books/index.js` 文件,在 `rowAction` `items` 下新增一项: + +````js +{ + text: l('Delete'), + confirmMessage: function (data) { + return l('BookDeletionConfirmationMessage', data.record.name); + }, + action: function (data) { + acme.bookStore.book + .delete(data.record.id) + .then(function() { + abp.notify.info(l('SuccessfullyDeleted')); + dataTable.ajax.reload(); + }); + } +} +```` + +* `confirmMessage` 用来在实际执行 `action` 之前向用户进行确认. +* 通过javascript代理方法 `acme.bookStore.book.delete` 执行一个AJAX请求来删除一个book实体. +* `abp.notify.info` 用来在执行删除操作后显示一个toastr通知信息. + +最终的 `index.js` 文件内容如下所示: + +````js +$(function () { + + var l = abp.localization.getResource('BookStore'); + + var createModal = new abp.ModalManager(abp.appPath + 'Books/CreateModal'); + var editModal = new abp.ModalManager(abp.appPath + 'Books/EditModal'); + + var dataTable = $('#BooksTable').DataTable(abp.libs.datatables.normalizeConfiguration({ + processing: true, + serverSide: true, + paging: true, + searching: false, + autoWidth: false, + scrollCollapse: true, + order: [[1, "asc"]], + ajax: abp.libs.datatables.createAjax(acme.bookStore.book.getList), + columnDefs: [ + { + rowAction: { + items: + [ + { + text: l('Edit'), + action: function (data) { + editModal.open({ id: data.record.id }); + } + }, + { + text: l('Delete'), + confirmMessage: function (data) { + return l('BookDeletionConfirmationMessage', data.record.name); + }, + action: function (data) { + acme.bookStore.book + .delete(data.record.id) + .then(function() { + abp.notify.info(l('SuccessfullyDeleted')); + dataTable.ajax.reload(); + }); + } + } + ] + } + }, + { data: "name" }, + { data: "type" }, + { data: "publishDate" }, + { data: "price" }, + { data: "creationTime" } + ] + })); + + createModal.onResult(function () { + dataTable.ajax.reload(); + }); + + editModal.onResult(function () { + dataTable.ajax.reload(); + }); + + $('#NewBookButton').click(function (e) { + e.preventDefault(); + createModal.open(); + }); +}); +```` + +打开`Acme.BookStore.Domain.Shared`项目中的`en.json`并添加以下行: + +````json +"BookDeletionConfirmationMessage": "Are you sure to delete the book {0}?", +"SuccessfullyDeleted": "Successfully deleted" +```` + +运行程序并尝试删除一个book实体. + +{{end}} + +### 新增 Book 实体 + +下面的章节中,你将学习到如何创建一个新的模态对话框来新增Book实体. + +#### 状态定义 + +在 `books\state` 文件夹下打开 `books.action.ts` 文件,使用以下内容替换它: + +```js +import { CreateUpdateBookDto } from '../../app/shared/models'; //<== added this line ==> + +export class GetBooks { + static readonly type = '[Books] Get'; +} + +// added CreateUpdateBook class +export class CreateUpdateBook { + static readonly type = '[Books] Create Update Book'; + constructor(public payload: CreateUpdateBookDto) { } +} +``` + +* 我们导入了 `CreateUpdateBookDto` 模型并且创建了 `CreateUpdateBook` 动作. + +打开 `books\state` 文件夹下的 `books.state.ts` 文件,使用以下内容替换它: + +```js +import { PagedResultDto } from '@abp/ng.core'; +import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { GetBooks, CreateUpdateBook } from './books.actions'; // <== added CreateUpdateBook==> +import { BookService } from '../../app/shared/services'; +import { tap } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { BookDto } from '../../app/shared/models'; + +export class BooksStateModel { + public book: PagedResultDto; +} + +@State({ + name: 'BooksState', + defaults: { book: {} } as BooksStateModel, +}) +@Injectable() +export class BooksState { + @Selector() + static getBooks(state: BooksStateModel) { + return state.book.items || []; + } + + constructor(private bookService: BookService) {} + + @Action(GetBooks) + get(ctx: StateContext) { + return this.bookService.getListByInput().pipe( + tap((bookResponse) => { + ctx.patchState({ + book: bookResponse, + }); + }) + ); + } + + // added CreateUpdateBook action listener + @Action(CreateUpdateBook) + save(ctx: StateContext, action: CreateUpdateBook) { + return this.bookService.createByInput(action.payload); + } +} +``` + +* 我们导入了 `CreateUpdateBook` 动作并且定义了 `save` 方法监听 `CreateUpdateBook` 动作去创建图书. + +当 `SaveBook` 动作被分派时,save方法被执行. 它调用 `BookService` 的 `createByInput` 方法. + +#### 添加模态到 BookListComponent + +打开 `books\book-list` 文件夹内的 `book-list.component.html` 文件,使用以下内容替换它: + +```html +
+
+
+
+
+ {%{{{ '::Menu:Books' | abpLocalization }}}%} +
+
+ +
+
+ +
+
+
+
+
+ + + + + {%{{{ "::Name" | abpLocalization }}}%} + {%{{{ "::Type" | abpLocalization }}}%} + {%{{{ "::PublishDate" | abpLocalization }}}%} + {%{{{ "::Price" | abpLocalization }}}%} + + + + + {%{{{ data.name }}}%} + {%{{{ booksType[data.type] }}}%} + {%{{{ data.publishDate | date }}}%} + {%{{{ data.price }}}%} + + +
+
+ + + + +

{%{{{ '::NewBook' | abpLocalization }}}%}

+
+ + + + + + +
+``` + +* 我们添加了 `abp-modal` 渲染模态框,允许用户创建新书. +* `abp-modal` 是显示模态框的预构建组件. 你也可以使用其它方法显示模态框,但 `abp-modal` 提供了一些附加的好处. +* 我们添加了 `New book` 按钮到 `AbpContentToolbar`. + +打开 `books\book-list` 文件夹下的 `book-list.component.ts` 文件,使用以下内容替换它: + +```js +import { Component, OnInit } from '@angular/core'; +import { Select, Store } from '@ngxs/store'; +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; +import { BookDto, BookType } from '../../app/shared/models'; +import { GetBooks } from '../state/books.actions'; +import { BooksState } from '../state/books.state'; + +@Component({ + selector: 'app-book-list', + templateUrl: './book-list.component.html', + styleUrls: ['./book-list.component.scss'], +}) +export class BookListComponent implements OnInit { + @Select(BooksState.getBooks) + books$: Observable; + + booksType = BookType; + + loading = false; + + isModalOpen = false; // <== added this line ==> + + constructor(private store: Store) {} + + ngOnInit() { + this.get(); + } + + get() { + this.loading = true; + this.store + .dispatch(new GetBooks()) + .pipe(finalize(() => (this.loading = false))) + .subscribe(() => {}); + } + + // added createBook method + createBook() { + this.isModalOpen = true; + } +} +``` + +* 我们添加了 `isModalOpen = false` 和 `createBook` 方法. + +你可以打开浏览器,点击**New book**按钮看到模态框. + +![Empty modal for new book](./images/bookstore-empty-new-book-modal.png) + +#### 添加响应式表单 + +[响应式表单](https://angular.io/guide/reactive-forms) 提供一种模型驱动的方法来处理其值随时间变化的表单输入. + +打开 `app\books\book-list` 文件夹下的 `book-list.component.ts` 文件,使用以下内容替换它: + +```js +import { Component, OnInit } from '@angular/core'; +import { Select, Store } from '@ngxs/store'; +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; +import { BookDto, BookType } from '../../app/shared/models'; +import { GetBooks } from '../state/books.actions'; +import { BooksState } from '../state/books.state'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; // <== added this line ==> + +@Component({ + selector: 'app-book-list', + templateUrl: './book-list.component.html', + styleUrls: ['./book-list.component.scss'], +}) +export class BookListComponent implements OnInit { + @Select(BooksState.getBooks) + books$: Observable; + + booksType = BookType; + + loading = false; + + isModalOpen = false; + + form: FormGroup; // <== added this line ==> + + constructor(private store: Store, private fb: FormBuilder) {} // <== added FormBuilder ==> + + ngOnInit() { + this.get(); + } + + get() { + this.loading = true; + this.store + .dispatch(new GetBooks()) + .pipe(finalize(() => (this.loading = false))) + .subscribe(() => {}); + } + + createBook() { + this.buildForm(); //<== added this line ==> + this.isModalOpen = true; + } + + // added buildForm method + buildForm() { + this.form = this.fb.group({ + name: ['', Validators.required], + type: [null, Validators.required], + publishDate: [null, Validators.required], + price: [null, Validators.required], + }); + } +} +``` + +* 我们导入了 `FormGroup, FormBuilder and Validators`. +* 我们添加了 `form: FormGroup` 变量. +* 我们注入了 `fb: FormBuilder` 服务到构造函数. [FormBuilder](https://angular.io/api/forms/FormBuilder) 服务为生成控件提供了方便的方法. 它减少了构建复杂表单所需的样板文件的数量. +* 我们添加了 `buildForm` 方法到文件末尾, 在 `createBook` 方法调用 `buildForm()` 方法. 该方法创建一个响应式表单去创建新书. + * `FormBuilder` 内的 `fb.group` 方法创建一个 `FormGroup`. + * 添加了 `Validators.required` 静态方法用于验证表单元素. + +#### 创建表单的DOM元素 + +打开 `app\books\book-list` 文件夹下的 `book-list.component.html` 文件,使用以下内容替换 ` `: + +```html + +
+
+ * + +
+ +
+ * + +
+ +
+ * + +
+ +
+ * + +
+
+
+``` + +- 模板创建了带有 `Name`, `Price`, `Type` 和 `Publish` 时间字段的表单. +- 我们在组件中使用了 [NgBootstrap datepicker](https://ng-bootstrap.github.io/#/components/datepicker/overview). + +#### Datepicker 要求 + +打开 `app\books` 文件夹下的 `books.module.ts` 文件,使用以下内容替换它: + +```js +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { BooksRoutingModule } from './books-routing.module'; +import { BooksComponent } from './books.component'; +import { BookListComponent } from './book-list/book-list.component'; +import { SharedModule } from '../shared/shared.module'; +import { NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; //<== added this line ==> + +@NgModule({ + declarations: [BooksComponent, BookListComponent], + imports: [ + CommonModule, + BooksRoutingModule, + SharedModule, + NgbDatepickerModule //<== added this line ==> + ] +}) +export class BooksModule { } +``` + +* 我们导入了 `NgbDatepickerModule` 来使用日期选择器. + +打开 `app\books\book-list` 文件夹下的 `book-list.component.ts` 文件,使用以下内容替换它: + +```js +import { Component, OnInit } from '@angular/core'; +import { Select, Store } from '@ngxs/store'; +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; +import { BookDto, BookType } from '../../app/shared/models'; +import { GetBooks } from '../state/books.actions'; +import { BooksState } from '../state/books.state'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'; // <== added this line ==> + +@Component({ + selector: 'app-book-list', + templateUrl: './book-list.component.html', + styleUrls: ['./book-list.component.scss'], + providers: [{ provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }], // <== added this line ==> +}) +export class BookListComponent implements OnInit { + @Select(BooksState.getBooks) + books$: Observable; + + booksType = BookType; + + //added bookTypeArr array + bookTypeArr = Object.keys(BookType).filter( + (bookType) => typeof this.booksType[bookType] === 'number' + ); + + loading = false; + + isModalOpen = false; + + form: FormGroup; + + constructor(private store: Store, private fb: FormBuilder) {} + + ngOnInit() { + this.get(); + } + + get() { + this.loading = true; + this.store + .dispatch(new GetBooks()) + .pipe(finalize(() => (this.loading = false))) + .subscribe(() => {}); + } + + createBook() { + this.buildForm(); + this.isModalOpen = true; + } + + buildForm() { + this.form = this.fb.group({ + name: ['', Validators.required], + type: [null, Validators.required], + publishDate: [null, Validators.required], + price: [null, Validators.required], + }); + } +} +``` + +* 我们导入了 ` NgbDateNativeAdapter, NgbDateAdapter` + +* 我们添加了一个新的 `NgbDateAdapter` 提供程序,它将Datepicker值转换为Date类型. 有关更多详细信息,请参见[datepicker adapters](https://ng-bootstrap.github.io/#/components/datepicker/overview). + +* 我们添加了 `bookTypeArr` 数组,以便能够在combobox值中使用它. `bookTypeArr` 包含 `BookType` 枚举的字段. 得到的数组如下所示: + + ```js + ['Adventure', 'Biography', 'Dystopia', 'Fantastic' ...] + ``` + + 在先前的表单模板中 用 `ngFor` 使用这个数组. + +现在你可以打开浏览器看到以下变化: + +![New book modal](./images/bookstore-new-book-form.png) + +#### 保存图书 + +打开 `app\books\book-list` 文件夹下的 `book-list.component.ts` 文件,使用以下内容替换它: + +```js +import { Component, OnInit } from '@angular/core'; +import { Select, Store } from '@ngxs/store'; +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; +import { BookDto, BookType } from '../../app/shared/models'; +import { GetBooks, CreateUpdateBook } from '../state/books.actions'; // <== added CreateUpdateBook ==> +import { BooksState } from '../state/books.state'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'app-book-list', + templateUrl: './book-list.component.html', + styleUrls: ['./book-list.component.scss'], + providers: [{ provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }], +}) +export class BookListComponent implements OnInit { + @Select(BooksState.getBooks) + books$: Observable; + + booksType = BookType; + + //added bookTypeArr array + bookTypeArr = Object.keys(BookType).filter( + (bookType) => typeof this.booksType[bookType] === 'number' + ); + + loading = false; + + isModalOpen = false; + + form: FormGroup; + + constructor(private store: Store, private fb: FormBuilder) {} + + ngOnInit() { + this.get(); + } + + get() { + this.loading = true; + this.store + .dispatch(new GetBooks()) + .pipe(finalize(() => (this.loading = false))) + .subscribe(() => {}); + } + + createBook() { + this.buildForm(); + this.isModalOpen = true; + } + + buildForm() { + this.form = this.fb.group({ + name: ['', Validators.required], + type: [null, Validators.required], + publishDate: [null, Validators.required], + price: [null, Validators.required], + }); + } + + //<== added save ==> + save() { + if (this.form.invalid) { + return; + } + + this.store.dispatch(new CreateUpdateBook(this.form.value)).subscribe(() => { + this.isModalOpen = false; + this.form.reset(); + this.get(); + }); + } +} +``` + +* 我们导入了 `CreateUpdateBook`. +* 我们添加了 `save` 方法. + +打开 `app\books\book-list` 文件夹下的 `app\books\book-list`文件, 添加 `abp-button` 保存图书. + +```html + + + + + + +``` + +使用以下内容替换 `
` 标签: + +```html + +``` + +* 我们添加了 `(ngSubmit)="save()"` 到 `` 元素,当按下enter时保存图书. +* 我们在模态框的底部添加了 `abp-button` 来保存图书. + +模态框最终看起来像这样: + +![Save button to the modal](./images/bookstore-new-book-form-v2.png) + +### 更新图书 + +#### CreateUpdateBook 动作 + +打开 `books\state` 文件夹下的 `books.actions.ts` 文件,使用以下内容替换它: + +```js +import { CreateUpdateBookDto } from '../../app/shared/models'; + +export class GetBooks { + static readonly type = '[Books] Get'; +} + +export class CreateUpdateBook { + static readonly type = '[Books] Create Update Book'; + constructor(public payload: CreateUpdateBookDto, public id?: string) { } // <== added id parameter ==> +} +``` + +* 我们在 `CreateUpdateBook` 动作的构造函数添加了 `id` 参数. + +打开 `books\state` 文件夹下的 `books.state.ts` 文件,使用以下内容替换 `save` 方法: + +```js +@Action(CreateUpdateBook) +save(ctx: StateContext, action: CreateUpdateBook) { + if (action.id) { + return this.bookService.updateByIdAndInput(action.payload, action.id); + } else { + return this.bookService.createByInput(action.payload); + } +} +``` + +#### BookListComponent + +打开 `app\books\book-list` 文件夹下的 `book-list.component.ts` 文件,在构造函数注入 `BookService` 服务,并添加 名为 `selectedBook` 的变量. + +```js +import { Component, OnInit } from '@angular/core'; +import { Select, Store } from '@ngxs/store'; +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; +import { BookDto, BookType } from '../../app/shared/models'; +import { GetBooks, CreateUpdateBook } from '../state/books.actions'; +import { BooksState } from '../state/books.state'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'; +import { BookService } from '../../app/shared/services'; // <== imported BookService ==> + +@Component({ + selector: 'app-book-list', + templateUrl: './book-list.component.html', + styleUrls: ['./book-list.component.scss'], + providers: [{ provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }], +}) +export class BookListComponent implements OnInit { + @Select(BooksState.getBooks) + books$: Observable; + + booksType = BookType; + + bookTypeArr = Object.keys(BookType).filter( + (bookType) => typeof this.booksType[bookType] === 'number' + ); + + loading = false; + + isModalOpen = false; + + form: FormGroup; + + selectedBook = {} as BookDto; // <== declared selectedBook ==> + + constructor(private store: Store, private fb: FormBuilder, private bookService: BookService) {} //<== injected BookService ==> + + ngOnInit() { + this.get(); + } + + get() { + this.loading = true; + this.store + .dispatch(new GetBooks()) + .pipe(finalize(() => (this.loading = false))) + .subscribe(() => {}); + } + + // <== this method is replaced ==> + createBook() { + this.selectedBook = {} as BookDto; // <== added ==> + this.buildForm(); + this.isModalOpen = true; + } + + // <== added editBook method ==> + editBook(id: string) { + this.bookService.getById(id).subscribe((book) => { + this.selectedBook = book; + this.buildForm(); + this.isModalOpen = true; + }); + } + + // <== this method is replaced ==> + buildForm() { + this.form = this.fb.group({ + name: [this.selectedBook.name || '', Validators.required], + type: [this.selectedBook.type || null, Validators.required], + publishDate: [ + this.selectedBook.publishDate ? new Date(this.selectedBook.publishDate) : null, + Validators.required, + ], + price: [this.selectedBook.price || null, Validators.required], + }); + } + + save() { + if (this.form.invalid) { + return; + } + + //<== added this.selectedBook.id ==> + this.store + .dispatch(new CreateUpdateBook(this.form.value, this.selectedBook.id)) + .subscribe(() => { + this.isModalOpen = false; + this.form.reset(); + this.get(); + }); + } +} +``` + +* 我们导入了 `BookService`. +* 我们声明了类型为 `BookDto` 的 `selectedBook` 变量. +* 我们在构造函数注入了 `BookService`, 它用于检索正在编辑的图书数据. +* 我们添加了 `editBook` 方法, 根据给定图书 `Id` 设置 `selectedBook` 对象. +* 我们替换了 `buildForm` 方法使用 `selectedBook` 数据创建表单. +* 我们替换了 `createBook` 方法,设置 `selectedBook` 为空对象. +* 我们在 `CreateUpdateBook` 构造函数添加了 `selectedBook.id`. + +#### 添加 "Actions" 下拉框到表格 + +打开 `app\books\book-list` 文件夹下的 `book-list.component.html` 文件,使用以下内容替换 `
` 标签: + +```html +
+ + + + + {%{{{ "::Actions" | abpLocalization }}}%} + {%{{{ "::Name" | abpLocalization }}}%} + {%{{{ "::Type" | abpLocalization }}}%} + {%{{{ "::PublishDate" | abpLocalization }}}%} + {%{{{ "::Price" | abpLocalization }}}%} + + + + + +
+ +
+ +
+
+ + {%{{{ data.name }}}%} + {%{{{ booksType[data.type] }}}%} + {%{{{ data.publishDate | date }}}%} + {%{{{ data.price }}}%} + +
+
+``` + +- 我们添加了 "Actions" 栏的 `th`. +- 我们添加了带有 `ngbDropdownToggle` 的 `button`,在点击按钮时打开操作. +- 我们习惯于将[NgbDropdown](https://ng-bootstrap.github.io/#/components/dropdown/examples)用于操作的下拉菜单. + +UI最终看起来像这样: + +![Action buttons](./images/bookstore-actions-buttons.png) + +打开 `app\books\book-list` 文件夹下的 `book-list.component.html` 文件,使用以下内容替换 `` 标签: + +```html + +

{%{{{ (selectedBook.id ? 'AbpIdentity::Edit' : '::NewBook' ) | abpLocalization }}}%}

+
+``` + +* **Edit** 文本做为编辑记录操作的标题, **New Book** 做为添加记录操作的标题. + +### 删除图书 + +#### DeleteBook 动作 + +打开 `books\state` 文件夹下的 `books.actions.ts` 文件添加名为 `DeleteBook` 的动作. + +```js +export class DeleteBook { + static readonly type = '[Books] Delete'; + constructor(public id: string) {} +} +``` + +打开 `books\state` 文件夹下的 `books.state.ts` 文件,使用以下内容替换它: + +```js +import { PagedResultDto } from '@abp/ng.core'; +import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { GetBooks, CreateUpdateBook, DeleteBook } from './books.actions'; // <== added DeleteBook==> +import { BookService } from '../../app/shared/services'; +import { tap } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { BookDto } from '../../app/shared/models'; + +export class BooksStateModel { + public book: PagedResultDto; +} + +@State({ + name: 'BooksState', + defaults: { book: {} } as BooksStateModel, +}) +@Injectable() +export class BooksState { + @Selector() + static getBooks(state: BooksStateModel) { + return state.book.items || []; + } + + constructor(private bookService: BookService) {} + + @Action(GetBooks) + get(ctx: StateContext) { + return this.bookService.getListByInput().pipe( + tap((booksResponse) => { + ctx.patchState({ + book: booksResponse, + }); + }) + ); + } + + @Action(CreateUpdateBook) + save(ctx: StateContext, action: CreateUpdateBook) { + if (action.id) { + return this.bookService.updateByIdAndInput(action.payload, action.id); + } else { + return this.bookService.createByInput(action.payload); + } + } + + // <== added DeleteBook action listener ==> + @Action(DeleteBook) + delete(ctx: StateContext, action: DeleteBook) { + return this.bookService.deleteById(action.id); + } +} +``` + +- 我们导入了 `DeleteBook` . + +- 我们在文件末尾添加了 `DeleteBook` 动作监听器. + +#### 删除确认弹层 + +打开 `app\books\book-list` 文件夹下的 `book-list.component.ts` 文件,注入 `ConfirmationService`. + +替换构造函数: + +```js +import { ConfirmationService } from '@abp/ng.theme.shared'; +//... + +constructor( + private store: Store, + private fb: FormBuilder, + private bookService: BookService, + private confirmation: ConfirmationService // <== added this line ==> +) { } +``` + +* 我们导入了 `ConfirmationService`. +* 我们在构造函数注入了 `ConfirmationService` . + +参阅[确认弹层文档](https://docs.abp.io/en/abp/latest/UI/Angular/Confirmation-Service)了解更多 + +在 `book-list.component.ts` 中添加删除方法: + +```js +import { GetBooks, CreateUpdateBook, DeleteBook } from '../state/books.actions' ;// <== imported DeleteBook ==> + +import { ConfirmationService, Confirmation } from '@abp/ng.theme.shared'; //<== imported Confirmation ==> + +//... + +delete(id: string) { + this.confirmation + .warn('::AreYouSureToDelete', 'AbpAccount::AreYouSure') + .subscribe(status => { + if (status === Confirmation.Status.confirm) { + this.store.dispatch(new DeleteBook(id)).subscribe(() => this.get()); + } + }); +} +``` + +`delete` 方法会显示一个确认弹层并订阅用户响应. 只在用户点击 `Yes` 按钮时分派动作. 确认弹层看起来如下: + +![bookstore-confirmation-popup](./images/bookstore-confirmation-popup.png) + +#### 添加删除按钮 + +打开 `app\books\book-list` 文件夹下的 `app\books\book-list` 文件,修改 `ngbDropdownMenu` 添加删除按钮: + +```html +
+ + +
+``` + +最终操作下拉框UI看起来如下: + +![bookstore-final-actions-dropdown](./images/bookstore-final-actions-dropdown.png) + +{{end}} + +### 下一章 + +查看本教程的 [下一章](Part-3.md) . diff --git a/docs/zh-Hans/Tutorials/Part-3.md b/docs/zh-Hans/Tutorials/Part-3.md index d12e28290b..cded954e6b 100644 --- a/docs/zh-Hans/Tutorials/Part-3.md +++ b/docs/zh-Hans/Tutorials/Part-3.md @@ -1 +1,198 @@ -TODO .... \ No newline at end of file +## ASP.NET Core {{UI_Value}} 教程 - 第三章 +````json +//[doc-params] +{ + "UI": ["MVC","NG"] +} +```` + +{{ +if UI == "MVC" + DB="ef" + DB_Text="Entity Framework Core" + UI_Text="mvc" +else if UI == "NG" + DB="mongodb" + DB_Text="MongoDB" + UI_Text="angular" +else + DB ="?" + UI_Text="?" +end +}} + +### 关于本教程 + +这是ASP.NET Core{{UI_Value}}系列教程的第二章. 共有三章: + +- [Part-1: 创建项目和书籍列表页面](Part-1.md) +- [Part 2: 创建,编辑,删除书籍](Part-2.md) +- **Part-3: 集成测试(本章)** + +> 你也可以观看由ABP社区成员为本教程录制的[视频课程](https://amazingsolutions.teachable.com/p/lets-build-the-bookstore-application). + +### 解决方案中的测试项目 + +解决方案中有多个测试项目: + +![bookstore-test-projects-v2](./images/bookstore-test-projects-{{UI_Text}}.png) + +每个项目用于测试相关的应用程序项目.测试项目使用以下库进行测试: + +* [xunit](https://xunit.github.io/) 作为主测试框架. +* [Shoudly](http://shouldly.readthedocs.io/en/latest/) 作为断言库. +* [NSubstitute](http://nsubstitute.github.io/) 作为模拟库. + +### 添加测试数据 + +启动模板包含`Acme.BookStore.TestBase`项目中的`BookStoreTestDataSeedContributor`类,它创建一些数据来运行测试. +更改`BookStoreTestDataSeedContributor`类如下所示: + +````csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Guids; + +namespace Acme.BookStore +{ + public class BookStoreTestDataSeedContributor + : IDataSeedContributor, ITransientDependency + { + private readonly IRepository _bookRepository; + private readonly IGuidGenerator _guidGenerator; + + public BookStoreTestDataSeedContributor( + IRepository bookRepository, + IGuidGenerator guidGenerator) + { + _bookRepository = bookRepository; + _guidGenerator = guidGenerator; + } + + public async Task SeedAsync(DataSeedContext context) + { + await _bookRepository.InsertAsync( + new Book(id: _guidGenerator.Create(), + name: "Test book 1", + type: BookType.Fantastic, + publishDate: new DateTime(2015, 05, 24), + price: 21 + ) + ); + + await _bookRepository.InsertAsync( + new Book(id: _guidGenerator.Create(), + name: "Test book 2", + type: BookType.Science, + publishDate: new DateTime(2014, 02, 11), + price: 15 + ) + ); + } + } +} +```` + +* 注入`IRepository`并在`SeedAsync`中使用它来创建两个书实体作为测试数据. +* 使用`IGuidGenerator`服务创建GUID. 虽然`Guid.NewGuid()`非常适合测试,但`IGuidGenerator`在使用真实数据库时还有其他特别重要的功能(参见[Guid生成文档](../Guid-Generation.md)了解更多信息). + +### 测试 BookAppService + +在 `Acme.BookStore.Application.Tests` 项目中创建一个名叫 `BookAppService_Tests` 的测试类: + +````csharp +using System; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Shouldly; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Validation; +using Microsoft.EntityFrameworkCore.Internal; + +namespace Acme.BookStore +{ + public class BookAppService_Tests : BookStoreApplicationTestBase + { + private readonly IBookAppService _bookAppService; + + public BookAppService_Tests() + { + _bookAppService = GetRequiredService(); + } + + [Fact] + public async Task Should_Get_List_Of_Books() + { + //Act + var result = await _bookAppService.GetListAsync( + new PagedAndSortedResultRequestDto() + ); + + //Assert + result.TotalCount.ShouldBeGreaterThan(0); + result.Items.ShouldContain(b => b.Name == "Test book 1"); + } + } +} +```` + +* 测试方法 `Should_Get_List_Of_Books` 直接使用 `BookAppService.GetListAsync` 方法来获取用户列表,并执行检查. + +新增测试方法,用以测试创建一个合法book实体的场景: + +````C# +[Fact] +public async Task Should_Create_A_Valid_Book() +{ + //Act + var result = await _bookAppService.CreateAsync( + new CreateUpdateBookDto + { + Name = "New test book 42", + Price = 10, + PublishDate = DateTime.Now, + Type = BookType.ScienceFiction + } + ); + + //Assert + result.Id.ShouldNotBe(Guid.Empty); + result.Name.ShouldBe("New test book 42"); +} +```` + +新增测试方法,用以测试创建一个非法book实体失败的场景: + +````csharp +[Fact] +public async Task Should_Not_Create_A_Book_Without_Name() +{ + var exception = await Assert.ThrowsAsync(async () => + { + await _bookAppService.CreateAsync( + new CreateUpdateBookDto + { + Name = "", + Price = 10, + PublishDate = DateTime.Now, + Type = BookType.ScienceFiction + } + ); + }); + + exception.ValidationErrors + .ShouldContain(err => err.MemberNames.Any(mem => mem == "Name")); +} +```` + +* 由于 `Name` 是空值, ABP 抛出一个 `AbpValidationException` 异常. + +打开**测试资源管理器**(测试 -> Windows -> 测试资源管理器)并**执行**所有测试: + +![bookstore-appservice-tests](./images/bookstore-appservice-tests.png) + +恭喜, 绿色图标表示测试已成功通过! diff --git a/docs/zh-Hans/UI/Angular/Config-State.md b/docs/zh-Hans/UI/Angular/Config-State.md index 4967dd78b3..de645c8968 100644 --- a/docs/zh-Hans/UI/Angular/Config-State.md +++ b/docs/zh-Hans/UI/Angular/Config-State.md @@ -236,7 +236,7 @@ const newRoute: ABP.Route = { path: "page", invisible: false, order: 2, - requiredPolicy: "MyProjectName::MyNewPage" + requiredPolicy: "MyProjectName.MyNewPage" }; this.config.dispatchAddRoute(newRoute); @@ -248,16 +248,17 @@ this.config.dispatchAddRoute(newRoute); 如果你想要**添加一个子路由,你可以这样做:** ```js +import { eIdentityRouteNames } from '@abp/ng.identity'; // this.config is instance of ConfigStateService const newRoute: ABP.Route = { - parentName: "AbpAccount::Login", + parentName: eIdentityRouteNames.IdentityManagement, name: "My New Page", iconClass: "fa fa-dashboard", path: "page", invisible: false, order: 2, - requiredPolicy: "MyProjectName::MyNewPage" + requiredPolicy: "MyProjectName.MyNewPage" }; this.config.dispatchAddRoute(newRoute); @@ -291,4 +292,4 @@ this.config.dispatchSetEnvironment({ ## 下一步是什么? -* [组件替换](./Component-Replacement.md) \ No newline at end of file +- [修改菜单](./Modifying-the-Menu.md) \ No newline at end of file diff --git a/docs/zh-Hans/UI/Angular/Modifying-the-Menu.md b/docs/zh-Hans/UI/Angular/Modifying-the-Menu.md new file mode 100644 index 0000000000..ec3297ea78 --- /dev/null +++ b/docs/zh-Hans/UI/Angular/Modifying-the-Menu.md @@ -0,0 +1,197 @@ +# 修改菜单 + + +菜单在 @abp/ng.theme.basic包 `ApplicationLayoutComponent` 内部. 有几种修改菜单的方法,本文档介绍了这些方法. 如果你想完全替换菜单,请参考[组件替换文档]了解如何替换布局. + + + +## 如何添加Logo + +环境变量中的 `logoUrl` 是logo的url. + +你可以在 `src/assets` 文件夹下添加logo并设置 `logoUrl`: + +```js +export const environment = { + // other configurations + application: { + name: 'MyProjectName', + logoUrl: 'assets/logo.png', + }, + // other configurations +}; +``` + +## 如何添加导航元素 + +### 通过 AppRoutingModule 中的 `routes` 属性 + +你可以通过在 `app-routing.module` 中将路由作为子属性添加到路由配置的 `data` 属性来定义路由. `@abp/ng.core` 包组织路由并将其存储在 `ConfigState` 中.`ApplicationLayoutComponent` 从存储中获取路由显示在菜单上. + +你可以像以下一样添加 `routes` 属性: + +```js +{ + path: 'your-path', + data: { + routes: { + name: 'Your navigation', + order: 3, + iconClass: 'fas fa-question-circle', + requiredPolicy: 'permission key here', + children: [ + { + path: 'child', + name: 'Your child navigation', + order: 1, + requiredPolicy: 'permission key here', + }, + ], + } as ABP.Route, // can be imported from @abp/ng.core + } +} +``` + +- `name` 是导航元素的标签,可以传递本地化密钥或本地化对象. +- `order` 排序导航元素. +- `iconClass` 是 `i` 标签的类,在导航标签的左侧. +- `requiredPolicy` 是访问页面所需的权限key. 参阅 [权限管理文档](./Permission-Management.md) +- `children` is an array and is used for declaring child navigation elements. The child navigation element will be placed as a child route which will be available at `'/your-path/child'` based on the given `path` property. +- `children` 是一个数组,用于声明子菜单,它基于给定的 `path` 属性,路径是在`/your-path/child`. + +添加了上面描述的route属性后,导航菜单如下图所示: + +![navigation-menu-via-app-routing](./images/navigation-menu-via-app-routing.png) + +## 通过 ConfigState + +`ConfigStateService` 的 `dispatchAddRoute` 方法可以向菜单添加一个新的导航元素. + +```js +// this.config is instance of ConfigStateService + +const newRoute: ABP.Route = { + name: 'My New Page', + iconClass: 'fa fa-dashboard', + path: 'page', + invisible: false, + order: 2, + requiredPolicy: 'MyProjectName.MyNewPage', +} as Omit; + +this.config.dispatchAddRoute(newRoute); +// returns a state stream which emits after dispatch action is complete +``` + +`newRoute` 放在根级别,没有任何父路由,url将为`/path`. + +如果你想 **添加子路由, 你可以这样做:** + +```js +// this.config is instance of ConfigStateService +// eIdentityRouteNames enum can be imported from @abp/ng.identity + +const newRoute: ABP.Route = { + parentName: eIdentityRouteNames.IdentityManagement, + name: 'My New Page', + iconClass: 'fa fa-dashboard', + path: 'page', + invisible: false, + order: 3, + requiredPolicy: 'MyProjectName.MyNewPage' +} as Omit; + +this.config.dispatchAddRoute(newRoute); +// returns a state stream which emits after dispatch action is complete +``` + +`newRoute` 做为 `eIdentityRouteNames.IdentityManagement` 的子路由添加, url 设置为 `'/identity/page'`. + +新路由看起来像这样: + +![navigation-menu-via-config-state](./images/navigation-menu-via-config-state.png) + +## 如何修改一个导航元素 + +`DispatchPatchRouteByName` 方法通过名称查找路由,并使用二个参数传递的新配置替换存储中的配置. + +```js +// this.config is instance of ConfigStateService +// eIdentityRouteNames enum can be imported from @abp/ng.identity + +const newRouteConfig: Partial = { + iconClass: 'fas fa-home', + parentName: eIdentityRouteNames.Administration, + order: 0, + children: [ + { + name: 'Dashboard', + path: 'dashboard', + }, + ], +}; + +this.config.dispatchPatchRouteByName('::Menu:Home', newRouteConfig); +// returns a state stream which emits after dispatch action is complete +``` + +* 根据给定的 `parentName` 将 _Home_ 导航移动到 _Administration_ 下拉框下. +* 添加了 icon. +* 指定了顺序. +* 添加了名为 _Dashboard_ 的子路由. + +修改后,导航元素看起来像这样: + +![navigation-menu-after-patching](./images/navigation-menu-after-patching.png) + +## 如何在菜单的右侧添加元素 + +右侧的元素存储在 @abp/ng.theme.basic 包的 `LayoutState` 中. + +`LayoutStateService` 的 `dispatchAddNavigationElement` 方法添加元素到右侧的菜单. + +你可以通过将模板添加到 `app.component` 调用 `dispatchAddNavigationElement` 方法来插入元素: + +```js +import { Layout, LayoutStateService } from '@abp/ng.theme.basic'; // added this line + +@Component({ + selector: 'app-root', + template: ` + + + + `, +}) +export class AppComponent { + // Added ViewChild + @ViewChild('search', { static: false, read: TemplateRef }) searchElementRef: TemplateRef; + + constructor(private layout: LayoutStateService) {} // injected LayoutStateService + + // Added ngAfterViewInit + ngAfterViewInit() { + const newElement = { + name: 'Search', + element: this.searchElementRef, + order: 1, + } as Layout.NavigationElement; + + this.layout.dispatchAddNavigationElement(newElement); + } +} +``` + +上面我们在菜单添加了一个搜索输入,最终UI如下:s + +![navigation-menu-search-input](./images/navigation-menu-search-input.png) + +## 如何删除右侧菜单元素 + +TODO + +## 下一步是什么? + +* [组件替换](./Component-Replacement.md) diff --git a/docs/zh-Hans/UI/Angular/images/navigation-menu-after-patching.png b/docs/zh-Hans/UI/Angular/images/navigation-menu-after-patching.png new file mode 100644 index 0000000000..2f6bf08c88 Binary files /dev/null and b/docs/zh-Hans/UI/Angular/images/navigation-menu-after-patching.png differ diff --git a/docs/zh-Hans/UI/Angular/images/navigation-menu-search-input.png b/docs/zh-Hans/UI/Angular/images/navigation-menu-search-input.png new file mode 100644 index 0000000000..ebdc05e3e0 Binary files /dev/null and b/docs/zh-Hans/UI/Angular/images/navigation-menu-search-input.png differ diff --git a/docs/zh-Hans/UI/Angular/images/navigation-menu-via-app-routing.png b/docs/zh-Hans/UI/Angular/images/navigation-menu-via-app-routing.png new file mode 100644 index 0000000000..4d5f61301c Binary files /dev/null and b/docs/zh-Hans/UI/Angular/images/navigation-menu-via-app-routing.png differ diff --git a/docs/zh-Hans/UI/Angular/images/navigation-menu-via-config-state.png b/docs/zh-Hans/UI/Angular/images/navigation-menu-via-config-state.png new file mode 100644 index 0000000000..19944f154d Binary files /dev/null and b/docs/zh-Hans/UI/Angular/images/navigation-menu-via-config-state.png differ diff --git a/docs/zh-Hans/docs-nav.json b/docs/zh-Hans/docs-nav.json index 984fc0270d..c70284637d 100644 --- a/docs/zh-Hans/docs-nav.json +++ b/docs/zh-Hans/docs-nav.json @@ -333,6 +333,10 @@ "text": "配置状态", "path": "UI/Angular/Config-State.md" }, + { + "text": "修改菜单", + "path": "UI/Angular/Modifying-the-Menu.md" + }, { "text": "替换组件", "path": "UI/Angular/Component-Replacement.md" diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Http/CliHttpClient.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Http/CliHttpClient.cs index ca1a23fcfc..bd3037d830 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Http/CliHttpClient.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Http/CliHttpClient.cs @@ -1,9 +1,15 @@ using System; +using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Text; +using System.Threading; +using System.Threading.Tasks; using IdentityModel.Client; +using Polly; +using Polly.Extensions.Http; using Volo.Abp.Cli.Auth; +using Microsoft.Extensions.Logging; namespace Volo.Abp.Cli.Http { @@ -41,5 +47,55 @@ namespace Volo.Abp.Cli.Http client.SetBearerToken(accessToken); } } + + public async Task GetHttpResponseMessageWithRetryAsync + ( + string url, + CancellationToken? cancellationToken = null, + ILogger logger = null, + IEnumerable sleepDurations = null + ) + { + if (sleepDurations == null) + { + sleepDurations = new[] + { + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(4), + TimeSpan.FromSeconds(7) + }; + } + + if (!cancellationToken.HasValue) + { + cancellationToken = CancellationToken.None; + } + + return await HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => !msg.IsSuccessStatusCode) + .WaitAndRetryAsync(sleepDurations, + (responseMessage, timeSpan, retryCount, context) => + { + if (responseMessage.Exception != null) + { + string httpErrorCode = responseMessage.Result == null ? + httpErrorCode = string.Empty : + "HTTP-" + (int)responseMessage.Result.StatusCode + ", "; + + logger?.LogWarning( + $"{retryCount}. HTTP request attempt failed to {url} with an error: {httpErrorCode}{responseMessage.Exception.Message}. " + + $"Waiting {timeSpan.TotalSeconds} secs for the next try..."); + } + else if (responseMessage.Result != null) + { + logger?.LogWarning( + $"{retryCount}. HTTP request attempt failed to {url} with an error: {(int)responseMessage.Result.StatusCode}-{responseMessage.Result.ReasonPhrase}. " + + $"Waiting {timeSpan.TotalSeconds} secs for the next try..."); + } + }) + .ExecuteAsync(async () => await this.GetAsync(url, cancellationToken.Value)); + } + } } \ No newline at end of file diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Licensing/AbpIoApiKeyService.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Licensing/AbpIoApiKeyService.cs index cddead548f..33a2e10fdf 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Licensing/AbpIoApiKeyService.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Licensing/AbpIoApiKeyService.cs @@ -13,6 +13,7 @@ using Volo.Abp.Cli.Http; using Volo.Abp.Cli.ProjectBuilding; using Volo.Abp.DependencyInjection; using Volo.Abp.Json; +using Volo.Abp.Threading; namespace Volo.Abp.Cli.Licensing { @@ -20,14 +21,21 @@ namespace Volo.Abp.Cli.Licensing { protected IJsonSerializer JsonSerializer { get; } protected IRemoteServiceExceptionHandler RemoteServiceExceptionHandler { get; } + protected ICancellationTokenProvider CancellationTokenProvider { get; } + private readonly ILogger _logger; private DeveloperApiKeyResult _apiKeyResult = null; - public AbpIoApiKeyService(IJsonSerializer jsonSerializer, IRemoteServiceExceptionHandler remoteServiceExceptionHandler, ILogger logger) + public AbpIoApiKeyService( + IJsonSerializer jsonSerializer, + ICancellationTokenProvider cancellationTokenProvider, + IRemoteServiceExceptionHandler remoteServiceExceptionHandler, + ILogger logger) { JsonSerializer = jsonSerializer; RemoteServiceExceptionHandler = remoteServiceExceptionHandler; _logger = logger; + CancellationTokenProvider = cancellationTokenProvider; } public async Task GetApiKeyOrNullAsync(bool invalidateCache = false) @@ -51,31 +59,10 @@ namespace Volo.Abp.Cli.Licensing using (var client = new CliHttpClient()) { - var response = await HttpPolicyExtensions - .HandleTransientHttpError() - .OrResult(msg => !msg.IsSuccessStatusCode) - .WaitAndRetryAsync(new[] - { - TimeSpan.FromSeconds(1), - TimeSpan.FromSeconds(3), - TimeSpan.FromSeconds(7) - }, - (responseMessage, timeSpan, retryCount, context) => - { - if (responseMessage.Exception != null) - { - _logger.LogWarning( - $"{retryCount}. request attempt failed to {url} with an error: \"{responseMessage.Exception.Message}\". " + - $"Waiting {timeSpan.TotalSeconds} secs for the next try..."); - } - else if (responseMessage.Result != null) - { - _logger.LogWarning( - $"{retryCount}. request attempt failed {url} with {(int)responseMessage.Result.StatusCode}-{responseMessage.Result.ReasonPhrase}. " + - $"Waiting {timeSpan.TotalSeconds} secs for the next try..."); - } - }) - .ExecuteAsync(async () => await client.GetAsync(url)); + var response = await client.GetHttpResponseMessageWithRetryAsync( + url: url, + cancellationToken: CancellationTokenProvider.Token, + logger: _logger); if (!response.IsSuccessStatusCode) { diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/NuGet/NuGetService.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/NuGet/NuGetService.cs index c0eb6fd209..0fd35582bf 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/NuGet/NuGetService.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/NuGet/NuGetService.cs @@ -1,16 +1,10 @@ using Newtonsoft.Json; using NuGet.Versioning; -using System; using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Net; -using System.Net.Http; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Polly; -using Polly.Extensions.Http; using Volo.Abp.Cli.Auth; using Volo.Abp.Cli.Http; using Volo.Abp.Cli.Licensing; @@ -29,6 +23,8 @@ namespace Volo.Abp.Cli.NuGet protected ICancellationTokenProvider CancellationTokenProvider { get; } protected IRemoteServiceExceptionHandler RemoteServiceExceptionHandler { get; } private readonly IApiKeyService _apiKeyService; + private List _proPackageList; + private DeveloperApiKeyResult _apiKeyResult; public NuGetService( IJsonSerializer jsonSerializer, @@ -45,20 +41,20 @@ namespace Volo.Abp.Cli.NuGet public async Task GetLatestVersionOrNullAsync(string packageId, bool includePreviews = false, bool includeNightly = false) { - List proPackageList = null; - if (AuthService.IsLoggedIn()) { - proPackageList = await GetProPackageListAsync(); + if (_proPackageList == null) + { + _proPackageList = await GetProPackageListAsync(); + } } string url; if (includeNightly) { - url = - $"https://www.myget.org/F/abp-nightly/api/v3/flatcontainer/{packageId.ToLowerInvariant()}/index.json"; + url = $"https://www.myget.org/F/abp-nightly/api/v3/flatcontainer/{packageId.ToLowerInvariant()}/index.json"; } - else if (proPackageList?.Contains(packageId) ?? false) + else if (_proPackageList?.Contains(packageId) ?? false) { url = await GetNuGetUrlForCommercialPackage(packageId); } @@ -67,15 +63,13 @@ namespace Volo.Abp.Cli.NuGet url = $"https://api.nuget.org/v3-flatcontainer/{packageId.ToLowerInvariant()}/index.json"; } - using (var client = new CliHttpClient(setBearerToken: false)) { - var responseMessage = await GetHttpResponseMessageWithRetryAsync(client, url); - - if (!responseMessage.IsSuccessStatusCode) - { - throw new Exception($"ERROR: Remote server returns '{responseMessage.StatusCode}'"); - } + var responseMessage = await client.GetHttpResponseMessageWithRetryAsync( + url, + cancellationToken: CancellationTokenProvider.Token, + logger: Logger + ); await RemoteServiceExceptionHandler.EnsureSuccessfulHttpResponseAsync(responseMessage); @@ -98,62 +92,41 @@ namespace Volo.Abp.Cli.NuGet private async Task GetNuGetUrlForCommercialPackage(string packageId) { - var apiKeyResult = await _apiKeyService.GetApiKeyOrNullAsync(); - return CliUrls.GetNuGetPackageInfoUrl(apiKeyResult.ApiKey, packageId); - } + if (_apiKeyResult == null) + { + _apiKeyResult = await _apiKeyService.GetApiKeyOrNullAsync(); + } - private async Task GetHttpResponseMessageWithRetryAsync(HttpClient client, string url) - { - return await HttpPolicyExtensions - .HandleTransientHttpError() - .OrResult(msg => !msg.IsSuccessStatusCode) - .WaitAndRetryAsync(new[] - { - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(4), - TimeSpan.FromSeconds(7) - }, - (responseMessage, timeSpan, retryCount, context) => - { - if (responseMessage.Exception != null) - { - Logger.LogWarning( - $"{retryCount}. HTTP request attempt failed to {url} with an error: HTTP {(int)responseMessage.Result.StatusCode}-{responseMessage.Exception.Message}. " + - $"Waiting {timeSpan.TotalSeconds} secs for the next try..."); - } - else if (responseMessage.Result != null) - { - Logger.LogWarning( - $"{retryCount}. HTTP request attempt failed to {url} with an error: {(int)responseMessage.Result.StatusCode}-{responseMessage.Result.ReasonPhrase}. " + - $"Waiting {timeSpan.TotalSeconds} secs for the next try..."); - } - }) - .ExecuteAsync(async () => await client.GetAsync(url, CancellationTokenProvider.Token)); + return CliUrls.GetNuGetPackageInfoUrl(_apiKeyResult.ApiKey, packageId); } private async Task> GetProPackageListAsync() { using var client = new CliHttpClient(); - var responseMessage = await client.GetAsync( - $"{CliUrls.WwwAbpIo}api/app/nugetPackage/proPackageNames", - CancellationTokenProvider.Token + var url = $"{CliUrls.WwwAbpIo}api/app/nugetPackage/proPackageNames"; + + var responseMessage = await client.GetHttpResponseMessageWithRetryAsync( + url: url, + cancellationToken: CancellationTokenProvider.Token, + logger: Logger ); - if (!responseMessage.IsSuccessStatusCode) + if (responseMessage.IsSuccessStatusCode) { - var exceptionMessage = "Remote server returns '" + (int)responseMessage.StatusCode + "-" + responseMessage.ReasonPhrase + "'. "; - var remoteServiceErrorMessage = await RemoteServiceExceptionHandler.GetAbpRemoteServiceErrorAsync(responseMessage); + return JsonSerializer.Deserialize>(await responseMessage.Content.ReadAsStringAsync()); + } - if (remoteServiceErrorMessage != null) - { - exceptionMessage += remoteServiceErrorMessage; - } - Logger.LogInformation(exceptionMessage); - return null; + var exceptionMessage = "Remote server returns '" + (int)responseMessage.StatusCode + "-" + responseMessage.ReasonPhrase + "'. "; + var remoteServiceErrorMessage = await RemoteServiceExceptionHandler.GetAbpRemoteServiceErrorAsync(responseMessage); + + if (remoteServiceErrorMessage != null) + { + exceptionMessage += remoteServiceErrorMessage; } - return JsonSerializer.Deserialize>(await responseMessage.Content.ReadAsStringAsync()); + Logger.LogError(exceptionMessage); + return null; } public class NuGetVersionResultDto diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/MyGetPackageListFinder.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/MyGetPackageListFinder.cs index 41907276f4..75e9156ce0 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/MyGetPackageListFinder.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/MyGetPackageListFinder.cs @@ -20,7 +20,7 @@ namespace Volo.Abp.Cli.ProjectModification Logger = NullLogger.Instance; } - public async Task GetPackages() + public async Task GetPackagesAsync() { if (_response != null) { diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/NpmPackagesUpdater.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/NpmPackagesUpdater.cs index a5a2e506cc..1a279fafc7 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/NpmPackagesUpdater.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/NpmPackagesUpdater.cs @@ -13,25 +13,31 @@ using Volo.Abp.Cli.Http; using Volo.Abp.Cli.Utils; using Volo.Abp.DependencyInjection; using Volo.Abp.IO; +using Volo.Abp.Threading; namespace Volo.Abp.Cli.ProjectModification { public class NpmPackagesUpdater : ITransientDependency { public ILogger Logger { get; set; } + protected ICancellationTokenProvider CancellationTokenProvider { get; } private readonly PackageJsonFileFinder _packageJsonFileFinder; private readonly NpmGlobalPackagesChecker _npmGlobalPackagesChecker; private readonly MyGetPackageListFinder _myGetPackageListFinder; - private readonly Dictionary _fileVersionStorage = new Dictionary(); + private MyGetApiResponse _myGetApiResponse; - public NpmPackagesUpdater(PackageJsonFileFinder packageJsonFileFinder, NpmGlobalPackagesChecker npmGlobalPackagesChecker, MyGetPackageListFinder myGetPackageListFinder) + public NpmPackagesUpdater( + PackageJsonFileFinder packageJsonFileFinder, + NpmGlobalPackagesChecker npmGlobalPackagesChecker, + MyGetPackageListFinder myGetPackageListFinder, + ICancellationTokenProvider cancellationTokenProvider) { _packageJsonFileFinder = packageJsonFileFinder; _npmGlobalPackagesChecker = npmGlobalPackagesChecker; _myGetPackageListFinder = myGetPackageListFinder; - + CancellationTokenProvider = cancellationTokenProvider; Logger = NullLogger.Instance; } @@ -77,7 +83,7 @@ namespace Volo.Abp.Cli.ProjectModification } } - private async Task DeleteNpmrcFileAsync(string directoryName) + private static async Task DeleteNpmrcFileAsync(string directoryName) { FileHelper.DeleteIfExists(Path.Combine(directoryName, ".npmrc")); @@ -135,11 +141,13 @@ namespace Volo.Abp.Cli.ProjectModification { using (var client = new CliHttpClient(TimeSpan.FromMinutes(1))) { - var responseMessage = await client.GetAsync( - $"{CliUrls.WwwAbpIo}api/myget/apikey/" + var response = await client.GetHttpResponseMessageWithRetryAsync( + url: $"{CliUrls.WwwAbpIo}api/myget/apikey/", + cancellationToken: CancellationTokenProvider.Token, + logger: Logger ); - return Encoding.Default.GetString(await responseMessage.Content.ReadAsByteArrayAsync()); + return Encoding.Default.GetString(await response.Content.ReadAsByteArrayAsync()); } } catch (Exception) @@ -148,26 +156,26 @@ namespace Volo.Abp.Cli.ProjectModification } } - private bool IsAngularProject(string fileDirectory) + private static bool IsAngularProject(string fileDirectory) { return File.Exists(Path.Combine(fileDirectory, "angular.json")); } - protected virtual async Task UpdatePackagesInFile(string file, bool includePreviews = false, bool switchToStable = false) + protected virtual async Task UpdatePackagesInFile(string filePath, bool includePreviews = false, bool switchToStable = false) { var packagesUpdated = false; - var fileContent = File.ReadAllText(file); + var fileContent = File.ReadAllText(filePath); var packageJson = JObject.Parse(fileContent); var abpPackages = GetAbpPackagesFromPackageJson(packageJson); if (!abpPackages.Any()) { - return packagesUpdated; + return false; } foreach (var abpPackage in abpPackages) { - var updated = await TryUpdatePackage(file, abpPackage, includePreviews, switchToStable); + var updated = await TryUpdatingPackage(filePath, abpPackage, includePreviews, switchToStable); if (updated) { @@ -175,15 +183,18 @@ namespace Volo.Abp.Cli.ProjectModification } } - var modifiedFileContent = packageJson.ToString(Formatting.Indented); + var updatedContent = packageJson.ToString(Formatting.Indented); - File.WriteAllText(file, modifiedFileContent); + File.WriteAllText(filePath, updatedContent); return packagesUpdated; } - protected virtual async Task TryUpdatePackage(string file, JProperty package, - bool includePreviews = false, bool switchToStable = false) + protected virtual async Task TryUpdatingPackage( + string filePath, + JProperty package, + bool includePreviews = false, + bool switchToStable = false) { var currentVersion = (string)package.Value; @@ -198,23 +209,31 @@ namespace Volo.Abp.Cli.ProjectModification package.Value.Replace(versionWithPrefix); - Logger.LogInformation($"Updated {package.Name} to {version} in {file.Replace(Directory.GetCurrentDirectory(), "")}."); + Logger.LogInformation($"Updated {package.Name} to {version} in {filePath.Replace(Directory.GetCurrentDirectory(), "")}."); return true; } - protected virtual async Task GetLatestVersion(JProperty package, string currentVersion, - bool includePreviews = false, bool switchToStable = false) + protected virtual async Task GetLatestVersion( + JProperty package, + string currentVersion, + bool includePreviews = false, + bool switchToStable = false) { if (_fileVersionStorage.ContainsKey(package.Name)) { return _fileVersionStorage[package.Name]; } - string newVersion = currentVersion; + var newVersion = currentVersion; if (includePreviews || (!switchToStable && currentVersion.Contains("-preview"))) { - var mygetPackage = (await _myGetPackageListFinder.GetPackages()).Packages.FirstOrDefault(p => p.Id == package.Name); + if (_myGetApiResponse == null) + { + _myGetApiResponse = await _myGetPackageListFinder.GetPackagesAsync(); + } + + var mygetPackage = _myGetApiResponse.Packages.FirstOrDefault(p => p.Id == package.Name); if (mygetPackage != null) { newVersion = mygetPackage.Versions.Last(); @@ -225,7 +244,6 @@ namespace Volo.Abp.Cli.ProjectModification newVersion = CmdHelper.RunCmdAndGetOutput($"npm show {package.Name} version"); } - _fileVersionStorage[package.Name] = newVersion; return newVersion; diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/VoloNugetPackagesVersionUpdater.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/VoloNugetPackagesVersionUpdater.cs index 587492804b..fb2a386ef9 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/VoloNugetPackagesVersionUpdater.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectModification/VoloNugetPackagesVersionUpdater.cs @@ -42,8 +42,9 @@ namespace Volo.Abp.Cli.ProjectModification protected virtual async Task UpdateInternalAsync(string projectPath, bool includePreviews = false, bool switchToStable = false) { var fileContent = File.ReadAllText(projectPath); + var updatedContent = await UpdateVoloPackagesAsync(fileContent, includePreviews, switchToStable); - File.WriteAllText(projectPath, await UpdateVoloPackagesAsync(fileContent, includePreviews, switchToStable)); + File.WriteAllText(projectPath, updatedContent); } private async Task UpdateVoloPackagesAsync(string content, bool includePreviews = false, bool switchToStable = false) @@ -74,16 +75,13 @@ namespace Volo.Abp.Cli.ProjectModification var versionAttribute = package.Attributes["Version"]; var currentVersion = versionAttribute.Value; - var packageVersion = SemanticVersion.Parse(currentVersion); - - Logger.LogDebug("Checking package: \"{0}\" - Current version: {1}", packageId, packageVersion); + var currentSemanticVersion = SemanticVersion.Parse(currentVersion); + Logger.LogDebug("Checking package: \"{0}\" - Current version: {1}", packageId, currentSemanticVersion); if (includePreviews || (currentVersion.Contains("-preview") && !switchToStable)) { - var latestVersion = (await _myGetPackageListFinder.GetPackages()).Packages - .FirstOrDefault(p => p.Id == packageId) - ?.Versions.LastOrDefault(); + var latestVersion = await GetLatestVersionFromMyGet(packageId); if (currentVersion != latestVersion) { @@ -99,14 +97,14 @@ namespace Volo.Abp.Cli.ProjectModification { var latestVersion = await _nuGetService.GetLatestVersionOrNullAsync(packageId); - if (latestVersion != null && (currentVersion.Contains("-preview") || packageVersion < latestVersion)) + if (latestVersion != null && (currentVersion.Contains("-preview") || currentSemanticVersion < latestVersion)) { - Logger.LogInformation("Updating package \"{0}\" from v{1} to v{2}.", packageId, packageVersion.ToString(), latestVersion.ToString()); + Logger.LogInformation("Updating package \"{0}\" from v{1} to v{2}.", packageId, currentSemanticVersion.ToString(), latestVersion.ToString()); versionAttribute.Value = latestVersion.ToString(); } else { - Logger.LogInformation("Package: \"{0}-v{1}\" is up to date.", packageId, packageVersion); + Logger.LogInformation("Package: \"{0}-v{1}\" is up to date.", packageId, currentSemanticVersion); } } } @@ -116,11 +114,18 @@ namespace Volo.Abp.Cli.ProjectModification } catch (Exception ex) { - Logger.LogError("Cannot update volo packages! An error occured while updating the package \"{0}\". Error: {1}", packageId, ex.Message); + Logger.LogError("Cannot update Volo.* packages! An error occured while updating the package \"{0}\". Error: {1}", packageId, ex.Message); Logger.LogException(ex); } return await Task.FromResult(content); } + + private async Task GetLatestVersionFromMyGet(string packageId) + { + var myGetPack = await _myGetPackageListFinder.GetPackagesAsync(); + + return myGetPack.Packages.FirstOrDefault(p => p.Id == packageId)?.Versions.LastOrDefault(); + } } } diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/cs.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/cs.json index 2be5ff3b15..d202abe1eb 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/cs.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/cs.json @@ -10,7 +10,7 @@ "The field {0} must be a string or array type with a minimum length of '{1}'.": "V poli {0} musí být řežezec nebo řada o minimální délce '{1}'.", "The {0} field is not a valid phone number.": "V poli {0} není platné telefonní číslo.", "The field {0} must be between {1} and {2}.": "Pole {0} musí být mezi {1} a {2}.", - "The field {0} must match the regular expression '{1}'.": "Pole {0} musí odpovídat regulérnímu výrazu '{1}'.", + "The field {0} must match the regular expression '{1}'.": "Pole {0} neodpovídá požadovanému formátu.", "The {0} field is required.": "Pole {0} je povinné.", "The field {0} must be a string with a maximum length of {1}.": "Pole {0} musí být řetězec o maximální délce {1}.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "Pole {0} musí být řetězec o minimální délce {2} a maximální délce {1} znaků.", diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/en.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/en.json index b6ff1bb26c..74c6670e55 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/en.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/en.json @@ -10,7 +10,7 @@ "The field {0} must be a string or array type with a minimum length of '{1}'.": "The field {0} must be a string or array type with a minimum length of '{1}'.", "The {0} field is not a valid phone number.": "The {0} field is not a valid phone number.", "The field {0} must be between {1} and {2}.": "The field {0} must be between {1} and {2}.", - "The field {0} must match the regular expression '{1}'.": "The field {0} must match the regular expression '{1}'.", + "The field {0} must match the regular expression '{1}'.": "The field {0} does not match the requested format.", "The {0} field is required.": "The {0} field is required.", "The field {0} must be a string with a maximum length of {1}.": "The field {0} must be a string with a maximum length of {1}.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.", diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/es.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/es.json index 25ed44d7ad..1615d7db42 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/es.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/es.json @@ -10,7 +10,7 @@ "The field {0} must be a string or array type with a minimum length of '{1}'.": "El campo {0} debe ser una cadena o un array con una longitud mínima de '{1}'.", "The {0} field is not a valid phone number.": "{0} no es un número de teléfono válido.", "The field {0} must be between {1} and {2}.": "El campo {0} debe ser entre {1} y {2}.", - "The field {0} must match the regular expression '{1}'.": "El campo {0} debe coincidir con la expresión regular '{1}'.", + "The field {0} must match the regular expression '{1}'.": "El campo {0} no coincide con el formato solicitado.", "The {0} field is required.": "La {0} campo es obligatorio.", "The field {0} must be a string with a maximum length of {1}.": "El campo {0} debe ser una cadena con una longitud máxima de {1}.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "El campo {0} debe ser una cadena con una longitud mínima de {2} y una longitud máxima de {1}.", diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/pl-PL.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/pl-PL.json index 93145f0a3a..80dc72212d 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/pl-PL.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/pl-PL.json @@ -10,7 +10,7 @@ "The field {0} must be a string or array type with a minimum length of '{1}'.": "Pole {0} musi być łańcuchem znaków lub talicą o minimalnej długości '{1}'.", "The {0} field is not a valid phone number.": "Pole {0} nie jest poprawnym numerem telefonu.", "The field {0} must be between {1} and {2}.": "Pole {0} musi być pomiędzy {1} i {2}.", - "The field {0} must match the regular expression '{1}'.": "Pole {0} musi pasować do wyrażenia regularnego '{1}'.", + "The field {0} must match the regular expression '{1}'.": "Pole {0} nie pasuje do żądanego formatu.", "The {0} field is required.": "Pole {0} jest wymagane.", "The field {0} must be a string with a maximum length of {1}.": "Pole {0} musi być łańcuchem znaków o maksymalnej długości {1}.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "Pole {0} musi być łańcuchem znaków o minimalnej długości {2} i maksymalnej długości {1}.", diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/pt-BR.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/pt-BR.json index b0852a3f5f..8d799a7730 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/pt-BR.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/pt-BR.json @@ -10,7 +10,7 @@ "The field {0} must be a string or array type with a minimum length of '{1}'.": "O campo {0} deve ser uma palavra ou matriz com o tamanho mínimo de '{1}'.", "The {0} field is not a valid phone number.": "O campo {0} não é um telefone válido.", "The field {0} must be between {1} and {2}.": "O campo {0} deve estar entre {1} e {2}.", - "The field {0} must match the regular expression '{1}'.": "O campo {0} deve ser compatível com a expressão regular '{1}'.", + "The field {0} must match the regular expression '{1}'.": "O campo {0} não corresponde ao formato solicitado.", "The {0} field is required.": "O Campo {0} é obrigatório.", "The field {0} must be a string with a maximum length of {1}.": "O campo {0} deve ser uma palavra com o tamanho máximo de {1}.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "O campo {0} deve ser uma palavra com o tamanho mínimo de {2} e tamanho máximo de {1}.", diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/ru.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/ru.json index 6272fdf534..d718dbbed8 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/ru.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/ru.json @@ -10,7 +10,7 @@ "The field {0} must be a string or array type with a minimum length of '{1}'.": "Поле {0} должно иметь тип строки или массива с минимальной длиной '{1}'.", "The {0} field is not a valid phone number.": "Поле {0} не содержит действительный номер телефона.", "The field {0} must be between {1} and {2}.": "Поле {0} должно находиться между {1} и {2}.", - "The field {0} must match the regular expression '{1}'.": "Поле {0} должно соответствовать регулярному выражению '{1}'.", + "The field {0} must match the regular expression '{1}'.": "Поле {0} не соответствует запрошенному формату.", "The {0} field is required.": "Поле {0} необходимо заполнить.", "The field {0} must be a string with a maximum length of {1}.": "Поле {0} должно быть строкой с максимальной длиной {1}.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "Поле {0} должно быть строкой с минимальной длиной {2} и максимальной длиной {1}.", diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/tr.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/tr.json index 447495e236..a70a3cd329 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/tr.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/tr.json @@ -10,7 +10,7 @@ "The field {0} must be a string or array type with a minimum length of '{1}'.": "{0} alanı en az '{1}' uzunluğunda bir metin ya da dizi olmalıdır.", "The {0} field is not a valid phone number.": "{0} alanı geçerli bir telefon numarası olmalıdır.", "The field {0} must be between {1} and {2}.": "{0} değeri {1} ile {2} arasında olmalıdır.", - "The field {0} must match the regular expression '{1}'.": "{0} alanı şu düzenli ifadeye uymalıdır: '{1}'.", + "The field {0} must match the regular expression '{1}'.": "{0} alanı istenilen biçimde değil.", "The {0} field is required.": "{0} alanı zorunludur.", "The field {0} must be a string with a maximum length of {1}.": "{0} alanı en fazla {1} uzunluğunda bir metin olmalıdır.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "{0} alanı en az {2}, en fazla {1} uzunluğunda bir metin olmalıdır.", diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/vi.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/vi.json index 2d9c0c55f5..9f8e3e30ee 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/vi.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/vi.json @@ -9,7 +9,7 @@ "The field {0} must be a string or array type with a maximum length of '{1}'.": "Trường {0} phải là một chuỗi hoặc một mảng với độ dài tối đa là '{1}'.", "The {0} field is not a valid phone number.": "Trường {0} không phải là một số điện thoại hợp lệ", "The field {0} must be between {1} and {2}.": "Trường {0} phải ở giữa {1} and {2}.", - "The field {0} must match the regular expression '{1}'.": "Trường {0} phải khớp với biểu thức chính quy '{1}'.", + "The field {0} must match the regular expression '{1}'.": "Trường {0} không khớp với định dạng được yêu cầu.", "The {0} field is required.": "Trường {0} là bắt buộc.", "The field {0} must be a string with a maximum length of {1}.": "Trường {0} phải là một chuỗi với độ dài tối đa là {1}.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "Trường {0} phải là một chuỗi với độ dài tối thiểu {2} và tối đa là {1}.", diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/zh-Hans.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/zh-Hans.json index 282423a44a..88a6e93acf 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/zh-Hans.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/zh-Hans.json @@ -10,7 +10,7 @@ "The field {0} must be a string or array type with a minimum length of '{1}'.": "字段{0}必须是最小长度为'{1}'的字符串或数组.", "The {0} field is not a valid phone number.": "字段{0}不是有效的手机号码.", "The field {0} must be between {1} and {2}.": "字段{0}值必须在{1}和{2}范围内.", - "The field {0} must match the regular expression '{1}'.": "字段{0}必须匹配正则表达式'{1}'.", + "The field {0} must match the regular expression '{1}'.": "字段{0}与请求的格式不匹配。", "The {0} field is required.": "字段{0}不可为空.", "The field {0} must be a string with a maximum length of {1}.": "字段{0}必须是长度为{1}的字符串.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "字段{0}必须是最小长度为{2}并且最大长度{1}的字符串.", diff --git a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/zh-Hant.json b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/zh-Hant.json index 1e7e419625..2cd200151c 100644 --- a/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/zh-Hant.json +++ b/framework/src/Volo.Abp.Validation/Volo/Abp/Validation/Localization/zh-Hant.json @@ -10,7 +10,7 @@ "The field {0} must be a string or array type with a minimum length of '{1}'.": "欄位{0}必須是最小長度為'{1}'的字串或陣列.", "The {0} field is not a valid phone number.": "欄位{0}不是有效的電話號碼.", "The field {0} must be between {1} and {2}.": "欄位{0}值必須在{1}和{2}範圍內.", - "The field {0} must match the regular expression '{1}'.": "欄位{0}必須匹配正規表示式'{1}'.", + "The field {0} must match the regular expression '{1}'.": "字段{0}与请求的格式不匹配。", "The {0} field is required.": "欄位{0}不可為空.", "The field {0} must be a string with a maximum length of {1}.": "欄位{0}必須是長度為{1}的字串.", "The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.": "欄位{0}必須是最小長度為{2}並且最大長度{1}的字串.",