Browse Source

Merge branch 'dev' of https://github.com/abpframework/abp into dev

pull/4882/head
Alper Ebicoglu 6 years ago
parent
commit
d5faf65594
  1. 14
      abp_io/AbpIoLocalization/AbpIoLocalization/Account/Localization/Resources/ar.json
  2. 161
      abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/ar.json
  3. 7
      build/common.ps1
  4. 2
      docs/en/Authorization.md
  5. 8
      docs/en/Blob-Storing-Aliyun.md
  6. 81
      docs/en/Blob-Storing-Aws.md
  7. 3
      docs/en/Blob-Storing.md
  8. 6
      docs/en/Nightly-Builds.md
  9. 7
      docs/en/Tutorials/Part-1.md
  10. 920
      docs/en/Tutorials/Part-10.md
  11. 14
      docs/en/Tutorials/Part-2.md
  12. 47
      docs/en/Tutorials/Part-3.md
  13. 9
      docs/en/Tutorials/Part-4.md
  14. 10
      docs/en/Tutorials/Part-5.md
  15. 289
      docs/en/Tutorials/Part-6.md
  16. 236
      docs/en/Tutorials/Part-7.md
  17. 575
      docs/en/Tutorials/Part-8.md
  18. 854
      docs/en/Tutorials/Part-9.md
  19. BIN
      docs/en/Tutorials/images/bookstore-add-migration-authors.png
  20. BIN
      docs/en/Tutorials/images/bookstore-added-author-to-book-list-angular.png
  21. BIN
      docs/en/Tutorials/images/bookstore-added-author-to-book-list.png
  22. BIN
      docs/en/Tutorials/images/bookstore-added-authors-to-modals.png
  23. BIN
      docs/en/Tutorials/images/bookstore-angular-authors-page.png
  24. BIN
      docs/en/Tutorials/images/bookstore-angular-service-proxy-author.png
  25. BIN
      docs/en/Tutorials/images/bookstore-author-domain-layer.png
  26. BIN
      docs/en/Tutorials/images/bookstore-author-permissions.png
  27. BIN
      docs/en/Tutorials/images/bookstore-authors-page.png
  28. BIN
      docs/en/Tutorials/images/bookstore-new-author-modal.png
  29. 203
      docs/en/UI/Angular/Subscription-Service.md
  30. 2
      docs/en/UI/Angular/Track-By-Service.md
  31. 28
      docs/en/docs-nav.json
  32. 6
      docs/zh-Hans/Blob-Storing-Aliyun.md
  33. 81
      docs/zh-Hans/Blob-Storing-Aws.md
  34. 14
      docs/zh-Hans/Blob-Storing-Minio.md
  35. 3
      docs/zh-Hans/Blob-Storing.md
  36. 4
      docs/zh-Hans/docs-nav.json
  37. 21
      framework/src/Volo.Abp.BlobStoring.Aliyun/Volo/Abp/BlobStoring/Aliyun/AliyunBlobProviderConfiguration.cs
  38. 4
      framework/src/Volo.Abp.BlobStoring.Aliyun/Volo/Abp/BlobStoring/Aliyun/AliyunBlobProviderConfigurationNames.cs
  39. 7
      framework/src/Volo.Abp.BlobStoring.Aliyun/Volo/Abp/BlobStoring/Aliyun/AliyunTemporaryCredentialsCacheItem.cs
  40. 104
      framework/src/Volo.Abp.BlobStoring.Aliyun/Volo/Abp/BlobStoring/Aliyun/DefaultOssClientFactory.cs
  41. 2
      framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs
  42. 4
      framework/test/Volo.Abp.BlobStoring.Aliyun.Tests/Volo/Abp/BlobStoring/Aliyun/AbpBlobStoringAliyunTestModule.cs
  43. 4
      modules/blogging/app/Volo.BloggingTestApp/BloggingTestAppModule.cs
  44. 1
      modules/blogging/app/Volo.BloggingTestApp/Volo.BloggingTestApp.csproj
  45. 1
      modules/blogging/src/Volo.Blogging.Web/Pages/Blogs/Posts/Edit.cshtml
  46. 1
      modules/blogging/src/Volo.Blogging.Web/Pages/Blogs/Posts/New.cshtml
  47. 6
      modules/docs/src/Volo.Docs.Web/DocsUiOptions.cs
  48. 11
      modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs
  49. 23
      modules/identity/src/Volo.Abp.Identity.Application.Contracts/Volo/Abp/Identity/AbpIdentityApplicationContractsModule.cs
  50. 5
      modules/identity/src/Volo.Abp.Identity.Application.Contracts/Volo/Abp/Identity/IdentityRoleCreateOrUpdateDtoBase.cs
  51. 5
      modules/identity/src/Volo.Abp.Identity.Application.Contracts/Volo/Abp/Identity/IdentityUserCreateOrUpdateDtoBase.cs
  52. 8
      modules/identity/src/Volo.Abp.Identity.Web/AbpIdentityWebAutoMapperProfile.cs
  53. 21
      modules/identity/src/Volo.Abp.Identity.Web/AbpIdentityWebModule.cs
  54. 26
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/CreateModal.cshtml
  55. 5
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/CreateModal.cshtml.cs
  56. 28
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/EditModal.cshtml
  57. 3
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/EditModal.cshtml.cs
  58. 9
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/Index.cshtml
  59. 155
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/index.js
  60. 24
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/CreateModal.cshtml
  61. 3
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/CreateModal.cshtml.cs
  62. 25
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/EditModal.cshtml
  63. 3
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/EditModal.cshtml.cs
  64. 11
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/Index.cshtml
  65. 143
      modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/index.js
  66. 14
      modules/tenant-management/src/Volo.Abp.TenantManagement.Application.Contracts/Volo/Abp/TenantManagement/AbpTenantManagementApplicationContractsModule.cs
  67. 5
      modules/tenant-management/src/Volo.Abp.TenantManagement.Application.Contracts/Volo/Abp/TenantManagement/TenantCreateOrUpdateDtoBase.cs
  68. 17
      modules/tenant-management/src/Volo.Abp.TenantManagement.Domain.Shared/Volo/Abp/ObjectExtending/TenantManagementModuleExtensionConfiguration.cs
  69. 18
      modules/tenant-management/src/Volo.Abp.TenantManagement.Domain.Shared/Volo/Abp/ObjectExtending/TenantManagementModuleExtensionConfigurationDictionaryExtensions.cs
  70. 12
      modules/tenant-management/src/Volo.Abp.TenantManagement.Domain.Shared/Volo/Abp/ObjectExtending/TenantManagementModuleExtensionConsts.cs
  71. 11
      modules/tenant-management/src/Volo.Abp.TenantManagement.Domain/Volo/Abp/TenantManagement/AbpTenantManagementDomainModule.cs
  72. 4
      modules/tenant-management/src/Volo.Abp.TenantManagement.Web/AbpTenantManagementWebAutoMapperProfile.cs
  73. 13
      modules/tenant-management/src/Volo.Abp.TenantManagement.Web/AbpTenantManagementWebModule.cs
  74. 25
      modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml
  75. 4
      modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs
  76. 20
      modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml
  77. 3
      modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs
  78. 9
      modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml
  79. 151
      modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js
  80. 27
      npm/ng-packs/packages/account/src/lib/components/auth-wrapper/auth-wrapper.component.ts
  81. 14
      npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts
  82. 28
      npm/ng-packs/packages/core/src/lib/components/replaceable-route-container.component.ts
  83. 29
      npm/ng-packs/packages/core/src/lib/directives/debounce.directive.ts
  84. 36
      npm/ng-packs/packages/core/src/lib/directives/form-submit.directive.ts
  85. 21
      npm/ng-packs/packages/core/src/lib/directives/permission.directive.ts
  86. 27
      npm/ng-packs/packages/core/src/lib/directives/replaceable-template.directive.ts
  87. 21
      npm/ng-packs/packages/core/src/lib/directives/stop-propagation.directive.ts
  88. 1
      npm/ng-packs/packages/core/src/lib/services/index.ts
  89. 21
      npm/ng-packs/packages/core/src/lib/services/list.service.ts
  90. 12
      npm/ng-packs/packages/core/src/lib/services/routes.service.ts
  91. 54
      npm/ng-packs/packages/core/src/lib/services/subscription.service.ts
  92. 109
      npm/ng-packs/packages/core/src/lib/tests/subscription.service.spec.ts
  93. 8
      npm/ng-packs/packages/core/src/lib/utils/rxjs-utils.ts
  94. 12
      npm/ng-packs/packages/theme-basic/src/lib/components/application-layout/application-layout.component.ts
  95. 28
      npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts
  96. 19
      npm/ng-packs/packages/theme-shared/src/lib/components/http-error-wrapper/http-error-wrapper.component.ts
  97. 36
      npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.ts
  98. 17
      npm/ng-packs/packages/theme-shared/src/lib/components/modal/modal.component.ts
  99. 11
      npm/ng-packs/packages/theme-shared/src/lib/tests/loader-bar.component.spec.ts

14
abp_io/AbpIoLocalization/AbpIoLocalization/Account/Localization/Resources/ar.json

@ -0,0 +1,14 @@
{
"culture": "ar",
"texts": {
"Account": "حساب ABP - تسجيل الدخول وإنشاء حساب | ABP.IO",
"Welcome": "أهلا و سهلا",
"UseOneOfTheFollowingLinksToContinue": "استخدم أحد الروابط التالية للمتابعة",
"FrameworkHomePage": "الصفحة الرئيسية للنظام",
"FrameworkDocumentation": "وثائق النظام",
"OfficialBlog": "المدونة الرسمية",
"CommercialHomePage": "الصفحة الرئيسية للنظام التجاري",
"CommercialSupportWebSite": "موقع الدعم الفني للنظام التجاري",
"CommunityWebSite": "موقع مجتمع ABP"
}
}

161
abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/ar.json

@ -0,0 +1,161 @@
{
"culture": "ar",
"texts": {
"Permission:Organizations": "المنظمات",
"Permission:Manage": "إدارة المنظمات",
"Permission:DiscountRequests": "طلبات الخصم",
"Permission:DiscountManage": "إدارة طلبات الخصم",
"Permission:Disable": "تعطيل",
"Permission:Enable": "تفعيل",
"Permission:EnableSendEmail": "تمكين إرسال البريد الإلكتروني",
"Permission:SendEmail": "ارسل بريد الكتروني",
"Permission:NpmPackages": "حزم NPM",
"Permission:NugetPackages": "حزم Nuget",
"Permission:Maintenance": "صيانة",
"Permission:Maintain": "المحافظة",
"Permission:ClearCaches": "مسح ذاكرة التخزين المؤقت",
"Permission:Modules": "الوحدات",
"Permission:Packages": "حزم",
"Permission:Edit": "تعديل",
"Permission:Delete": "حذف",
"Permission:Create": "إنشاء",
"Permission:Accounting": "محاسبة",
"Permission:Accounting:Quotation": "عرض سعر",
"Permission:Accounting:Invoice": "فاتورة",
"Menu:Organizations": "المنظمات",
"Menu:Accounting": "المحاسبة",
"Menu:Packages": "الحزم",
"Menu:DiscountRequests": "طلبات الخصم",
"NpmPackageDeletionWarningMessage": "سيتم حذف حزمة NPM هذه. هل تؤكد ذلك؟",
"NugetPackageDeletionWarningMessage": "سيتم حذف حزمة Nuget هذه. هل تؤكد ذلك؟",
"ModuleDeletionWarningMessage": "سيتم حذف هذه الوحدة. هل تؤكد ذلك؟",
"Name": "اسم",
"DisplayName": "اسم العرض",
"ShortDescription": "وصف قصير",
"NameFilter": "الاسم",
"CreationTime": "وقت الإنشاء",
"IsPro": "هو pro",
"ShowOnModuleList": "إظهار في قائمة الوحدات",
"EfCoreConfigureMethodName": "تكوين اسم method",
"IsProFilter": "هو pro",
"ApplicationType": "نوع التطبيق",
"Target": "استهداف",
"TargetFilter": "استهداف",
"Edit": "تعديل",
"Delete": "حذف",
"Refresh": "تحديث",
"NpmPackages": "حزم NPM",
"NugetPackages": "حزم Nuget",
"NpmPackageCount": "عدد حزم NPM",
"NugetPackageCount": "عدد حزم Nuget",
"Module": "الوحدات",
"ModuleInfo": "معلومات الوحدة",
"CreateANpmPackage": "إنشاء حزمة NPM",
"CreateAModule": "إنشاء وحدة",
"CreateANugetPackage": "أنشاء حزمة Nuget",
"AddNew": "اضف جديد",
"PackageAlreadyExist{0}": "",
"ModuleAlreadyExist{0}": "",
"ClearCache": "مسح ذاكرة التخزين المؤقت",
"SuccessfullyCleared": "تم محوه بنجاح",
"Menu:NpmPackages": "حزم NPM",
"Menu:Modules": "الوحدات",
"Menu:Maintenance": "الصيانة",
"Menu:NugetPackages": "حزم Nuget",
"CreateAnOrganization": "إنشاء منظمة",
"Organizations": "المنظمات",
"LongName": "اسم طويل",
"LicenseType": "نوع الرخصة",
"MissingLicenseTypeField": "حقل نوع الرخصة مطلوب!",
"LicenseStartTime": "وقت بدء الرخصة",
"LicenseEndTime": "وقت إنتهاء الرخصة",
"AllowedDeveloperCount": "عدد مطوري البرامج المسموح به",
"UserNameOrEmailAddress": "اسم المستخدم أو البريد الالكتروني",
"AddOwner": "أضف مالك",
"UserName": "اسم المستخدم",
"Email": "البريد الإلكتروني",
"Developers": "المطورين",
"AddDeveloper": "أضف مطور",
"Create": "إنشاء",
"UserNotFound": "لم نعثر على المستخدم",
"{0}WillBeRemovedFromDevelopers": "{0} ستتم إزالته من المطورين ، هل تؤكد ذلك؟",
"{0}WillBeRemovedFromOwners": "{0} ستتم إزالته من المالكين ، هل تؤكد ذلك؟",
"Computers": "أجهزة الكمبيوتر",
"UniqueComputerId": "معرف الكمبيوتر",
"LastSeenDate": "تاريخ آخر ظهور",
"{0}Computer{1}WillBeRemovedFromRecords": "ستتم إزالة كمبيوتر {0} ({1}) من السجلات",
"OrganizationDeletionWarningMessage": "سيتم حذف المنظمة",
"DeletingLastOwnerWarningMessage": "يجب أن يكون للمنظمة مالك واحد على الأقل! لذلك لا يمكنك إزالة هذا المالك",
"This{0}AlreadyExistInThisOrganization": "هذا {0} موجود بالفعل في هذه المنظمة",
"AreYouSureYouWantToDeleteAllComputers": "هل أنت متأكد أنك تريد حذف جميع أجهزة الكمبيوتر؟",
"DeleteAll": "حذف الكل",
"DoYouWantToCreateNewUser": "هل تريد إنشاء مستخدم جديد؟",
"MasterModules": "الوحدات الرئيسية",
"OrganizationName": "اسم المنظمة",
"CreationDate": "تاريخ الإنشاء",
"LicenseStart": "تاريخ بدء الرخصة",
"LicenseEndDate": "تاريخ إنتهاء الرخصة",
"OrganizationNamePlaceholder": "اسم المنظمة ...",
"TotalQuestionCountPlaceholder": "إجمالي عدد الأسئلة ...",
"RemainingQuestionCountPlaceholder": "عدد الأسئلة المتبقية ...",
"LicenseTypePlaceholder": "نوع الرخصة...",
"CreationDatePlaceholder": "تاريخ الإنشاء...",
"LicenseStartDatePlaceholder": "تاريخ بدء الرخصة ...",
"LicenseEndDatePlaceholder": "تاريخ إنتهاء الرخصة ...",
"UsernameOrEmail": "اسم المستخدم أو البريد الالكتروني",
"UsernameOrEmailPlaceholder": "اسم المستخدم أو البريد الالكتروني...",
"Member": "عضو",
"PurchaseOrderNo": "طلب الشراء رقم",
"QuotationDate": "تاريخ عرض السعر",
"CompanyName": "اسم الشركة",
"CompanyAddress": "عنوان الشركة",
"Price": "السعر",
"DiscountText": "نص الخصم",
"DiscountQuantity": "كمية الخصم",
"DiscountPrice": "سعر الخصم",
"Quotation": "عرض السعر",
"ExtraText": "نص إضافي",
"ExtraAmount": "مبلغ إضافي",
"DownloadQuotation": "تنزيل عرض الأسعار",
"Invoice": "فاتورة",
"TaxNumber": "الرقم الضريبي",
"InvoiceNumber": "رقم الفاتورة",
"InvoiceDate": "تاريخ الفاتورة",
"InvoiceNote": "ملاحظة الفاتورة",
"Quantity": "كمية",
"AddProduct": "أضف منتج",
"AddProductWarning": "تحتاج إلى إضافة منتج!",
"TotalPrice": "السعر الكلي",
"Generate": "انشاء",
"MissingQuantityField": "حقل الكمية مطلوب!",
"MissingPriceField": "حقل السعر مطلوب!",
"CodeUsageStatus": "الحالة",
"Country": "الدولة",
"DeveloperCount": "عدد المطورين",
"RequestCode": "كود الطلب",
"WebSite": "موقع الكتروني",
"GithubUsername": "اسم مستخدم Github",
"PhoneNumber": "رقم الهاتف",
"ProjectDescription": "وصف المشروع",
"Referrer": "المُحيل",
"DiscountRequests": "طلب الخصم",
"Copylink": "انسخ الرابط",
"Disable": "تعطيل",
"Enable": "تفعيل",
"EnableSendEmail": "تمكين إرسال البريد الإلكتروني",
"SendEmail": "ارسل بريد الكتروني",
"SuccessfullyDisabled": "تم التعطيل بنجاح",
"SuccessfullyEnabled": "تم التفعيل بنجاح",
"EmailSent": "تم ارسال البريد الالكتروني",
"SuccessfullySent": "أرسلت بنجاح",
"SuccessfullyDeleted": "تم الحذف بنجاح",
"DiscountRequestDeletionWarningMessage": "سيتم حذف طلب الخصم",
"BusinessType": "نوع العمل",
"TotalQuestionCount": "إجمالي عدد الأسئلة",
"RemainingQuestionCount": "عدد الأسئلة المتبقية",
"TotalQuestionMustBeGreaterWarningMessage": "يجب أن يكون TotalQuestionCount أكبر من RemainingQuestionCount!",
"QuestionCountsMustBeGreaterThanZero": "يجب أن يكون TotalQuestionCount و RemainingQuestionCount صفرًا أو أكبر من الصفر!",
"UnlimitedQuestionCount": "عدد أسئلة غير محدود",
"Notes": "ملاحظات"
}
}

7
build/common.ps1

@ -13,13 +13,14 @@ $solutionPaths = (
"../modules/identity",
"../modules/identityserver",
"../modules/tenant-management",
"../modules/account",
"../modules/docs",
"../modules/blogging",
"../modules/audit-logging",
"../modules/background-jobs",
"../modules/account",
"../modules/client-simulation",
"../modules/virtual-file-explorer",
"../modules/docs",
"../modules/blogging",
"../templates/module/aspnet-core",
"../templates/app/aspnet-core",
"../abp_io/AbpIoLocalization"

2
docs/en/Authorization.md

@ -284,7 +284,7 @@ You may need to check a policy/permission on the client side. For ASP.NET Core M
abp.auth.isGranted('MyPermissionName');
```
See [abp.auth](API/JavaScript-API/Auth.md) API documentation for details.
See [abp.auth](UI/AspNetCore/JavaScript-API/Index.md) API documentation for details.
## Permission Management

8
docs/en/Blob-Storing-Aliyun.md

@ -45,17 +45,19 @@ Configure<AbpBlobStoringOptions>(options =>
* **AccessKeyId** ([NotNull]string): AccessKey is the key to access the Alibaba Cloud API. It has full permissions for the account. Please keep it safe! Recommend to follow [Alibaba Cloud security best practicess](https://help.aliyun.com/document_detail/102600.html),Use RAM sub-user AccessKey to call API.
* **AccessKeySecret** ([NotNull]string): Same as above.
* **Endpoint** ([NotNull]string): Endpoint is the external domain name of OSS. See the [document](https://help.aliyun.com/document_detail/31837.html) for details.
* **Endpoint** ([NotNull]string): Endpoint is the external domain name of OSS. See the [document](https://help.aliyun.com/document_detail/31837.html) for details.
* **UseSecurityTokenService** (bool): Use [STS temporary credentials](https://help.aliyun.com/document_detail/100624.html) to access OSS services,default: `false`.
* **RegionId** (string): Access address of STS service. See the [document](https://help.aliyun.com/document_detail/66053.html) for details.
* **RoleArn** ([NotNull]string): STS required role ARN. See the [document](https://help.aliyun.com/document_detail/100624.html) for details.
* **RoleSessionName** ([NotNull]string): Used to identify the temporary access credentials, it is recommended to use different application users to distinguish.
* **Policy** (string): Additional permission restrictions. See the [document](https://help.aliyun.com/document_detail/100680.html) for details.
* **DurationSeconds** (int): Validity period(s) of a temporary access certificate,minimum is 900 and the maximum is 3600. **note**: Using subaccounts operated OSS,if the value is 0.
* **DurationSeconds** (int): Validity period(s) of a temporary access certificate,minimum is 900 and the maximum is 3600.
* **ContainerName** (string): You can specify the container name in Aliyun. If this is not specified, it uses the name of the BLOB container defined with the `BlogContainerName` attribute (see the [BLOB storing document](Blob-Storing.md)). Please note that Aliyun has some **rules for naming containers**. A container name must be a valid DNS name, conforming to the [following naming rules](https://help.aliyun.com/knowledge_detail/39668.html):
* Container names must start or end with a letter or number, and can contain only letters, numbers, and the dash (-) character.
* Container names Must start and end with lowercase letters and numbers.
* Container names must be from **3** through **63** characters long.
* **CreateContainerIfNotExists** (bool): Default value is `false`, If a container does not exist in Aliyun, `AliyunBlobProvider` will try to create it.
* **TemporaryCredentialsCacheKey** (bool): The cache key of STS credentials.
## Aliyun Blob Name Calculator
@ -70,4 +72,4 @@ Aliyun Blob Provider organizes BLOB name and implements some conventions. The fu
* `AliyunBlobProvider` is the main service that implements the Aliyun BLOB storage provider, if you want to override/replace it via [dependency injection](Dependency-Injection.md) (don't replace `IBlobProvider` interface, but replace `AliyunBlobProvider` class).
* `IAliyunBlobNameCalculator` is used to calculate the full BLOB name (that is explained above). It is implemented by the `DefaultAliyunBlobNameCalculator` by default.
* `IOssClientFactory` is used create OSS client. It is implemented by the `DefaultOssClientFactory` by default. You can override/replace it,if you want customize.
* `IOssClientFactory` is used create OSS client. It is implemented by the `DefaultOssClientFactory` by default. You can override/replace it,if you want customize.

81
docs/en/Blob-Storing-Aws.md

@ -0,0 +1,81 @@
# BLOB Storing Aws Provider
BLOB Storing Aws Provider can store BLOBs in [Amazon Simple Storage Service](https://aws.amazon.com/s3/).
> Read the [BLOB Storing document](Blob-Storing.md) to understand how to use the BLOB storing system. This document only covers how to configure containers to use a Aws BLOB as the storage provider.
## Installation
Use the ABP CLI to add [Volo.Abp.BlobStoring.Aws](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Aws) NuGet package to your project:
* Install the [ABP CLI](https://docs.abp.io/en/abp/latest/CLI) if you haven't installed before.
* Open a command line (terminal) in the directory of the `.csproj` file you want to add the `Volo.Abp.BlobStoring.Aws` package.
* Run `abp add-package Volo.Abp.BlobStoring.Aws` command.
If you want to do it manually, install the [Volo.Abp.BlobStoring.Aws](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Aws) NuGet package to your project and add `[DependsOn(typeof(AbpBlobStoringAwsModule))]` to the [ABP module](Module-Development-Basics.md) class inside your project.
## Configuration
Configuration is done in the `ConfigureServices` method of your [module](Module-Development-Basics.md) class, as explained in the [BLOB Storing document](Blob-Storing.md).
**Example: Configure to use the Aws storage provider by default**
````csharp
Configure<AbpBlobStoringOptions>(options =>
{
options.Containerscontainer.UseAws(Aws =>
{
Aws.AccessKeyId = "your Aws access key id";
Aws.SecretAccessKey = "your Aws access key secret";
Aws.UseCredentials = "set true to use credentials";
Aws.UseTemporaryCredentials = "set true to use temporary credentials";
Aws.UseTemporaryFederatedCredentials = "set true to use temporary federated credentials";
Aws.ProfileName = "the name of the profile to get credentials from";
Aws.ProfilesLocation = "the path to the aws credentials file to look at";
Aws.Region = "the system name of the service";
Aws.Name = "the name of the federated user";
Aws.Policy = "policy";
Aws.DurationSeconds = "expiration date";
Aws.ContainerName = "your Aws container name";
Aws.CreateContainerIfNotExists = false;
});
});
````
> See the [BLOB Storing document](Blob-Storing.md) to learn how to configure this provider for a specific container.
### Options
* **AccessKeyId** (string): AWS Access Key ID.
* **SecretAccessKey** (string): AWS Secret Access Key.
* **UseCredentials** (bool): Use [credentials](https://docs.aws.amazon.com/AmazonS3/latest/dev/AuthUsingAcctOrUserCredentials.html) to access AWS services,default : `false`.
* **UseTemporaryCredentials** (bool): Use [temporary credentials](https://docs.aws.amazon.com/AmazonS3/latest/dev/AuthUsingTempSessionToken.html) to access AWS services,default : `false`.
* **UseTemporaryFederatedCredentials** (bool): Use [federated user temporary credentials](https://docs.aws.amazon.com/AmazonS3/latest/dev/AuthUsingTempFederationToken.html) to access AWS services, default : `false`.
* **ProfileName** (string): The [name of the profile]((https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-creds.html)) to get credentials from.
* **ProfilesLocation** (string): The path to the aws credentials file to look at.
* **Region** (string): The system name of the service.
* **Policy** (string): An IAM policy in JSON format that you want to use as an inline session policy.
* **DurationSeconds** (int): Validity period(s) of a temporary access certificate,minimum is 900 and the maximum is 3600. **note**: Using subaccounts operated OSS,if the value is 0.
* **ContainerName** (string): You can specify the container name in Aws. If this is not specified, it uses the name of the BLOB container defined with the `BlogContainerName` attribute (see the [BLOB storing document](Blob-Storing.md)). Please note that Aws has some **rules for naming containers**. A container name must be a valid DNS name, conforming to the [following naming rules](https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html):
* Bucket names must be between **3** and **63** characters long.
* Bucket names can consist only of **lowercase** letters, numbers, dots (.), and hyphens (-).
* Bucket names must begin and end with a letter or number.
* Bucket names must not be formatted as an IP address (for example, 192.168.5.4).
* Bucket names can't begin with **xn--** (for buckets created after February 2020).
* Bucket names must be unique within a partition.
* Buckets used with Amazon S3 Transfer Acceleration can't have dots (.) in their names. For more information about transfer acceleration, see Amazon S3 Transfer Acceleration.
* **CreateContainerIfNotExists** (bool): Default value is `false`, If a container does not exist in Aws, `AwsBlobProvider` will try to create it.
## Aws Blob Name Calculator
Aws Blob Provider organizes BLOB name and implements some conventions. The full name of a BLOB is determined by the following rules by default:
* Appends `host` string if [current tenant](Multi-Tenancy.md) is `null` (or multi-tenancy is disabled for the container - see the [BLOB Storing document](Blob-Storing.md) to learn how to disable multi-tenancy for a container).
* Appends `tenants/<tenant-id>` string if current tenant is not `null`.
* Appends the BLOB name.
## Other Services
* `AwsBlobProvider` is the main service that implements the Aws BLOB storage provider, if you want to override/replace it via [dependency injection](Dependency-Injection.md) (don't replace `IBlobProvider` interface, but replace `AwsBlobProvider` class).
* `IAwsBlobNameCalculator` is used to calculate the full BLOB name (that is explained above). It is implemented by the `DefaultAwsBlobNameCalculator` by default.
* `IAmazonS3ClientFactory` is used create OSS client. It is implemented by the `DefaultAmazonS3ClientFactory` by default. You can override/replace it,if you want customize.

3
docs/en/Blob-Storing.md

@ -19,8 +19,9 @@ The ABP Framework has already the following storage provider implementations;
* [File System](Blob-Storing-File-System.md): Stores BLOBs in a folder of the local file system, as standard files.
* [Database](Blob-Storing-Database.md): Stores BLOBs in a database.
* [Azure](Blob-Storing-Azure.md): Stores BLOBs on the [Azure BLOB storage](https://azure.microsoft.com/en-us/services/storage/blobs/).
* [Aliyun](Blob-Storing-Aliyun.md): Stores BLOBs on the [Aliyun Blob storage](https://help.aliyun.com/product/31815.html).
* [Aliyun](Blob-Storing-Aliyun.md): Stores BLOBs on the [Aliyun Storage Service](https://help.aliyun.com/product/31815.html).
* [Ninio](Blob-Storing-Minio.md): Stores BLOBs on the [MinIO Object storage](https://min.io/).
* [Aws](Blob-Storing-Aws.md): Stores BLOBs on the[Amazon Simple Storage Service](https://min.io/).
More providers will be implemented by the time. You can [request](https://github.com/abpframework/abp/issues/new) it for your favorite provider or [create it yourself](Blob-Storing-Custom-Provider.md) and [contribute](Contribution/Index.md) to the ABP Framework.

6
docs/en/Nightly-Builds.md

@ -29,13 +29,13 @@ Now, you can install preview / nightly packages to your project from Nuget Brows
The latest version of preview NPM packages can be installed by the running below command in the root folder of application:
```bash
abp switch-to-preview
abp switch-to-preview --npm
```
If you're using the ABP Framework preview packages, you can switch back to stable version using this command:
```bash
abp switch-to-stable
abp switch-to-stable --npm
```
See the [ABP CLI documentation](./CLI.md) for more information.
See the [ABP CLI documentation](./CLI.md) for more information.

7
docs/en/Tutorials/Part-1.md

@ -27,7 +27,7 @@ end
In this tutorial series, you will build an ABP based web application named `Acme.BookStore`. This application is used to manage a list of books and their authors. It is developed using the following technologies:
* **{{DB_Text}}** as the ORM provider.
* **{{DB_Text}}** as the ORM provider.
* **{{UI_Value}}** as the UI Framework.
This tutorial is organized as the following parts;
@ -37,6 +37,11 @@ This tutorial is organized as the following parts;
- [Part 3: Creating, updating and deleting books](Part-3.md)
- [Part 4: Integration tests](Part-4.md)
- [Part 5: Authorization](Part-5.md)
- [Part 6: Authors: Domain layer](Part-6.md)
- [Part 7: Authors: Database Integration](Part-7.md)
- [Part 8: Authors: Application Layer](Part-8.md)
- [Part 9: Authors: User Interface](Part-9.md)
- [Part 10: Book to Author Relation](Part-10.md)
### Download the Source Code

920
docs/en/Tutorials/Part-10.md

@ -0,0 +1,920 @@
# Web Application Development Tutorial - Part 10: Book to Author Relation
````json
//[doc-params]
{
"UI": ["MVC","NG"],
"DB": ["EF","Mongo"]
}
````
{{
if UI == "MVC"
UI_Text="mvc"
else if UI == "NG"
UI_Text="angular"
else
UI_Text="?"
end
if DB == "EF"
DB_Text="Entity Framework Core"
else if DB == "Mongo"
DB_Text="MongoDB"
else
DB_Text="?"
end
}}
## About This Tutorial
In this tutorial series, you will build an ABP based web application named `Acme.BookStore`. This application is used to manage a list of books and their authors. It is developed using the following technologies:
* **{{DB_Text}}** as the ORM provider.
* **{{UI_Value}}** as the UI Framework.
This tutorial is organized as the following parts;
- [Part 1: Creating the server side](Part-1.md)
- [Part 2: The book list page](Part-2.md)
- [Part 3: Creating, updating and deleting books](Part-3.md)
- [Part 4: Integration tests](Part-4.md)
- [Part 5: Authorization](Part-5.md)
- [Part 6: Authors: Domain layer](Part-6.md)
- [Part 7: Authors: Database Integration](Part-7.md)
- [Part 8: Authors: Application Layer](Part-8.md)
- [Part 9: Authors: User Interface](Part-9.md)
- **Part 10: Book to Author Relation (this part)**
### Download the Source Code
This tutorials has multiple versions based on your **UI** and **Database** preferences. We've prepared two combinations of the source code to be downloaded:
* [MVC (Razor Pages) UI with EF Core](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore)
* [Angular UI with MongoDB](https://github.com/abpframework/abp-samples/tree/master/BookStore-Angular-MongoDb)
## Introduction
We have created `Book` and `Author` functionalities for the book store application. However, currently there is no relation between these entities.
In this tutorial, we will establish a **1 to N** relation between the `Book` and the `Author`.
## Add Relation to The Book Entity
Open the `Books/Book.cs` in the `Acme.BookStore.Domain` project and add the following property to the `Book` entity:
````csharp
public Guid AuthorId { get; set; }
````
{{if DB=="EF"}}
> In this tutorial, we preferred to not add a **navigation property** to the `Author` entity (like `public Author Author { get; set; }`). This is due to follow the DDD best practices (rule: refer to other aggregates only by id). However, you can add such a navigation property and configure it for the EF Core. In this way, you don't need to write join queries while getting books with their entities (just like we will done below) which makes your application code simpler.
{{end}}
## Database & Data Migration
Added a new, required `AuthorId` property to the `Book` entity. But, what about the existing books on the database? They currently don't have `AuthorId`s and this will be a problem when we try to run the application.
This is a typical migration problem and the decision depends on your case;
* If you haven't published your application to the production yet, you can just delete existing books in the database, or you can even delete the entire database in your development environment.
* You can do it programmatically on data migration or seed phase.
* You can manually handle it on the database.
We prefer to delete the database {{if DB=="EF"}}(run the `Drop-Database` in the *Package Manager Console*){{end}} since this is just an example project and data loss is not important. Since this topic is not related to the ABP Framework, we don't go deeper for all the scenarios.
{{if DB=="EF"}}
### Update the EF Core Mapping
Open the `BookStoreDbContextModelCreatingExtensions` class under the `EntityFrameworkCore` folder of the `Acme.BookStore.EntityFrameworkCore` project and change the `builder.Entity<Book>` part as shown below:
````csharp
builder.Entity<Book>(b =>
{
b.ToTable(BookStoreConsts.DbTablePrefix + "Books", BookStoreConsts.DbSchema);
b.ConfigureByConvention(); //auto configure for the base class props
b.Property(x => x.Name).IsRequired().HasMaxLength(128);
// ADD THE MAPPING FOR THE RELATION
b.HasOne<Author>().WithMany().HasForeignKey(x => x.AuthorId).IsRequired();
});
````
### Add New EF Core Migration
Run the following command in the Package Manager Console (of the Visual Studio) to add a new database migration:
````bash
Add-Migration "Added_AuthorId_To_Book"
````
This should create a new migration class with the following code in its `Up` method:
````csharp
migrationBuilder.AddColumn<Guid>(
name: "AuthorId",
table: "AppBooks",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateIndex(
name: "IX_AppBooks_AuthorId",
table: "AppBooks",
column: "AuthorId");
migrationBuilder.AddForeignKey(
name: "FK_AppBooks_AppAuthors_AuthorId",
table: "AppBooks",
column: "AuthorId",
principalTable: "AppAuthors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
````
* Adds an `AuthorId` field to the `AppBooks` table.
* Creates an index on the `AuthorId` field.
* Declares the foreign key to the `AppAuthors` table.
{{end}}
## Change the Data Seeder
Since the `AuthorId` is a required property of the `Book` entity, current data seeder code can not work. Open the `BookStoreDataSeederContributor` in the `Acme.BookStore.Domain` project and change as the following:
````csharp
using System;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Books;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
namespace Acme.BookStore
{
public class BookStoreDataSeederContributor
: IDataSeedContributor, ITransientDependency
{
private readonly IRepository<Book, Guid> _bookRepository;
private readonly IAuthorRepository _authorRepository;
private readonly AuthorManager _authorManager;
public BookStoreDataSeederContributor(
IRepository<Book, Guid> bookRepository,
IAuthorRepository authorRepository,
AuthorManager authorManager)
{
_bookRepository = bookRepository;
_authorRepository = authorRepository;
_authorManager = authorManager;
}
public async Task SeedAsync(DataSeedContext context)
{
if (await _bookRepository.GetCountAsync() > 0)
{
return;
}
var orwell = await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"George Orwell",
new DateTime(1903, 06, 25),
"Orwell produced literary criticism and poetry, fiction and polemical journalism; and is best known for the allegorical novella Animal Farm (1945) and the dystopian novel Nineteen Eighty-Four (1949)."
)
);
var douglas = await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"Douglas Adams",
new DateTime(1952, 03, 11),
"Douglas Adams was an English author, screenwriter, essayist, humorist, satirist and dramatist. Adams was an advocate for environmentalism and conservation, a lover of fast cars, technological innovation and the Apple Macintosh, and a self-proclaimed 'radical atheist'."
)
);
await _bookRepository.InsertAsync(
new Book
{
AuthorId = orwell.Id, // SET THE AUTHOR
Name = "1984",
Type = BookType.Dystopia,
PublishDate = new DateTime(1949, 6, 8),
Price = 19.84f
},
autoSave: true
);
await _bookRepository.InsertAsync(
new Book
{
AuthorId = douglas.Id, // SET THE AUTHOR
Name = "The Hitchhiker's Guide to the Galaxy",
Type = BookType.ScienceFiction,
PublishDate = new DateTime(1995, 9, 27),
Price = 42.0f
},
autoSave: true
);
}
}
}
````
The only change is that we set the `AuthorId` properties of the `Book` entities.
{{if DB=="EF"}}
You can now run the `.DbMigrator` console application to **migrate** the **database schema** and **seed** the initial data.
{{else if DB=="Mongo"}}
You can now run the `.DbMigrator` console application to **seed** the initial data.
{{end}}
## Application Layer
We will change the `BookAppService` to support the Author relation.
### Data Transfer Objects
Let's begin from the DTOs.
#### BookDto
Open the `BookDto` class in the `Books` folder of the `Acme.BookStore.Application.Contracts` project and add the following properties:
```csharp
public Guid AuthorId { get; set; }
public string AuthorName { get; set; }
```
The final `BookDto` class should be following:
```csharp
using System;
using Volo.Abp.Application.Dtos;
namespace Acme.BookStore.Books
{
public class BookDto : AuditedEntityDto<Guid>
{
public Guid AuthorId { get; set; }
public string AuthorName { get; set; }
public string Name { get; set; }
public BookType Type { get; set; }
public DateTime PublishDate { get; set; }
public float Price { get; set; }
}
}
```
#### CreateUpdateBookDto
Open the `CreateUpdateBookDto` class in the `Books` folder of the `Acme.BookStore.Application.Contracts` project and add an `AuthorId` property as shown:
````csharp
public Guid AuthorId { get; set; }
````
#### AuthorLookupDto
Create a new class, `AuthorLookupDto`, inside the `Books` folder of the `Acme.BookStore.Application.Contracts` project:
````csharp
using System;
using Volo.Abp.Application.Dtos;
namespace Acme.BookStore.Books
{
public class AuthorLookupDto : EntityDto<Guid>
{
public string Name { get; set; }
}
}
````
This will be used in a new method will be added to the `IBookAppService`.
### IBookAppService
Open the `IBookAppService` interface in the `Books` folder of the `Acme.BookStore.Application.Contracts` project and add a new method, named `GetAuthorLookupAsync`, as shown below:
````csharp
using System;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
namespace Acme.BookStore.Books
{
public interface IBookAppService :
ICrudAppService< //Defines CRUD methods
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto> //Used to create/update a book
{
// ADD the NEW METHOD
Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync();
}
}
````
This new method will be used from the UI to get a list of authors and fill a dropdown list to select the author of a book.
### BookAppService
Open the `BookAppService` interface in the `Books` folder of the `Acme.BookStore.Application` project and replace the file content with the following code:
{{if DB=="EF"}}
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Permissions;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Domain.Repositories;
namespace Acme.BookStore.Books
{
[Authorize(BookStorePermissions.Books.Default)]
public class BookAppService :
CrudAppService<
Book, //The Book entity
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto>, //Used to create/update a book
IBookAppService //implement the IBookAppService
{
private readonly IAuthorRepository _authorRepository;
public BookAppService(
IRepository<Book, Guid> repository,
IAuthorRepository authorRepository)
: base(repository)
{
_authorRepository = authorRepository;
GetPolicyName = BookStorePermissions.Books.Default;
GetListPolicyName = BookStorePermissions.Books.Default;
CreatePolicyName = BookStorePermissions.Books.Create;
UpdatePolicyName = BookStorePermissions.Books.Edit;
DeletePolicyName = BookStorePermissions.Books.Create;
}
public override async Task<BookDto> GetAsync(Guid id)
{
//Prepare a query to join books and authors
var query = from book in Repository
join author in _authorRepository 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)
{
//Prepare a query to join books and authors
var query = from book in Repository
join author in _authorRepository on book.AuthorId equals author.Id
orderby input.Sorting
select new {book, author};
query = query
.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)
);
}
}
}
```
Let's see the changes we've done:
* Added `[Authorize(BookStorePermissions.Books.Default)]` to authorize the methods we've newly added/overrode (remember, authorize attribute is valid for all the methods of the class when it is declared for a class).
* Injected `IAuthorRepository` to query from the authors.
* Overrode the `GetAsync` method of the base `CrudAppService`, which returns a single `BookDto` object with the given `id`.
* Used a simple LINQ expression to join books and authors and query them together for the given book id.
* Used `AsyncExecuter.FirstOrDefaultAsync(...)` to execute the query and get a result. `AsyncExecuter` was previously used in the `AuthorAppService`. Check the [repository documentation](../Repositories.md) to understand why we've used it.
* Throws an `EntityNotFoundException` which results an `HTTP 404` (not found) result if requested book was not present in the database.
* Finally, created a `BookDto` object using the `ObjectMapper`, then assigning the `AuthorName` manually.
* Overrode the `GetListAsync` method of the base `CrudAppService`, which returns a list of books. The logic is similar to the previous method, so you can easily understand the code.
* Created a new method: `GetAuthorLookupAsync`. This simple gets all the authors. The UI uses this method to fill a dropdown list and select and author while creating/editing books.
{{else if DB=="Mongo"}}
```csharp
using System;
using System.Collections.Generic;
using System.Linq.Dynamic.Core;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Permissions;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
namespace Acme.BookStore.Books
{
[Authorize(BookStorePermissions.Books.Default)]
public class BookAppService :
CrudAppService<
Book, //The Book entity
BookDto, //Used to show books
Guid, //Primary key of the book entity
PagedAndSortedResultRequestDto, //Used for paging/sorting
CreateUpdateBookDto>, //Used to create/update a book
IBookAppService //implement the IBookAppService
{
private readonly IAuthorRepository _authorRepository;
public BookAppService(
IRepository<Book, Guid> repository,
IAuthorRepository authorRepository)
: base(repository)
{
_authorRepository = authorRepository;
GetPolicyName = BookStorePermissions.Books.Default;
GetListPolicyName = BookStorePermissions.Books.Default;
CreatePolicyName = BookStorePermissions.Books.Create;
UpdatePolicyName = BookStorePermissions.Books.Edit;
DeletePolicyName = BookStorePermissions.Books.Create;
}
public override async Task<BookDto> GetAsync(Guid id)
{
var book = await Repository.GetAsync(id);
var bookDto = ObjectMapper.Map<Book, BookDto>(book);
var author = await _authorRepository.GetAsync(book.AuthorId);
bookDto.AuthorName = author.Name;
return bookDto;
}
public override async Task<PagedResultDto<BookDto>>
GetListAsync(PagedAndSortedResultRequestDto input)
{
//Set a default sorting, if not provided
if (input.Sorting.IsNullOrWhiteSpace())
{
input.Sorting = nameof(Book.Name);
}
//Get the books
var books = await AsyncExecuter.ToListAsync(
Repository
.OrderBy(input.Sorting)
.Skip(input.SkipCount)
.Take(input.MaxResultCount)
);
//Convert to DTOs
var bookDtos = ObjectMapper.Map<List<Book>, List<BookDto>>(books);
//Get a lookup dictionary for the related authors
var authorDictionary = await GetAuthorDictionaryAsync(books);
//Set AuthorName for the DTOs
bookDtos.ForEach(bookDto => bookDto.AuthorName =
authorDictionary[bookDto.AuthorId].Name);
//Get the total count with another query (required for the paging)
var totalCount = await Repository.GetCountAsync();
return new PagedResultDto<BookDto>(
totalCount,
bookDtos
);
}
public async Task<ListResultDto<AuthorLookupDto>> GetAuthorLookupAsync()
{
var authors = await _authorRepository.GetListAsync();
return new ListResultDto<AuthorLookupDto>(
ObjectMapper.Map<List<Author>, List<AuthorLookupDto>>(authors)
);
}
private async Task<Dictionary<Guid, Author>>
GetAuthorDictionaryAsync(List<Book> books)
{
var authorIds = books
.Select(b => b.AuthorId)
.Distinct()
.ToArray();
var authors = await AsyncExecuter.ToListAsync(
_authorRepository.Where(a => authorIds.Contains(a.Id))
);
return authors.ToDictionary(x => x.Id, x => x);
}
}
}
```
Let's see the changes we've done:
* Added `[Authorize(BookStorePermissions.Books.Default)]` to authorize the methods we've newly added/overrode (remember, authorize attribute is valid for all the methods of the class when it is declared for a class).
* Injected `IAuthorRepository` to query from the authors.
* Overrode the `GetAsync` method of the base `CrudAppService`, which returns a single `BookDto` object with the given `id`.
* Overrode the `GetListAsync` method of the base `CrudAppService`, which returns a list of books. This code separately queries the authors from database and sets the name of the authors in the application code. Instead, you could create a custom repository method and perform a join query or take the power of the MongoDB API to get the books and their authors in a single query, which would be more performant.
* Created a new method: `GetAuthorLookupAsync`. This simple gets all the authors. The UI uses this method to fill a dropdown list and select and author while creating/editing books.
{{end}}
### Object to Object Mapping Configuration
Introduced the `AuthorLookupDto` class and used object mapping inside the `GetAuthorLookupAsync` method. So, we need to add a new mapping definition inside the `BookStoreApplicationAutoMapperProfile.cs` file of the `Acme.BookStore.Application` project:
````csharp
CreateMap<Author, AuthorLookupDto>();
````
## Unit Tests
Some of the unit tests will fail since we made some changed on the `AuthorAppService`. Open the `BookAppService_Tests` in the `Books` folder of the `Acme.BookStore.Application.Tests` project and change the content as the following:
```csharp
using System;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Shouldly;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Validation;
using Xunit;
namespace Acme.BookStore.Books
{ {{if DB=="Mongo"}}
[Collection(BookStoreTestConsts.CollectionDefinitionName)]{{end}}
public class BookAppService_Tests : BookStoreApplicationTestBase
{
private readonly IBookAppService _bookAppService;
private readonly IAuthorAppService _authorAppService;
public BookAppService_Tests()
{
_bookAppService = GetRequiredService<IBookAppService>();
_authorAppService = GetRequiredService<IAuthorAppService>();
}
[Fact]
public async Task Should_Get_List_Of_Books()
{
//Act
var result = await _bookAppService.GetListAsync(
new PagedAndSortedResultRequestDto()
);
//Assert
result.TotalCount.ShouldBeGreaterThan(0);
result.Items.ShouldContain(b => b.Name == "1984" &&
b.AuthorName == "George Orwell");
}
[Fact]
public async Task Should_Create_A_Valid_Book()
{
var authors = await _authorAppService.GetListAsync(new GetAuthorListDto());
var firstAuthor = authors.Items.First();
//Act
var result = await _bookAppService.CreateAsync(
new CreateUpdateBookDto
{
AuthorId = firstAuthor.Id,
Name = "New test book 42",
Price = 10,
PublishDate = System.DateTime.Now,
Type = BookType.ScienceFiction
}
);
//Assert
result.Id.ShouldNotBe(Guid.Empty);
result.Name.ShouldBe("New test book 42");
}
[Fact]
public async Task Should_Not_Create_A_Book_Without_Name()
{
var exception = await Assert.ThrowsAsync<AbpValidationException>(async () =>
{
await _bookAppService.CreateAsync(
new CreateUpdateBookDto
{
Name = "",
Price = 10,
PublishDate = DateTime.Now,
Type = BookType.ScienceFiction
}
);
});
exception.ValidationErrors
.ShouldContain(err => err.MemberNames.Any(m => m == "Name"));
}
}
}
```
* Changed the assertion condition in the `Should_Get_List_Of_Books` from `b => b.Name == "1984"` to `b => b.Name == "1984" && b.AuthorName == "George Orwell"` to check if the author name was filled.
* Changed the `Should_Create_A_Valid_Book` method to set the `AuthorId` while creating a new book, since it is required anymore.
## The User Interface
{{if UI=="MVC"}}
### The Book List
Book list page change is trivial. Open the `Pages/Books/Index.js` in the `Acme.BookStore.Web` project and add the following column definition between the `name` and `type` columns:
````js
...
{
title: l('Name'),
data: "name"
},
// ADDED the NEW AUTHOR NAME COLUMN
{
title: l('Author'),
data: "authorName"
},
{
title: l('Type'),
data: "type",
render: function (data) {
return l('Enum:BookType:' + data);
}
},
...
````
When you run the application, you can see the *Author* column on the table:
![bookstore-added-author-to-book-list](images/bookstore-added-author-to-book-list.png)
### Create Modal
Open the `Pages/Books/CreateModal.cshtml.cs` in the `Acme.BookStore.Web` project and change the file content as shown below:
```csharp
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Books;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;
namespace Acme.BookStore.Web.Pages.Books
{
public class CreateModalModel : BookStorePageModel
{
[BindProperty]
public CreateBookViewModel Book { get; set; }
public List<SelectListItem> Authors { get; set; }
private readonly IBookAppService _bookAppService;
public CreateModalModel(
IBookAppService bookAppService)
{
_bookAppService = bookAppService;
}
public async Task OnGetAsync()
{
Book = new CreateBookViewModel();
var authorLookup = await _bookAppService.GetAuthorLookupAsync();
Authors = authorLookup.Items
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToList();
}
public async Task<IActionResult> OnPostAsync()
{
await _bookAppService.CreateAsync(
ObjectMapper.Map<CreateBookViewModel, CreateUpdateBookDto>(Book)
);
return NoContent();
}
public class CreateBookViewModel
{
[SelectItems(nameof(Authors))]
[DisplayName("Author")]
public Guid AuthorId { get; set; }
[Required]
[StringLength(128)]
public string Name { get; set; }
[Required]
public BookType Type { get; set; } = BookType.Undefined;
[Required]
[DataType(DataType.Date)]
public DateTime PublishDate { get; set; } = DateTime.Now;
[Required]
public float Price { get; set; }
}
}
}
```
* Changed type of the `Book` property from `CreateUpdateBookDto` to the new `CreateBookViewModel` class defined in this file. The main motivation of this change to customize the model class based on the User Interface (UI) requirements. We didn't want to use UI-related `[SelectItems(nameof(Authors))]` and `[DisplayName("Author")]` attributes inside the `CreateUpdateBookDto` class.
* Added `Authors` property that is filled inside the `OnGetAsync` method using the `IBookAppService.GetAuthorLookupAsync` method defined before.
* Changed the `OnPostAsync` method to map `CreateBookViewModel` object to a `CreateUpdateBookDto` object since `IBookAppService.CreateAsync` expects a parameter of this type.
### Edit Modal
Open the `Pages/Books/EditModal.cshtml.cs` in the `Acme.BookStore.Web` project and change the file content as shown below:
```csharp
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Books;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;
namespace Acme.BookStore.Web.Pages.Books
{
public class EditModalModel : BookStorePageModel
{
[BindProperty]
public EditBookViewModel Book { get; set; }
public List<SelectListItem> Authors { get; set; }
private readonly IBookAppService _bookAppService;
public EditModalModel(IBookAppService bookAppService)
{
_bookAppService = bookAppService;
}
public async Task OnGetAsync(Guid id)
{
var bookDto = await _bookAppService.GetAsync(id);
Book = ObjectMapper.Map<BookDto, EditBookViewModel>(bookDto);
var authorLookup = await _bookAppService.GetAuthorLookupAsync();
Authors = authorLookup.Items
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToList();
}
public async Task<IActionResult> OnPostAsync()
{
await _bookAppService.UpdateAsync(
Book.Id,
ObjectMapper.Map<EditBookViewModel, CreateUpdateBookDto>(Book)
);
return NoContent();
}
public class EditBookViewModel
{
[HiddenInput]
public Guid Id { get; set; }
[SelectItems(nameof(Authors))]
[DisplayName("Author")]
public Guid AuthorId { get; set; }
[Required]
[StringLength(128)]
public string Name { get; set; }
[Required]
public BookType Type { get; set; } = BookType.Undefined;
[Required]
[DataType(DataType.Date)]
public DateTime PublishDate { get; set; } = DateTime.Now;
[Required]
public float Price { get; set; }
}
}
}
```
* Changed type of the `Book` property from `CreateUpdateBookDto` to the new `EditBookViewModel` class defined in this file, just like done before for the create modal above.
* Moved the `Id` property inside the new `EditBookViewModel` class.
* Added `Authors` property that is filled inside the `OnGetAsync` method using the `IBookAppService.GetAuthorLookupAsync` method.
* Changed the `OnPostAsync` method to map `EditBookViewModel` object to a `CreateUpdateBookDto` object since `IBookAppService.UpdateAsync` expects a parameter of this type.
These changes require a small change in the `EditModal.cshtml`. Remove the `<abp-input asp-for="Id" />` tag since we no longer need to it (since moved it to the `EditBookViewModel`). The final content of the `EditModal.cshtml` should be following:
````html
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Web.Pages.Books
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model EditModalModel
@inject IStringLocalizer<BookStoreResource> L
@{
Layout = null;
}
<abp-dynamic-form abp-model="Book" asp-page="/Books/EditModal">
<abp-modal>
<abp-modal-header title="@L["Update"].Value"></abp-modal-header>
<abp-modal-body>
<abp-form-content />
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>
</abp-dynamic-form>
````
### Object to Object Mapping Configuration
The changes above requires to define some object to object mappings. Open the `BookStoreWebAutoMapperProfile.cs` in the `Acme.BookStore.Web` project and add the following mapping definitions inside the constructor:
```csharp
CreateMap<Pages.Books.CreateModalModel.CreateBookViewModel, CreateUpdateBookDto>();
CreateMap<BookDto, Pages.Books.EditModalModel.EditBookViewModel>();
CreateMap<Pages.Books.EditModalModel.EditBookViewModel, CreateUpdateBookDto>();
```
You can run the application and try to create a new book or update an existing book. You will see a drop down list on the create/update form to select the author of the book:
![bookstore-added-authors-to-modals](images/bookstore-added-authors-to-modals.png)
{{else if UI=="NG"}}
***Angular UI is being prepared...***
{{end}}

14
docs/en/Tutorials/Part-2.md

@ -37,6 +37,11 @@ This tutorial is organized as the following parts;
- [Part 3: Creating, updating and deleting books](Part-3.md)
- [Part 4: Integration tests](Part-4.md)
- [Part 5: Authorization](Part-5.md)
- [Part 6: Authors: Domain layer](Part-6.md)
- [Part 7: Authors: Database Integration](Part-7.md)
- [Part 8: Authors: Application Layer](Part-8.md)
- [Part 9: Authors: User Interface](Part-9.md)
- [Part 10: Book to Author Relation](Part-10.md)
### Download the Source Code
@ -457,12 +462,9 @@ For more information, see the [RoutesService document](https://docs.abp.io/en/ab
Run the following command in the `angular` folder:
```bash
abp generate-proxy --apiUrl https://localhost:XXXXX
abp generate-proxy
```
* XXXXX should be replaced with the backend port of your application.
* If you don't specify the `--apiUrl` parameter, it will try to get the URL from the `src/environments/environment.ts` file.
The generated files looks like below:
![Generated files](./images/generated-proxies-2.png)
@ -474,7 +476,7 @@ Open the `/src/app/book/book.component.ts` file and replace the content as below
```js
import { ListService, PagedResultDto } from '@abp/ng.core';
import { Component, OnInit } from '@angular/core';
import { BookDto, BookType } from './models';
import { BookDto } from './models';
import { BookService } from './services';
@Component({
@ -486,8 +488,6 @@ import { BookService } from './services';
export class BookComponent implements OnInit {
book = { items: [], totalCount: 0 } as PagedResultDto<BookDto>;
booksType = BookType;
constructor(public readonly list: ListService, private bookService: BookService) {}
ngOnInit() {

47
docs/en/Tutorials/Part-3.md

@ -32,11 +32,16 @@ In this tutorial series, you will build an ABP based web application named `Acme
This tutorial is organized as the following parts;
- [Part 1: Creating the project and book list page](Part-1.md)
- [Part 1: Creating the server side](Part-1.md)
- [Part 2: The book list page](Part-2.md)
- **Part 3: Creating, updating and deleting books (this part)**
- [Part 4: Integration tests](Part-4.md)
- [Part 5: Authorization](Part-5.md)
- [Part 6: Authors: Domain layer](Part-6.md)
- [Part 7: Authors: Database Integration](Part-7.md)
- [Part 8: Authors: Application Layer](Part-8.md)
- [Part 9: Authors: User Interface](Part-9.md)
- [Part 10: Book to Author Relation](Part-10.md)
### Download the Source Code
@ -643,7 +648,7 @@ Open `/src/app/book/book.component.ts` and replace the content as below:
```js
import { ListService, PagedResultDto } from '@abp/ng.core';
import { Component, OnInit } from '@angular/core';
import { BookDto, BookType } from './models';
import { BookDto } from './models';
import { BookService } from './services';
@Component({
@ -655,8 +660,6 @@ import { BookService } from './services';
export class BookComponent implements OnInit {
book = { items: [], totalCount: 0 } as PagedResultDto<BookDto>;
booksType = BookType;
isModalOpen = false; // add this line
constructor(public readonly list: ListService, private bookService: BookService) {}
@ -738,7 +741,7 @@ Open `/src/app/book/book.component.ts` and replace the content as below:
```js
import { ListService, PagedResultDto } from '@abp/ng.core';
import { Component, OnInit } from '@angular/core';
import { BookDto, BookType } from './models';
import { BookDto, BookType } from './models'; // add BookType
import { BookService } from './services';
import { FormGroup, FormBuilder, Validators } from '@angular/forms'; // add this
@ -751,13 +754,13 @@ import { FormGroup, FormBuilder, Validators } from '@angular/forms'; // add this
export class BookComponent implements OnInit {
book = { items: [], totalCount: 0 } as PagedResultDto<BookDto>;
booksType = BookType;
form: FormGroup; // add this line
// add bookTypes as a list of enum members
bookTypes = Object.keys(BookType).filter(
(bookType) => typeof this.booksType[bookType] === 'number'
bookType = BookType; // add this line
// add bookTypes as a list of BookType enum members
bookTypes = Object.keys(this.bookType).filter(
(key) => typeof this.bookType[key] === 'number'
);
isModalOpen = false;
@ -808,7 +811,8 @@ export class BookComponent implements OnInit {
* Imported `FormGroup`, `FormBuilder` and `Validators` from `@angular/forms`.
* Added `form: FormGroup` property.
* Add `bookTypes` as a list of `BookType` enum members.
* Added `bookType` property so that you can reach `BookType` enum members from template.
* Added `bookTypes` property as a list of `BookType` enum members. That will be used in form options.
* Injected `FormBuilder` into the constructor. [FormBuilder](https://angular.io/api/forms/FormBuilder) provides convenient methods for generating form controls. It reduces the amount of boilerplate needed to build complex forms.
* Added `buildForm` method to the end of the file and executed the `buildForm()` in the `createBook` method.
* Added `save` method.
@ -832,7 +836,7 @@ Open `/src/app/book/book.component.html` and replace `<ng-template #abpBody> </n
<label for="book-type">Type</label><span> * </span>
<select class="form-control" id="book-type" formControlName="type">
<option [ngValue]="null">Select a book type</option>
<option [ngValue]="booksType[type]" *ngFor="let type of bookTypes"> {%{{{ type }}}%}</option>
<option [ngValue]="bookType[type]" *ngFor="let type of bookTypes"> {%{{{ type }}}%}</option>
</select>
</div>
@ -917,13 +921,12 @@ import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap
export class BookComponent implements OnInit {
book = { items: [], totalCount: 0 } as PagedResultDto<BookDto>;
booksType = BookType;
form: FormGroup;
// <== added bookTypes array ==>
bookTypes = Object.keys(BookType).filter(
(bookType) => typeof this.booksType[bookType] === 'number'
bookType = BookType;
bookTypes = Object.keys(this.bookType).filter(
(key) => typeof this.bookType[key] === 'number'
);
isModalOpen = false;
@ -998,14 +1001,14 @@ import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap
export class BookComponent implements OnInit {
book = { items: [], totalCount: 0 } as PagedResultDto<BookDto>;
booksType = BookType;
selectedBook = new BookDto(); // declare selectedBook
form: FormGroup;
selectedBook = new BookDto(); // declare selectedBook
bookType = BookType;
bookTypes = Object.keys(BookType).filter(
(bookType) => typeof this.booksType[bookType] === 'number'
bookTypes = Object.keys(this.bookType).filter(
(key) => typeof this.bookType[key] === 'number'
);
isModalOpen = false;
@ -1182,4 +1185,4 @@ Clicking the "Delete" action calls the `delete` method which then shows a confir
## The Next Part
See the [next part](part-4.md) of this tutorial.
See the [next part](Part-4.md) of this tutorial.

9
docs/en/Tutorials/Part-4.md

@ -32,11 +32,16 @@ In this tutorial series, you will build an ABP based web application named `Acme
This tutorial is organized as the following parts;
- [Part 1: Creating the project and book list page](Part-1.md)
- [Part 1: Creating the server side](Part-1.md)
- [Part 2: The book list page](Part-2.md)
- [Part 3: Creating, updating and deleting books](Part-3.md)
- **Part 4: Integration tests (this part)**
- [Part 5: Authorization](Part-5.md)
- [Part 6: Authors: Domain layer](Part-6.md)
- [Part 7: Authors: Database Integration](Part-7.md)
- [Part 8: Authors: Application Layer](Part-8.md)
- [Part 9: Authors: User Interface](Part-9.md)
- [Part 10: Book to Author Relation](Part-10.md)
### Download the Source Code
@ -247,4 +252,4 @@ Congratulations, the **green icons** indicates that the tests have been successf
## The Next Part
See the [next part](part-5.md) of this tutorial.
See the [next part](Part-5.md) of this tutorial.

10
docs/en/Tutorials/Part-5.md

@ -32,11 +32,16 @@ In this tutorial series, you will build an ABP based web application named `Acme
This tutorial is organized as the following parts;
- [Part 1: Creating the project and book list page](Part-1.md)
- [Part 1: Creating the server side](Part-1.md)
- [Part 2: The book list page](Part-2.md)
- [Part 3: Creating, updating and deleting books](Part-3.md)
- [Part 4: Integration tests](Part-4.md)
- **Part 5: Authorization (this part)**
- [Part 6: Authors: Domain layer](Part-6.md)
- [Part 7: Authors: Database Integration](Part-7.md)
- [Part 8: Authors: Application Layer](Part-8.md)
- [Part 9: Authors: User Interface](Part-9.md)
- [Part 10: Book to Author Relation](Part-10.md)
### Download the Source Code
@ -399,3 +404,6 @@ Open the `/src/app/book/book.component.html` file and replace the edit and delet
{{end}}
## The Next Part
See the [next part](Part-6.md) of this tutorial.

289
docs/en/Tutorials/Part-6.md

@ -0,0 +1,289 @@
# Web Application Development Tutorial - Part 6: Authors: Domain Layer
````json
//[doc-params]
{
"UI": ["MVC","NG"],
"DB": ["EF","Mongo"]
}
````
{{
if UI == "MVC"
UI_Text="mvc"
else if UI == "NG"
UI_Text="angular"
else
UI_Text="?"
end
if DB == "EF"
DB_Text="Entity Framework Core"
else if DB == "Mongo"
DB_Text="MongoDB"
else
DB_Text="?"
end
}}
## About This Tutorial
In this tutorial series, you will build an ABP based web application named `Acme.BookStore`. This application is used to manage a list of books and their authors. It is developed using the following technologies:
* **{{DB_Text}}** as the ORM provider.
* **{{UI_Value}}** as the UI Framework.
This tutorial is organized as the following parts;
- [Part 1: Creating the server side](Part-1.md)
- [Part 2: The book list page](Part-2.md)
- [Part 3: Creating, updating and deleting books](Part-3.md)
- [Part 4: Integration tests](Part-4.md)
- [Part 5: Authorization](Part-5.md)
- **Part 6: Authors: Domain layer (this part)**
- [Part 7: Authors: Database Integration](Part-7.md)
- [Part 8: Authors: Application Layer](Part-8.md)
- [Part 9: Authors: User Interface](Part-9.md)
- [Part 10: Book to Author Relation](Part-10.md)
### Download the Source Code
This tutorials has multiple versions based on your **UI** and **Database** preferences. We've prepared two combinations of the source code to be downloaded:
* [MVC (Razor Pages) UI with EF Core](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore)
* [Angular UI with MongoDB](https://github.com/abpframework/abp-samples/tree/master/BookStore-Angular-MongoDb)
## Introduction
In the previous parts, we've used the ABP infrastructure to easily build some services;
* Used the [CrudAppService](../Application-Services.md) base class instead of manually developing an application service for standard create, read, update and delete operations.
* Used [generic repositories](../Repositories.md) to completely automate the database layer.
For the "Authors" part;
* We will **do some of the things manually** to show how you can do it in case of need.
* We will implement some **Domain Driven Design (DDD) best practices**.
> **The development will be done layer by layer to concentrate on an individual layer in one time. In a real project, you will develop your application feature by feature (vertical) as done in the previous parts. In this way, you will experience both approaches.**
## The Author Entity
Create an `Authors` folder (namespace) in the `Acme.BookStore.Domain` project and add an `Author` class inside it:
````csharp
using System;
using JetBrains.Annotations;
using Volo.Abp;
using Volo.Abp.Domain.Entities.Auditing;
namespace Acme.BookStore.Authors
{
public class Author : FullAuditedAggregateRoot<Guid>
{
public string Name { get; private set; }
public DateTime BirthDate { get; set; }
public string ShortBio { get; set; }
private Author()
{
/* This constructor is for deserialization / ORM purpose */
}
internal Author(
Guid id,
[NotNull] string name,
DateTime birthDate,
[CanBeNull] string shortBio = null)
: base(id)
{
SetName(name);
BirthDate = birthDate;
ShortBio = shortBio;
}
internal Author ChangeName([NotNull] string name)
{
SetName(name);
return this;
}
private void SetName([NotNull] string name)
{
Name = Check.NotNullOrWhiteSpace(
name,
nameof(name),
maxLength: AuthorConsts.MaxNameLength
);
}
}
}
````
* Inherited from `FullAuditedAggregateRoot<Guid>` which makes the entity [soft delete](../Data-Filtering.md) (that means when you delete it, it is not deleted in the database, but just marked as deleted) with all the [auditing](../Entities.md) properties.
* `private set` for the `Name` property restricts to set this property from out of this class. There are two ways of setting the name (in both cases, we validate the name):
* In the constructor, while creating a new author.
* Using the `ChangeName` method to update the name later.
* The `constructor` and the `ChangeName` method is `internal` to force to use these methods only in the domain layer, using the `AuthorManager` that will be explained later.
* `Check` class is an ABP Framework utility class to help you while checking method arguments (it throws `ArgumentException` on an invalid case).
`AuthorConsts` is a simple class that is located under the `Authors` namespace (folder) of the `Acme.BookStore.Domain.Shared` project:
````csharp
namespace Acme.BookStore.Authors
{
public static class AuthorConsts
{
public const int MaxNameLength = 64;
}
}
````
Created this class inside the `Acme.BookStore.Domain.Shared` project since we will re-use it on the [Data Transfer Objects](../Data-Transfer-Objects.md) (DTOs) later.
## AuthorManager: The Domain Service
`Author` constructor and `ChangeName` method is `internal`, so they can be usable only in the domain layer. Create an `AuthorManager` class in the `Authors` folder (namespace) of the `Acme.BookStore.Domain` project:
````csharp
using System;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Volo.Abp;
using Volo.Abp.Domain.Services;
namespace Acme.BookStore.Authors
{
public class AuthorManager : DomainService
{
private readonly IAuthorRepository _authorRepository;
public AuthorManager(IAuthorRepository authorRepository)
{
_authorRepository = authorRepository;
}
public async Task<Author> CreateAsync(
[NotNull] string name,
DateTime birthDate,
[CanBeNull] string shortBio = null)
{
Check.NotNullOrWhiteSpace(name, nameof(name));
var existingAuthor = await _authorRepository.FindByNameAsync(name);
if (existingAuthor != null)
{
throw new AuthorAlreadyExistsException(name);
}
return new Author(
GuidGenerator.Create(),
name,
birthDate,
shortBio
);
}
public async Task ChangeNameAsync(
[NotNull] Author author,
[NotNull] string newName)
{
Check.NotNull(author, nameof(author));
Check.NotNullOrWhiteSpace(newName, nameof(newName));
var existingAuthor = await _authorRepository.FindByNameAsync(newName);
if (existingAuthor != null && existingAuthor.Id != author.Id)
{
throw new AuthorAlreadyExistsException(newName);
}
author.ChangeName(newName);
}
}
}
````
* `AuthorManager` forces to create an author and change name of an author in a controlled way. The application layer (will be introduced later) will use these methods.
> **DDD tip**: Do not introduce domain service methods unless they are really needed and perform some core business rules. For this case, we needed to this service to be able to force the unique name constraint.
Both methods checks if there is already an author with the given name and throws a special business exception, `AuthorAlreadyExistsException`, defined in the `Acme.BookStore.Domain` project as shown below:
````csharp
using Volo.Abp;
namespace Acme.BookStore.Authors
{
public class AuthorAlreadyExistsException : BusinessException
{
public AuthorAlreadyExistsException(string name)
: base(BookStoreDomainErrorCodes.AuthorAlreadyExists)
{
WithData("name", name);
}
}
}
````
`BusinessException` is a special exception type. It is a good practice to throw domain related exceptions when needed. It is automatically handled by the ABP Framework and can be easily localized. `WithData(...)` method is used to provide additional data to the exception object that will later be used on the localization message or for some other purpose.
Open the `BookStoreDomainErrorCodes` in the `Acme.BookStore.Domain.Shared` project and change as shown below:
````csharp
namespace Acme.BookStore
{
public static class BookStoreDomainErrorCodes
{
public const string AuthorAlreadyExists = "BookStore:00001";
}
}
````
This is a unique string represents the error code thrown by your application and can be handled by client applications. For users, you probably want to localize it. Open the `Localization/BookStore/en.json` inside the `Acme.BookStore.Domain.Shared` project and add the following entry:
````json
"BookStore:00001": "There is already an author with the same name: {name}"
````
Whenever you throw an `AuthorAlreadyExistsException`, the end use will see a nice error message on the UI.
## IAuthorRepository
`AuthorManager` injects the `IAuthorRepository`, so we need to define it. Create this new interface in the `Authors` folder (namespace) of the `Acme.BookStore.Domain` project:
````csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Domain.Repositories;
namespace Acme.BookStore.Authors
{
public interface IAuthorRepository : IRepository<Author, Guid>
{
Task<Author> FindByNameAsync(string name);
Task<List<Author>> GetListAsync(
int skipCount,
int maxResultCount,
string sorting,
string filter = null
);
}
}
````
* `IAuthorRepository` extends the standard `IRepository<Author, Guid>` interface, so all the standard [repository](../Repositories.md) methods will also be available for the `IAuthorRepository`.
* `FindByNameAsync` was used in the `AuthorManager` to query an author by name.
* `GetListAsync` will be used in the application layer to get a listed, sorted and filtered list of authors to show on the UI.
We will implement this repository in the next part.
> Both of these methods might **seem unnecessary** since the standard repositories already `IQueryable` and you can directly use them instead of defining such custom methods. You're right and do it like in a real application. However, for this **"learning" tutorial**, it is useful to explain how to create custom repository methods when you really need it.
## Conclusion
This part covered the domain layer of the authors functionality of the book store application. The main files created/updated in this part was highlighted in the picture below:
![bookstore-author-domain-layer](images/bookstore-author-domain-layer.png)
## The Next Part
See the [next part](Part-7.md) of this tutorial.

236
docs/en/Tutorials/Part-7.md

@ -0,0 +1,236 @@
# Web Application Development Tutorial - Part 7: Authors: Database Integration
````json
//[doc-params]
{
"UI": ["MVC","NG"],
"DB": ["EF","Mongo"]
}
````
{{
if UI == "MVC"
UI_Text="mvc"
else if UI == "NG"
UI_Text="angular"
else
UI_Text="?"
end
if DB == "EF"
DB_Text="Entity Framework Core"
else if DB == "Mongo"
DB_Text="MongoDB"
else
DB_Text="?"
end
}}
## About This Tutorial
In this tutorial series, you will build an ABP based web application named `Acme.BookStore`. This application is used to manage a list of books and their authors. It is developed using the following technologies:
* **{{DB_Text}}** as the ORM provider.
* **{{UI_Value}}** as the UI Framework.
This tutorial is organized as the following parts;
- [Part 1: Creating the server side](Part-1.md)
- [Part 2: The book list page](Part-2.md)
- [Part 3: Creating, updating and deleting books](Part-3.md)
- [Part 4: Integration tests](Part-4.md)
- [Part 5: Authorization](Part-5.md)
- [Part 6: Authors: Domain layer](Part-6.md)
- **Part 7: Authors: Database Integration (this part)**
- [Part 8: Authors: Application Layer](Part-8.md)
- [Part 9: Authors: User Interface](Part-9.md)
- [Part 10: Book to Author Relation](Part-10.md)
### Download the Source Code
This tutorials has multiple versions based on your **UI** and **Database** preferences. We've prepared two combinations of the source code to be downloaded:
* [MVC (Razor Pages) UI with EF Core](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore)
* [Angular UI with MongoDB](https://github.com/abpframework/abp-samples/tree/master/BookStore-Angular-MongoDb)
## Introduction
This part explains how to configure the database integration for the `Author` entity introduced in the previous part.
{{if DB=="EF"}}
## DB Context
Open the `BookStoreDbContext` in the `Acme.BookStore.EntityFrameworkCore` project and add the following `DbSet` property:
````csharp
public DbSet<Author> Authors { get; set; }
````
Then open the `BookStoreDbContextModelCreatingExtensions` class in the same project and add the following lines to the end of the `ConfigureBookStore` method:
````csharp
builder.Entity<Author>(b =>
{
b.ToTable(BookStoreConsts.DbTablePrefix + "Authors",
BookStoreConsts.DbSchema);
b.ConfigureByConvention();
b.Property(x => x.Name)
.IsRequired()
.HasMaxLength(AuthorConsts.MaxNameLength);
b.HasIndex(x => x.Name);
});
````
This is just like done for the `Book` entity before, so no need to explain again.
## Create a new Database Migration
Open the **Package Manager Console** on Visual Studio and ensure that the **Default project** is `Acme.BookStore.EntityFrameworkCore.DbMigrations` in the Package Manager Console, as shown on the picture below. Also, set the `Acme.BookStore.Web` as the startup project (right click it on the solution explorer and click to "Set as Startup Project").
Run the following command to create a new database migration:
![bookstore-add-migration-authors](images/bookstore-add-migration-authors.png)
This will create a new migration class. Then run the `Update-Database` command to create the table on the database.
> See the [Microsoft's documentation](https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/) for more about the EF Core database migrations.
{{else if DB=="Mongo"}}
## DB Context
Open the `BookStoreMongoDbContext` in the `MongoDb` folder of the `Acme.BookStore.MongoDB` project and add the following property to the class:
````csharp
public IMongoCollection<Author> Authors => Collection<Author>();
````
{{end}}
## Implementing the IAuthorRepository
{{if DB=="EF"}}
Create a new class, named `EfCoreAuthorRepository` inside the `Acme.BookStore.EntityFrameworkCore` project (in the `Authors` folder) and paste the following code:
````csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Dynamic.Core;
using System.Threading.Tasks;
using Acme.BookStore.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
namespace Acme.BookStore.Authors
{
public class EfCoreAuthorRepository
: EfCoreRepository<BookStoreDbContext, Author, Guid>,
IAuthorRepository
{
public EfCoreAuthorRepository(
IDbContextProvider<BookStoreDbContext> dbContextProvider)
: base(dbContextProvider)
{
}
public async Task<Author> FindByNameAsync(string name)
{
return await DbSet.FirstOrDefaultAsync(author => author.Name == name);
}
public async Task<List<Author>> GetListAsync(
int skipCount,
int maxResultCount,
string sorting,
string filter = null)
{
return await DbSet
.WhereIf(
!filter.IsNullOrWhiteSpace(),
author => author.Name.Contains(filter)
)
.OrderBy(sorting)
.Skip(skipCount)
.Take(maxResultCount)
.ToListAsync();
}
}
}
````
* Inherited from the `EfCoreAuthorRepository`, so it inherits the standard repository method implementations.
* `WhereIf` is a shortcut extension method of the ABP Framework. It adds the `Where` condition only if the first condition meets (it filters by name, only if the filter was provided). You could do the same yourself, but these type of shortcut methods makes our life easier.
* `sorting` can be a string like `Name`, `Name ASC` or `Name DESC`. It is possible by using the [System.Linq.Dynamic.Core](https://www.nuget.org/packages/System.Linq.Dynamic.Core) NuGet package.
> See the [EF Core Integration document](../Entity-Framework-Core.md) for more information on the EF Core based repositories.
{{else if DB=="Mongo"}}
Create a new class, named `MongoDbAuthorRepository` inside the `Acme.BookStore.MongoDB` project (in the `Authors` folder) and paste the following code:
```csharp
using System;
using System.Linq;
using System.Linq.Dynamic.Core;
using System.Collections.Generic;
using System.Threading.Tasks;
using Acme.BookStore.MongoDB;
using MongoDB.Driver;
using MongoDB.Driver.Linq;
using Volo.Abp.Domain.Repositories.MongoDB;
using Volo.Abp.MongoDB;
namespace Acme.BookStore.Authors
{
public class MongoDbAuthorRepository
: MongoDbRepository<BookStoreMongoDbContext, Author, Guid>,
IAuthorRepository
{
public MongoDbAuthorRepository(
IMongoDbContextProvider<BookStoreMongoDbContext> dbContextProvider
) : base(dbContextProvider)
{
}
public async Task<Author> FindByNameAsync(string name)
{
return await GetMongoQueryable()
.FirstOrDefaultAsync(author => author.Name == name);
}
public async Task<List<Author>> GetListAsync(
int skipCount,
int maxResultCount,
string sorting,
string filter = null)
{
return await GetMongoQueryable()
.WhereIf<Author, IMongoQueryable<Author>>(
!filter.IsNullOrWhiteSpace(),
author => author.Name.Contains(filter)
)
.OrderBy(sorting)
.As<IMongoQueryable<Author>>()
.Skip(skipCount)
.Take(maxResultCount)
.ToListAsync();
}
}
}
```
* Inherited from the `MongoDbAuthorRepository`, so it inherits the standard repository method implementations.
* `WhereIf` is a shortcut extension method of the ABP Framework. It adds the `Where` condition only if the first condition meets (it filters by name, only if the filter was provided). You could do the same yourself, but these type of shortcut methods makes our life easier.
* `sorting` can be a string like `Name`, `Name ASC` or `Name DESC`. It is possible by using the [System.Linq.Dynamic.Core](https://www.nuget.org/packages/System.Linq.Dynamic.Core) NuGet package.
> See the [MongoDB Integration document](../MongoDB.md) for more information on the MongoDB based repositories.
{{end}}
## The Next Part
See the [next part](Part-8.md) of this tutorial.

575
docs/en/Tutorials/Part-8.md

@ -0,0 +1,575 @@
# Web Application Development Tutorial - Part 8: Authors: Application Layer
````json
//[doc-params]
{
"UI": ["MVC","NG"],
"DB": ["EF","Mongo"]
}
````
{{
if UI == "MVC"
UI_Text="mvc"
else if UI == "NG"
UI_Text="angular"
else
UI_Text="?"
end
if DB == "EF"
DB_Text="Entity Framework Core"
else if DB == "Mongo"
DB_Text="MongoDB"
else
DB_Text="?"
end
}}
## About This Tutorial
In this tutorial series, you will build an ABP based web application named `Acme.BookStore`. This application is used to manage a list of books and their authors. It is developed using the following technologies:
* **{{DB_Text}}** as the ORM provider.
* **{{UI_Value}}** as the UI Framework.
This tutorial is organized as the following parts;
- [Part 1: Creating the server side](Part-1.md)
- [Part 2: The book list page](Part-2.md)
- [Part 3: Creating, updating and deleting books](Part-3.md)
- [Part 4: Integration tests](Part-4.md)
- [Part 5: Authorization](Part-5.md)
- [Part 6: Authors: Domain layer](Part-6.md)
- [Part 7: Authors: Database Integration](Part-7.md)
- **Part 8: Author: Application Layer (this part)**
- [Part 9: Authors: User Interface](Part-9.md)
- [Part 10: Book to Author Relation](Part-10.md)
### Download the Source Code
This tutorials has multiple versions based on your **UI** and **Database** preferences. We've prepared two combinations of the source code to be downloaded:
* [MVC (Razor Pages) UI with EF Core](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore)
* [Angular UI with MongoDB](https://github.com/abpframework/abp-samples/tree/master/BookStore-Angular-MongoDb)
## Introduction
This part explains to create an application layer for the `Author` entity created before.
## IAuthorAppService
We will first create the [application service](../Application-Services.md) interface and the related [DTO](../Data-Transfer-Objects.md)s. Create a new interface, named `IAuthorAppService`, in the `Authors` namespace (folder) of the `Acme.BookStore.Application.Contracts` project:
````csharp
using System;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
namespace Acme.BookStore.Authors
{
public interface IAuthorAppService : IApplicationService
{
Task<AuthorDto> GetAsync(Guid id);
Task<PagedResultDto<AuthorDto>> GetListAsync(GetAuthorListDto input);
Task<AuthorDto> CreateAsync(CreateAuthorDto input);
Task UpdateAsync(Guid id, UpdateAuthorDto input);
Task DeleteAsync(Guid id);
}
}
````
* `IApplicationService` is a conventional interface that is inherited by all the application services, so the ABP Framework can identify the service.
* Defined standard methods to perform CRUD operations on the `Author` entity.
* `PagedResultDto` is a pre-defined DTO class in the ABP Framework. It has an `Items` collection and a `TotalCount` property to return a paged result.
* Preferred to return an `AuthorDto` (for the newly created author) from the `CreateAsync` method, while it is not used by this application - just to show a different usage.
This interface is using the DTOs defined below (create them for your project).
### AuthorDto
````csharp
using System;
using Volo.Abp.Application.Dtos;
namespace Acme.BookStore.Authors
{
public class AuthorDto : EntityDto<Guid>
{
public string Name { get; set; }
public DateTime BirthDate { get; set; }
public string ShortBio { get; set; }
}
}
````
* `EntityDto<T>` simply has an `Id` property with the given generic argument. You could create an `Id` property yourself instead of inheriting the `EntityDto<T>`.
### GetAuthorListDto
````csharp
using Volo.Abp.Application.Dtos;
namespace Acme.BookStore.Authors
{
public class GetAuthorListDto : PagedAndSortedResultRequestDto
{
public string Filter { get; set; }
}
}
````
* `Filter` is used to search authors. It can be `null` (or empty string) to get all the authors.
* `PagedAndSortedResultRequestDto` has the standard paging and sorting properties: `int MaxResultCount`, `int SkipCount` and `string Sorting`.
> ABP Framework has such base DTO classes to simplify and standardize your DTOs. See the [DTO documentation](../Data-Transfer-Objects.md) for all.
### CreateAuthorDto
````csharp
using System;
using System.ComponentModel.DataAnnotations;
namespace Acme.BookStore.Authors
{
public class CreateAuthorDto
{
[Required]
[StringLength(AuthorConsts.MaxNameLength)]
public string Name { get; set; }
[Required]
public DateTime BirthDate { get; set; }
public string ShortBio { get; set; }
}
}
````
Data annotation attributes can be used to validate the DTO. See the [validation document](../Validation.md) for details.
### UpdateAuthorDto
````csharp
using System;
using System.ComponentModel.DataAnnotations;
namespace Acme.BookStore.Authors
{
public class UpdateAuthorDto
{
[Required]
[StringLength(AuthorConsts.MaxNameLength)]
public string Name { get; set; }
[Required]
public DateTime BirthDate { get; set; }
public string ShortBio { get; set; }
}
}
````
> We could share (re-use) the same DTO among the create and the update operations. While you can do it, we prefer to create different DTOs for these operations since we see they generally be different by the time. So, code duplication is reasonable here compared to a tightly coupled design.
## AuthorAppService
It is time to implement the `IAuthorAppService` interface. Create a new class, named `AuthorAppService` in the `Authors` namespace (folder) of the `Acme.BookStore.Application` project:
````csharp
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Acme.BookStore.Permissions;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp.Application.Dtos;
namespace Acme.BookStore.Authors
{
[Authorize(BookStorePermissions.Authors.Default)]
public class AuthorAppService : BookStoreAppService, IAuthorAppService
{
private readonly IAuthorRepository _authorRepository;
private readonly AuthorManager _authorManager;
public AuthorAppService(
IAuthorRepository authorRepository,
AuthorManager authorManager)
{
_authorRepository = authorRepository;
_authorManager = authorManager;
}
//...SERVICE METHODS WILL COME HERE...
}
}
````
* `[Authorize(BookStorePermissions.Authors.Default)]` is a declarative way to check a permission (policy) to authorize the current user. See the [authorization document](../Authorization.md) for more. `BookStorePermissions` class will be updated below, don't worry for the compile error for now.
* Derived from the `BookStoreAppService`, which is a simple base class comes with the startup template. It is derived from the standard `ApplicationService` class.
* Implemented the `IAuthorAppService` which was defined above.
* Injected the `IAuthorRepository` and `AuthorManager` to use in the service methods.
Now, we will introduce the service methods one by one. Copy the explained method into the `AuthorAppService` class.
### GetAsync
````csharp
public async Task<AuthorDto> GetAsync(Guid id)
{
var author = await _authorRepository.GetAsync(id);
return ObjectMapper.Map<Author, AuthorDto>(author);
}
````
This method simply gets the `Author` entity by its `Id`, converts to the `AuthorDto` using the [object to object mapper](../Object-To-Object-Mapping.md). This requires to configure the AutoMapper, which will be explained later.
### GetListAsync
````csharp
public async Task<PagedResultDto<AuthorDto>> GetListAsync(GetAuthorListDto input)
{
if (input.Sorting.IsNullOrWhiteSpace())
{
input.Sorting = nameof(Author.Name);
}
var authors = await _authorRepository.GetListAsync(
input.SkipCount,
input.MaxResultCount,
input.Sorting,
input.Filter
);
var totalCount = await AsyncExecuter.CountAsync(
_authorRepository.WhereIf(
!input.Filter.IsNullOrWhiteSpace(),
author => author.Name.Contains(input.Filter)
)
);
return new PagedResultDto<AuthorDto>(
totalCount,
ObjectMapper.Map<List<Author>, List<AuthorDto>>(authors)
);
}
````
* Default sorting is "by author name" which is done in the beginning of the method in case of it wasn't sent by the client.
* Used the `IAuthorRepository.GetListAsync` to get a paged, sorted and filtered list of authors from the database. We had implemented it in the previous part of this tutorial. Again, it actually was not needed to create such a method since we could directly query over the repository, but wanted to demonstrate how to create custom repository methods.
* Directly queried from the `AuthorRepository` while getting the count of the authors. We preferred to use the `AsyncExecuter` service which allows us to perform async queries without depending on the EF Core. However, you could depend on the EF Core package and directly use the `_authorRepository.WhereIf(...).ToListAsync()` method. See the [repository document](../Repositories.md) to read the alternative approaches and the discussion.
* Finally, returning a paged result by mapping the list of `Author`s to a list of `AuthorDto`s.
### CreateAsync
````csharp
[Authorize(BookStorePermissions.Authors.Create)]
public async Task<AuthorDto> CreateAsync(CreateAuthorDto input)
{
var author = await _authorManager.CreateAsync(
input.Name,
input.BirthDate,
input.ShortBio
);
await _authorRepository.InsertAsync(author);
return ObjectMapper.Map<Author, AuthorDto>(author);
}
````
* `CreateAsync` requires the `BookStorePermissions.Authors.Create` permission (in addition to the `BookStorePermissions.Authors.Default` declared for the `AuthorAppService` class).
* Used the `AuthorManeger` (domain service) to create a new author.
* Used the `IAuthorRepository.InsertAsync` to insert the new author to the database.
* Used the `ObjectMapper` to return an `AuthorDto` representing the newly created author.
> **DDD tip**: Some developers may find useful to insert the new entity inside the `_authorManager.CreateAsync`. We think it is a better design to leave it to the application layer since it better knows when to insert it to the database (maybe it requires additional works on the entity before insert, which would require to an additional update if we perform the insert in the domain service). However, it is completely up to you.
### UpdateAsync
````csharp
[Authorize(BookStorePermissions.Authors.Edit)]
public async Task UpdateAsync(Guid id, UpdateAuthorDto input)
{
var author = await _authorRepository.GetAsync(id);
if (author.Name != input.Name)
{
await _authorManager.ChangeNameAsync(author, input.Name);
}
author.BirthDate = input.BirthDate;
author.ShortBio = input.ShortBio;
await _authorRepository.UpdateAsync(author);
}
````
* `UpdateAsync` requires the additional `BookStorePermissions.Authors.Edit` permission.
* Used the `IAuthorRepository.GetAsync` to get the author entity from the database. `GetAsync` throws `EntityNotFoundException` if there is no author with the given id, which results a `404` HTTP status code in a web application. It is a good practice to always bring the entity on an update operation.
* Used the `AuthorManager.ChangeNameAsync` (domain service method) to change the author name if it was requested to change by the client.
* Directly updated the `BirthDate` and `ShortBio` since there is not any business rule to change these properties, they accept any value.
* Finally, called the `IAuthorRepository.UpdateAsync` method to update the entity on the database.
{{if DB == "EF"}}
> **EF Core tip**: Entity Framework Core has a **change tracking** system and **automatically saves** any change to an entity at the end of the unit of work (You can simply think that the ABP Framework automatically calls `SaveChanges` at the end of the method). So, it will work as expected even if you don't call the `_authorRepository.UpdateAsync(...)` in the end of the method. If you don't consider to change the EF Core later, you can just remove this line.
{{end}}
### DeleteAsync
````csharp
[Authorize(BookStorePermissions.Authors.Delete)]
public async Task DeleteAsync(Guid id)
{
await _authorRepository.DeleteAsync(id);
}
````
* `DeleteAsync` requires the additional `BookStorePermissions.Authors.Delete` permission.
* It simply uses the `DeleteAsync` method of the repository.
## Permission Definitions
You can't compile the code since it is expecting some constants declared in the `BookStorePermissions` class.
Open the `BookStorePermissions` class inside the `Acme.BookStore.Application.Contracts` project and change the content as shown below:
````csharp
namespace Acme.BookStore.Permissions
{
public static class BookStorePermissions
{
public const string GroupName = "BookStore";
public static class Books
{
public const string Default = GroupName + ".Books";
public const string Create = Default + ".Create";
public const string Edit = Default + ".Edit";
public const string Delete = Default + ".Delete";
}
// *** ADDED a NEW NESTED CLASS ***
public static class Authors
{
public const string Default = GroupName + ".Authors";
public const string Create = Default + ".Create";
public const string Edit = Default + ".Edit";
public const string Delete = Default + ".Delete";
}
}
}
````
Then open the `BookStorePermissionDefinitionProvider` in the same project and add the following lines at the end of the `Define` method:
````csharp
var authorsPermission = bookStoreGroup.AddPermission(
BookStorePermissions.Authors.Default, L("Permission:Authors"));
authorsPermission.AddChild(
BookStorePermissions.Authors.Create, L("Permission:Authors.Create"));
authorsPermission.AddChild(
BookStorePermissions.Authors.Edit, L("Permission:Authors.Edit"));
authorsPermission.AddChild(
BookStorePermissions.Authors.Delete, L("Permission:Authors.Delete"));
````
Finally, add the following entries to the `Localization/BookStore/en.json` inside the `Acme.BookStore.Domain.Shared` project, to localize the permission names:
````csharp
"Permission:Authors": "Author Management",
"Permission:Authors.Create": "Creating new authors",
"Permission:Authors.Edit": "Editing the authors",
"Permission:Authors.Delete": "Deleting the authors"
````
## Object to Object Mapping
`AuthorAppService` is using the `ObjectMapper` to convert the `Author` objects to `AuthorDto` objects. So, we need to define this mapping in the AutoMapper configuration.
Open the `BookStoreApplicationAutoMapperProfile` class inside the `Acme.BookStore.Application` project and add the following line to the constructor:
````csharp
CreateMap<Author, AuthorDto>();
````
## Data Seeder
As just done for the books before, it would be good to have some initial author entities in the database. This will be good while running the application first time, but also it is very useful for the automated tests.
Open the `BookStoreDataSeederContributor` in the `Acme.BookStore.Domain` project and change the file content with the code below:
````csharp
using System;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Acme.BookStore.Books;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
namespace Acme.BookStore
{
public class BookStoreDataSeederContributor
: IDataSeedContributor, ITransientDependency
{
private readonly IRepository<Book, Guid> _bookRepository;
private readonly IAuthorRepository _authorRepository;
private readonly AuthorManager _authorManager;
public BookStoreDataSeederContributor(
IRepository<Book, Guid> bookRepository,
IAuthorRepository authorRepository,
AuthorManager authorManager)
{
_bookRepository = bookRepository;
_authorRepository = authorRepository;
_authorManager = authorManager;
}
public async Task SeedAsync(DataSeedContext context)
{
if (await _bookRepository.GetCountAsync() > 0)
{
return;
}
// ADDED SEED DATA FOR AUTHORS
await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"George Orwell",
new DateTime(1903, 06, 25),
"Orwell produced literary criticism and poetry, fiction and polemical journalism; and is best known for the allegorical novella Animal Farm (1945) and the dystopian novel Nineteen Eighty-Four (1949)."
)
);
await _authorRepository.InsertAsync(
await _authorManager.CreateAsync(
"Douglas Adams",
new DateTime(1952, 03, 11),
"Douglas Adams was an English author, screenwriter, essayist, humorist, satirist and dramatist. Adams was an advocate for environmentalism and conservation, a lover of fast cars, technological innovation and the Apple Macintosh, and a self-proclaimed 'radical atheist'."
)
);
await _bookRepository.InsertAsync(
new Book
{
Name = "1984",
Type = BookType.Dystopia,
PublishDate = new DateTime(1949, 6, 8),
Price = 19.84f
},
autoSave: true
);
await _bookRepository.InsertAsync(
new Book
{
Name = "The Hitchhiker's Guide to the Galaxy",
Type = BookType.ScienceFiction,
PublishDate = new DateTime(1995, 9, 27),
Price = 42.0f
},
autoSave: true
);
}
}
}
````
## Testing the Author Application Service
Finally, we can write some tests for the `IAuthorAppService`. Add a new class, named `AuthorAppService_Tests` in the `Authors` namespace (folder) of the `Acme.BookStore.Application.Tests` project:
````csharp
using System;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
namespace Acme.BookStore.Authors
{ {{if DB=="Mongo"}}
[Collection(BookStoreTestConsts.CollectionDefinitionName)]{{end}}
public class AuthorAppService_Tests : BookStoreApplicationTestBase
{
private readonly IAuthorAppService _authorAppService;
public AuthorAppService_Tests()
{
_authorAppService = GetRequiredService<IAuthorAppService>();
}
[Fact]
public async Task Should_Get_All_Authors_Without_Any_Filter()
{
var result = await _authorAppService.GetListAsync(new GetAuthorListDto());
result.TotalCount.ShouldBeGreaterThanOrEqualTo(2);
result.Items.ShouldContain(author => author.Name == "George Orwell");
result.Items.ShouldContain(author => author.Name == "Douglas Adams");
}
[Fact]
public async Task Should_Get_Filtered_Authors()
{
var result = await _authorAppService.GetListAsync(
new GetAuthorListDto {Filter = "George"});
result.TotalCount.ShouldBeGreaterThanOrEqualTo(1);
result.Items.ShouldContain(author => author.Name == "George Orwell");
result.Items.ShouldNotContain(author => author.Name == "Douglas Adams");
}
[Fact]
public async Task Should_Create_A_New_Author()
{
var authorDto = await _authorAppService.CreateAsync(
new CreateAuthorDto
{
Name = "Edward Bellamy",
BirthDate = new DateTime(1850, 05, 22),
ShortBio = "Edward Bellamy was an American author..."
}
);
authorDto.Id.ShouldNotBe(Guid.Empty);
authorDto.Name.ShouldBe("Edward Bellamy");
}
[Fact]
public async Task Should_Not_Allow_To_Create_Duplicate_Author()
{
await Assert.ThrowsAsync<AuthorAlreadyExistsException>(async () =>
{
await _authorAppService.CreateAsync(
new CreateAuthorDto
{
Name = "Douglas Adams",
BirthDate = DateTime.Now,
ShortBio = "..."
}
);
});
}
//TODO: Test other methods...
}
}
````
Created some tests for the application service methods, which should be clear to understand.
## The Next Part
See the [next part](Part-9.md) of this tutorial.

854
docs/en/Tutorials/Part-9.md

@ -0,0 +1,854 @@
# Web Application Development Tutorial - Part 9: Authors: User Interface
````json
//[doc-params]
{
"UI": ["MVC","NG"],
"DB": ["EF","Mongo"]
}
````
{{
if UI == "MVC"
UI_Text="mvc"
else if UI == "NG"
UI_Text="angular"
else
UI_Text="?"
end
if DB == "EF"
DB_Text="Entity Framework Core"
else if DB == "Mongo"
DB_Text="MongoDB"
else
DB_Text="?"
end
}}
## About This Tutorial
In this tutorial series, you will build an ABP based web application named `Acme.BookStore`. This application is used to manage a list of books and their authors. It is developed using the following technologies:
* **{{DB_Text}}** as the ORM provider.
* **{{UI_Value}}** as the UI Framework.
This tutorial is organized as the following parts;
- [Part 1: Creating the server side](Part-1.md)
- [Part 2: The book list page](Part-2.md)
- [Part 3: Creating, updating and deleting books](Part-3.md)
- [Part 4: Integration tests](Part-4.md)
- [Part 5: Authorization](Part-5.md)
- [Part 6: Authors: Domain layer](Part-6.md)
- [Part 7: Authors: Database Integration](Part-7.md)
- [Part 8: Authors: Application Layer](Part-8.md)
- **Part 9: Authors: User Interface (this part)**
- [Part 10: Book to Author Relation](Part-10.md)
### Download the Source Code
This tutorials has multiple versions based on your **UI** and **Database** preferences. We've prepared two combinations of the source code to be downloaded:
* [MVC (Razor Pages) UI with EF Core](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore)
* [Angular UI with MongoDB](https://github.com/abpframework/abp-samples/tree/master/BookStore-Angular-MongoDb)
## Introduction
This part explains how to create a CRUD page for the `Author` entity introduced in previous parts.
{{if UI == "MVC"}}
## The Book List Page
Create a new razor page, `Index.cshtml` under the `Pages/Authors` folder of the `Acme.BookStore.Web` project and change the content as given below.
### Index.cshtml
````html
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Permissions
@using Acme.BookStore.Web.Pages.Authors
@using Microsoft.AspNetCore.Authorization
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<BookStoreResource> L
@inject IAuthorizationService AuthorizationService
@model IndexModel
@section scripts
{
<abp-script src="/Pages/Authors/Index.js"/>
}
<abp-card>
<abp-card-header>
<abp-row>
<abp-column size-md="_6">
<abp-card-title>@L["Authors"]</abp-card-title>
</abp-column>
<abp-column size-md="_6" class="text-right">
@if (await AuthorizationService
.IsGrantedAsync(BookStorePermissions.Authors.Create))
{
<abp-button id="NewAuthorButton"
text="@L["NewAuthor"].Value"
icon="plus"
button-type="Primary"/>
}
</abp-column>
</abp-row>
</abp-card-header>
<abp-card-body>
<abp-table striped-rows="true" id="AuthorsTable"></abp-table>
</abp-card-body>
</abp-card>
````
This is a simple page similar to the Books page we had created before. It imports a JavaScript file which will be introduced below.
### IndexModel.cshtml.cs
````csharp
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Acme.BookStore.Web.Pages.Authors
{
public class IndexModel : PageModel
{
public void OnGet()
{
}
}
}
````
### Index.js
````js
$(function () {
var l = abp.localization.getResource('BookStore');
var createModal = new abp.ModalManager(abp.appPath + 'Authors/CreateModal');
var editModal = new abp.ModalManager(abp.appPath + 'Authors/EditModal');
var dataTable = $('#AuthorsTable').DataTable(
abp.libs.datatables.normalizeConfiguration({
serverSide: true,
paging: true,
order: [[1, "asc"]],
searching: false,
scrollX: true,
ajax: abp.libs.datatables.createAjax(acme.bookStore.authors.author.getList),
columnDefs: [
{
title: l('Actions'),
rowAction: {
items:
[
{
text: l('Edit'),
visible:
abp.auth.isGranted('BookStore.Authors.Edit'),
action: function (data) {
editModal.open({ id: data.record.id });
}
},
{
text: l('Delete'),
visible:
abp.auth.isGranted('BookStore.Authors.Delete'),
confirmMessage: function (data) {
return l(
'AuthorDeletionConfirmationMessage',
data.record.name
);
},
action: function (data) {
acme.bookStore.authors.author
.delete(data.record.id)
.then(function() {
abp.notify.info(
l('SuccessfullyDeleted')
);
dataTable.ajax.reload();
});
}
}
]
}
},
{
title: l('Name'),
data: "name"
},
{
title: l('BirthDate'),
data: "birthDate",
render: function (data) {
return luxon
.DateTime
.fromISO(data, {
locale: abp.localization.currentCulture.name
}).toLocaleString();
}
}
]
})
);
createModal.onResult(function () {
dataTable.ajax.reload();
});
editModal.onResult(function () {
dataTable.ajax.reload();
});
$('#NewAuthorButton').click(function (e) {
e.preventDefault();
createModal.open();
});
});
````
Briefly, this JavaScript page;
* Creates a Data table with `Actions`, `Name` and `BirthDate` columns.
* `Actions` column is used to add *Edit* and *Delete* actions.
* `BirthDate` provides a `render` function to format the `DateTime` value using the [luxon](https://moment.github.io/luxon/) library.
* Uses the `abp.ModalManager` to open *Create* and *Edit* modal forms.
This code is very similar to the Books page created before, so we will not explain it more.
### Localizations
This page uses some localization keys we need to declare. Open the `en.json` file under the `Localization/BookStore` folder of the `Acme.BookStore.Domain.Shared` project and add the following entries:
````json
"Menu:Authors": "Authors",
"Authors": "Authors",
"AuthorDeletionConfirmationMessage": "Are you sure to delete the author '{0}'?",
"BirthDate": "Birth date",
"NewAuthor": "New author"
````
Notice that we've added more keys. They will be used in the next sections.
### Add to the Main Menu
Open the `BookStoreMenuContributor.cs` in the `Menus` folder of the `Acme.BookStore.Web` project and add the following code in the end of the `ConfigureMainMenuAsync` method:
````csharp
if (await context.IsGrantedAsync(BookStorePermissions.Authors.Default))
{
bookStoreMenu.AddItem(new ApplicationMenuItem(
"BooksStore.Authors",
l["Menu:Authors"],
url: "/Authors"
));
}
````
### Run the Application
Run and login to the application. **You can not see the menu item since you don't have permission yet.** Go to the `Identity/Roles` page, click to the *Actions* button and select the *Permissions* action for the **admin role**:
![bookstore-author-permissions](images/bookstore-author-permissions.png)
As you see, the admin role has no *Author Management* permissions yet. Click to the checkboxes and save the modal to grant the necessary permissions. You will see the *Authors* menu item under the *Book Store* in the main menu, after **refreshing the page**:
![bookstore-authors-page](images/bookstore-authors-page.png)
The page is fully working except *New author* and *Actions/Edit* since we haven't implemented them yet.
> **Tip**: If you run the `.DbMigrator` console application after defining a new permission, it automatically grants these new permissions to the admin role and you don't need to manually grant the permissions yourself.
## Create Modal
Create a new razor page, `CreateModal.cshtml` under the `Pages/Authors` folder of the `Acme.BookStore.Web` project and change the content as given below.
### CreateModal.cshtml
```html
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Web.Pages.Authors
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model CreateModalModel
@inject IStringLocalizer<BookStoreResource> L
@{
Layout = null;
}
<form asp-page="/Authors/CreateModal">
<abp-modal>
<abp-modal-header title="@L["NewAuthor"].Value"></abp-modal-header>
<abp-modal-body>
<abp-input asp-for="Author.Name" />
<abp-input asp-for="Author.BirthDate" />
<abp-input asp-for="Author.ShortBio" />
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>
</form>
```
We had used [dynamic forms](../UI/AspNetCore/Tag-Helpers/Dynamic-Forms.md) of the ABP Framework for the books page before. We could use the same approach here, but we wanted to show how to do it manually. Actually, not so manually, because we've used `abp-input` tag helper in this case to simplify creating the form elements.
You can definitely use the standard Bootstrap HTML structure, but it requires to write a lot of code. `abp-input` automatically adds validation, localization and other standard elements based on the data type.
### CreateModal.cshtml.cs
```csharp
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;
namespace Acme.BookStore.Web.Pages.Authors
{
public class CreateModalModel : BookStorePageModel
{
[BindProperty]
public CreateAuthorViewModel Author { get; set; }
private readonly IAuthorAppService _authorAppService;
public CreateModalModel(IAuthorAppService authorAppService)
{
_authorAppService = authorAppService;
}
public void OnGet()
{
Author = new CreateAuthorViewModel();
}
public async Task<IActionResult> OnPostAsync()
{
var dto = ObjectMapper.Map<CreateAuthorViewModel, CreateAuthorDto>(Author);
await _authorAppService.CreateAsync(dto);
return NoContent();
}
public class CreateAuthorViewModel
{
[Required]
[StringLength(AuthorConsts.MaxNameLength)]
public string Name { get; set; }
[Required]
[DataType(DataType.Date)]
public DateTime BirthDate { get; set; }
[TextArea]
public string ShortBio { get; set; }
}
}
}
```
This page model class simply injects and uses the `IAuthorAppService` to create a new author. The main difference between the book creation model class is that this one is declaring a new class, `CreateAuthorViewModel`, for the view model instead of re-using the `CreateAuthorDto`.
The main reason of this decision was to show you how to use a different model class inside the page. But there is one more benefit: We added two attributes to the class members, which were not present in the `CreateAuthorDto`:
* Added `[DataType(DataType.Date)]` attribute to the `BirthDate` which shows a date picker on the UI for this property.
* Added `[TextArea]` attribute to the `ShortBio` which shows a multi-line text area instead of a standard textbox.
In this way, you can specialize the view model class based on your UI requirements without touching to the DTO. As a result of this decision, we have used `ObjectMapper` to map `CreateAuthorViewModel` to `CreateAuthorDto`. To be able to do that, you need to add a new mapping code to the `BookStoreWebAutoMapperProfile` constructor:
````csharp
using Acme.BookStore.Authors; // ADDED NAMESPACE IMPORT
using Acme.BookStore.Books;
using AutoMapper;
namespace Acme.BookStore.Web
{
public class BookStoreWebAutoMapperProfile : Profile
{
public BookStoreWebAutoMapperProfile()
{
CreateMap<BookDto, CreateUpdateBookDto>();
// ADD a NEW MAPPING
CreateMap<Pages.Authors.CreateModalModel.CreateAuthorViewModel,
CreateAuthorDto>();
}
}
}
````
"New author" button will work as expected and open a new model when you run the application again:
![bookstore-new-author-modal](images/bookstore-new-author-modal.png)
## Edit Modal
Create a new razor page, `EditModal.cshtml` under the `Pages/Authors` folder of the `Acme.BookStore.Web` project and change the content as given below.
### EditModal.cshtml
````html
@page
@using Acme.BookStore.Localization
@using Acme.BookStore.Web.Pages.Authors
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@model EditModalModel
@inject IStringLocalizer<BookStoreResource> L
@{
Layout = null;
}
<form asp-page="/Authors/EditModal">
<abp-modal>
<abp-modal-header title="@L["Update"].Value"></abp-modal-header>
<abp-modal-body>
<abp-input asp-for="Author.Id" />
<abp-input asp-for="Author.Name" />
<abp-input asp-for="Author.BirthDate" />
<abp-input asp-for="Author.ShortBio" />
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>
</form>
````
### EditModal.cshtml.cs
```csharp
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Acme.BookStore.Authors;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;
namespace Acme.BookStore.Web.Pages.Authors
{
public class EditModalModel : BookStorePageModel
{
[BindProperty]
public EditAuthorViewModel Author { get; set; }
private readonly IAuthorAppService _authorAppService;
public EditModalModel(IAuthorAppService authorAppService)
{
_authorAppService = authorAppService;
}
public async Task OnGetAsync(Guid id)
{
var authorDto = await _authorAppService.GetAsync(id);
Author = ObjectMapper.Map<AuthorDto, EditAuthorViewModel>(authorDto);
}
public async Task<IActionResult> OnPostAsync()
{
await _authorAppService.UpdateAsync(
Author.Id,
ObjectMapper.Map<EditAuthorViewModel, UpdateAuthorDto>(Author)
);
return NoContent();
}
public class EditAuthorViewModel
{
[HiddenInput]
public Guid Id { get; set; }
[Required]
[StringLength(AuthorConsts.MaxNameLength)]
public string Name { get; set; }
[Required]
[DataType(DataType.Date)]
public DateTime BirthDate { get; set; }
[TextArea]
public string ShortBio { get; set; }
}
}
}
```
This class is similar to the `CreateModal.cshtml.cs` while there are some main differences;
* Uses the `IAuthorAppService.GetAsync(...)` method to get the editing author from the application layer.
* `EditAuthorViewModel` has an additional `Id` property which is marked with the `[HiddenInput]` attribute that creates a hidden input for this property.
This class requires to add two object mapping declarations to the `BookStoreWebAutoMapperProfile` class:
```csharp
using Acme.BookStore.Authors;
using Acme.BookStore.Books;
using AutoMapper;
namespace Acme.BookStore.Web
{
public class BookStoreWebAutoMapperProfile : Profile
{
public BookStoreWebAutoMapperProfile()
{
CreateMap<BookDto, CreateUpdateBookDto>();
CreateMap<Pages.Authors.CreateModalModel.CreateAuthorViewModel,
CreateAuthorDto>();
// ADD THESE NEW MAPPINGS
CreateMap<AuthorDto, Pages.Authors.EditModalModel.EditAuthorViewModel>();
CreateMap<Pages.Authors.EditModalModel.EditAuthorViewModel,
UpdateAuthorDto>();
}
}
}
```
That's all! You can run the application and try to edit an author.
{{else if UI == "NG"}}
## The Author List Page, Create & Delete Authors
Run the following command line to create a new module, named `AuthorModule` in the root folder of the angular application:
```bash
yarn ng generate module author --module app --routing --route authors
```
This command should produce the following output:
```bash
> yarn ng generate module author --module app --routing --route authors
yarn run v1.19.1
$ ng generate module author --module app --routing --route authors
CREATE src/app/author/author-routing.module.ts (344 bytes)
CREATE src/app/author/author.module.ts (349 bytes)
CREATE src/app/author/author.component.html (21 bytes)
CREATE src/app/author/author.component.spec.ts (628 bytes)
CREATE src/app/author/author.component.ts (276 bytes)
CREATE src/app/author/author.component.scss (0 bytes)
UPDATE src/app/app-routing.module.ts (1396 bytes)
Done in 2.22s.
```
### AuthorModule
Open the `/src/app/author/author.module.ts` and replace the content as shown below:
```js
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { AuthorRoutingModule } from './author-routing.module';
import { AuthorComponent } from './author.component';
import { NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap';
@NgModule({
declarations: [AuthorComponent],
imports: [SharedModule, AuthorRoutingModule, NgbDatepickerModule],
})
export class AuthorModule {}
```
- Added the `SharedModule`. `SharedModule` exports some common modules needed to create user interfaces.
- `SharedModule` already exports the `CommonModule`, so we've removed the `CommonModule`.
- Added `NgbDatepickerModule` that will be used later on the author create and edit forms.
### Menu Definition
Open the `src/app/route.provider.ts` file and add the following menu definition:
````js
{
path: '/authors',
name: '::Menu:Authors',
parentName: '::Menu:BookStore',
layout: eLayoutType.application,
requiredPolicy: 'BookStore.Authors',
}
````
The final `configureRoutes` function declaration should be following:
```js
function configureRoutes(routes: RoutesService) {
return () => {
routes.add([
{
path: '/',
name: '::Menu:Home',
iconClass: 'fas fa-home',
order: 1,
layout: eLayoutType.application,
},
{
path: '/book-store',
name: '::Menu:BookStore',
iconClass: 'fas fa-book',
order: 2,
layout: eLayoutType.application,
},
{
path: '/books',
name: '::Menu:Books',
parentName: '::Menu:BookStore',
layout: eLayoutType.application,
requiredPolicy: 'BookStore.Books',
},
{
path: '/authors',
name: '::Menu:Authors',
parentName: '::Menu:BookStore',
layout: eLayoutType.application,
requiredPolicy: 'BookStore.Authors',
},
]);
};
}
```
### Service Proxy Generation
[ABP CLI](https://docs.abp.io/en/abp/latest/CLI) provides `generate-proxy` command that generates client proxies for your HTTP APIs to make easy to consume your HTTP APIs from the client side. Before running `generate-proxy` command, your host must be up and running.
Run the following command in the `angular` folder:
```bash
abp generate-proxy
```
This command generates the service proxy for the author service and the related model (DTO) classes:
![bookstore-angular-service-proxy-author](images/bookstore-angular-service-proxy-author.png)
### AuthorComponent
Open the `/src/app/author/author.component.ts` file and replace the content as below:
```js
import { Component, OnInit } from '@angular/core';
import { ListService, PagedResultDto } from '@abp/ng.core';
import { AuthorDto } from './models';
import { AuthorService } from './services';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { NgbDateNativeAdapter, NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap';
import { ConfirmationService, Confirmation } from '@abp/ng.theme.shared';
@Component({
selector: 'app-author',
templateUrl: './author.component.html',
styleUrls: ['./author.component.scss'],
providers: [ListService, { provide: NgbDateAdapter, useClass: NgbDateNativeAdapter }],
})
export class AuthorComponent implements OnInit {
author = { items: [], totalCount: 0 } as PagedResultDto<AuthorDto>;
isModalOpen = false;
form: FormGroup;
selectedAuthor = new AuthorDto();
constructor(
public readonly list: ListService,
private authorService: AuthorService,
private fb: FormBuilder,
private confirmation: ConfirmationService
) {}
ngOnInit(): void {
const authorStreamCreator = (query) => this.authorService.getListByInput(query);
this.list.hookToQuery(authorStreamCreator).subscribe((response) => {
this.author = response;
});
}
createAuthor() {
this.selectedAuthor = new AuthorDto();
this.buildForm();
this.isModalOpen = true;
}
editAuthor(id: string) {
this.authorService.getById(id).subscribe((author) => {
this.selectedAuthor = author;
this.buildForm();
this.isModalOpen = true;
});
}
buildForm() {
this.form = this.fb.group({
name: [this.selectedAuthor.name || '', Validators.required],
birthDate: [
this.selectedAuthor.birthDate ? new Date(this.selectedAuthor.birthDate) : null,
Validators.required,
],
});
}
save() {
if (this.form.invalid) {
return;
}
if (this.selectedAuthor.id) {
this.authorService
.updateByIdAndInput(this.form.value, this.selectedAuthor.id)
.subscribe(() => {
this.isModalOpen = false;
this.form.reset();
this.list.get();
});
} else {
this.authorService.createByInput(this.form.value).subscribe(() => {
this.isModalOpen = false;
this.form.reset();
this.list.get();
});
}
}
delete(id: string) {
this.confirmation.warn('::AreYouSureToDelete', '::AreYouSure')
.subscribe((status) => {
if (status === Confirmation.Status.confirm) {
this.authorService.deleteById(id).subscribe(() => this.list.get());
}
});
}
}
```
Open the `/src/app/author/author.component.html` and replace the content as below:
````html
<div class="card">
<div class="card-header">
<div class="row">
<div class="col col-md-6">
<h5 class="card-title">
{%{{{ '::Menu:Authors' | abpLocalization }}}%}
</h5>
</div>
<div class="text-right col col-md-6">
<div class="text-lg-right pt-2">
<button id="create" class="btn btn-primary" type="button" (click)="createAuthor()">
<i class="fa fa-plus mr-1"></i>
<span>{%{{{ '::NewAuthor' | abpLocalization }}}%}</span>
</button>
</div>
</div>
</div>
</div>
<div class="card-body">
<ngx-datatable [rows]="author.items" [count]="author.totalCount" [list]="list" default>
<ngx-datatable-column
[name]="'::Actions' | abpLocalization"
[maxWidth]="150"
[sortable]="false"
>
<ng-template let-row="row" ngx-datatable-cell-template>
<div ngbDropdown container="body" class="d-inline-block">
<button
class="btn btn-primary btn-sm dropdown-toggle"
data-toggle="dropdown"
aria-haspopup="true"
ngbDropdownToggle
>
<i class="fa fa-cog mr-1"></i>{%{{{ '::Actions' | abpLocalization }}}%}
</button>
<div ngbDropdownMenu>
<button ngbDropdownItem (click)="editAuthor(row.id)">
{%{{{ '::Edit' | abpLocalization }}}%}
</button>
<button ngbDropdownItem (click)="delete(row.id)">
{%{{{ '::Delete' | abpLocalization }}}%}
</button>
</div>
</div>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column [name]="'::Name' | abpLocalization" prop="name"></ngx-datatable-column>
<ngx-datatable-column [name]="'::BirthDate' | abpLocalization">
<ng-template let-row="row" ngx-datatable-cell-template>
{%{{{ row.birthDate | date }}}%}
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
</div>
</div>
<abp-modal [(visible)]="isModalOpen">
<ng-template #abpHeader>
<h3>{%{{{ (selectedAuthor.id ? '::Edit' : '::NewAuthor') | abpLocalization }}}%}</h3>
</ng-template>
<ng-template #abpBody>
<form [formGroup]="form" (ngSubmit)="save()">
<div class="form-group">
<label for="author-name">Name</label><span> * </span>
<input type="text" id="author-name" class="form-control" formControlName="name" autofocus />
</div>
<div class="form-group">
<label>Birth date</label><span> * </span>
<input
#datepicker="ngbDatepicker"
class="form-control"
name="datepicker"
formControlName="birthDate"
ngbDatepicker
(click)="datepicker.toggle()"
/>
</div>
</form>
</ng-template>
<ng-template #abpFooter>
<button type="button" class="btn btn-secondary" #abpClose>
{%{{{ '::Close' | abpLocalization }}}%}
</button>
<button class="btn btn-primary" (click)="save()" [disabled]="form.invalid">
<i class="fa fa-check mr-1"></i>
{%{{{ '::Save' | abpLocalization }}}%}
</button>
</ng-template>
</abp-modal>
````
### Localizations
This page uses some localization keys we need to declare. Open the `en.json` file under the `Localization/BookStore` folder of the `Acme.BookStore.Domain.Shared` project and add the following entries:
````json
"Menu:Authors": "Authors",
"Authors": "Authors",
"AuthorDeletionConfirmationMessage": "Are you sure to delete the author '{0}'?",
"BirthDate": "Birth date",
"NewAuthor": "New author"
````
### Run the Application
Run and login to the application. **You can not see the menu item since you don't have permission yet.** Go to the `identity/roles` page, click to the *Actions* button and select the *Permissions* action for the **admin role**:
![bookstore-author-permissions](images/bookstore-author-permissions.png)
As you see, the admin role has no *Author Management* permissions yet. Click to the checkboxes and save the modal to grant the necessary permissions. You will see the *Authors* menu item under the *Book Store* in the main menu, after **refreshing the page**:
![bookstore-authors-page](images/bookstore-angular-authors-page.png)
That's all! This is a fully working CRUD page, you can create, edit and delete authors.
> **Tip**: If you run the `.DbMigrator` console application after defining a new permission, it automatically grants these new permissions to the admin role and you don't need to manually grant the permissions yourself.
{{end}}
## The Next Part
See the [next part](Part-10.md) of this tutorial.

BIN
docs/en/Tutorials/images/bookstore-add-migration-authors.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
docs/en/Tutorials/images/bookstore-added-author-to-book-list-angular.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
docs/en/Tutorials/images/bookstore-added-author-to-book-list.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
docs/en/Tutorials/images/bookstore-added-authors-to-modals.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
docs/en/Tutorials/images/bookstore-angular-authors-page.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
docs/en/Tutorials/images/bookstore-angular-service-proxy-author.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
docs/en/Tutorials/images/bookstore-author-domain-layer.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
docs/en/Tutorials/images/bookstore-author-permissions.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
docs/en/Tutorials/images/bookstore-authors-page.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/en/Tutorials/images/bookstore-new-author-modal.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

203
docs/en/UI/Angular/Subscription-Service.md

@ -0,0 +1,203 @@
# Easy Unsubscription for Your Observables
`SubscriptionService` is a utility service to provide an easy unsubscription from RxJS observables in Angular components and directives. Please see [why you should unsubscribe from observables on instance destruction](https://angular.io/guide/lifecycle-hooks#cleaning-up-on-instance-destruction).
## Getting Started
You have to provide the `SubscriptionService` at component or directive level, because it is **not provided in root** and it works in sync with component/directive lifecycle. Only after then you can inject and start using it.
```js
import { SubscriptionService } from '@abp/ng.core';
@Component({
/* class metadata here */
providers: [SubscriptionService],
})
class DemoComponent {
count$ = interval(1000);
constructor(private subscription: SubscriptionService) {
this.subscription.addOne(this.count$, console.log);
}
}
```
The values emitted by the `count$` will be logged until the component is destroyed. You will not have to unsubscribe manually.
> Please do not try to use a singleton `SubscriptionService`. It simply will not work.
## Usage
### How to Subscribe to Observables
You can pass a `next` function and an `error` function.
```js
@Component({
/* class metadata here */
providers: [SubscriptionService],
})
class DemoComponent implements OnInit {
constructor(private subscription: SubscriptionService) {}
ngOnInit() {
const source$ = interval(1000);
const nextFn = value => console.log(value * 2);
const errorFn = error => {
console.error(error);
return of(null);
};
this.subscription.addOne(source$, nextFn, errorFn);
}
}
```
Or, you can pass an observer.
```js
@Component({
/* class metadata here */
providers: [SubscriptionService],
})
class DemoComponent implements OnInit {
constructor(private subscription: SubscriptionService) {}
ngOnInit() {
const source$ = interval(1000);
const observer = {
next: value => console.log(value * 2),
complete: () => console.log('DONE'),
};
this.subscription.addOne(source$, observer);
}
}
```
The `addOne` method returns the individual subscription, so that you may use it later on. Please see topics below for details.
### How to Unsubscribe Before Instance Destruction
There are two ways to do that. If you are not going to subscribe again, you may use the `closeAll` method.
```js
@Component({
/* class metadata here */
providers: [SubscriptionService],
})
class DemoComponent implements OnInit {
constructor(private subscription: SubscriptionService) {}
ngOnInit() {
this.subscription.addOne(interval(1000), console.log);
}
onSomeEvent() {
this.subscription.closeAll();
}
}
```
This will clear all subscriptions, but you will not be able to subscribe again. If you are planning to add another subscription, you may use the `reset` method instead.
```js
@Component({
/* class metadata here */
providers: [SubscriptionService],
})
class DemoComponent implements OnInit {
constructor(private subscription: SubscriptionService) {}
ngOnInit() {
this.subscription.addOne(interval(1000), console.log);
}
onSomeEvent() {
this.subscription.reset();
this.subscription.addOne(interval(1000), console.warn);
}
}
```
### How to Unsubscribe From a Single Subscription
Sometimes, you may need to unsubscribe from a particular subscription but leave others alive. In such a case, you may use the `closeOne` method.
```js
@Component({
/* class metadata here */
providers: [SubscriptionService],
})
class DemoComponent implements OnInit {
countSubscription: Subscription;
constructor(private subscription: SubscriptionService) {}
ngOnInit() {
this.countSubscription = this.subscription.addOne(
interval(1000),
console.log
);
}
onSomeEvent() {
this.subscription.closeOne(this.countSubscription);
console.log(this.countSubscription.closed); // true
}
}
```
### How to Remove a Single Subscription From Tracked Subscriptions
You may want to take control of a particular subscription. In such a case, you may use the `removeOne` method to remove it from tracked subscriptions.
```js
@Component({
/* class metadata here */
providers: [SubscriptionService],
})
class DemoComponent implements OnInit {
countSubscription: Subscription;
constructor(private subscription: SubscriptionService) {}
ngOnInit() {
this.countSubscription = this.subscription.addOne(
interval(1000),
console.log
);
}
onSomeEvent() {
this.subscription.removeOne(this.countSubscription);
console.log(this.countSubscription.closed); // false
}
}
```
### How to Check If Unsubscribed From All
Please use `isClosed` getter to check if `closeAll` was called before.
```js
@Component({
/* class metadata here */
providers: [SubscriptionService],
})
class DemoComponent implements OnInit {
constructor(private subscription: SubscriptionService) {}
ngOnInit() {
this.subscription.addOne(interval(1000), console.log);
}
onSomeEvent() {
console.log(this.subscription.isClosed); // false
}
}
```
## What's Next?
- [ListService](./List-Service.md)

2
docs/en/UI/Angular/Track-By-Service.md

@ -116,4 +116,4 @@ class DemoComponent {
## What's Next?
- [ListService](./List-Service.md)
- [SubscriptionService](./Subscription-Service.md)

28
docs/en/docs-nav.json

@ -47,6 +47,26 @@
{
"text": "5: Authorization",
"path": "Tutorials/Part-5.md"
},
{
"text": "6: Authors: Domain layer",
"path": "Tutorials/Part-6.md"
},
{
"text": "7: Authors: Database Integration",
"path": "Tutorials/Part-7.md"
},
{
"text": "8: Authors: Application Layer",
"path": "Tutorials/Part-8.md"
},
{
"text": "9: Authors: User Interface",
"path": "Tutorials/Part-9.md"
},
{
"text": "10: Book to Author Relation",
"path": "Tutorials/Part-10.md"
}
]
}
@ -224,6 +244,10 @@
"text": "Minio Provider",
"path": "Blob-Storing-Minio.md"
},
{
"text": "AWS Provider",
"path": "Blob-Storing-Aws.md"
},
{
"text": "Create a Custom Provider",
"path": "Blob-Storing-Custom-Provider.md"
@ -442,6 +466,10 @@
"text": "TrackByService",
"path": "UI/Angular/Track-By-Service.md"
},
{
"text": "SubscriptionService",
"path": "UI/Angular/Subscription-Service.md"
},
{
"text": "ListService",
"path": "UI/Angular/List-Service.md"

6
docs/zh-Hans/Blob-Storing-Aliyun.md

@ -46,16 +46,18 @@ Configure<AbpBlobStoringOptions>(options =>
* **AccessKeyId** ([NotNull]string): 云账号AccessKey是访问阿里云API的密钥,具有该账户完全的权限,请你务必妥善保管!强烈建议遵循[阿里云安全最佳实践](https://help.aliyun.com/document_detail/102600.html),使用RAM子用户AccessKey来进行API调用.
* **AccessKeySecret** ([NotNull]string): 同上.
* **Endpoint** ([NotNull]string): Endpoint表示OSS对外服务的访问域名. [访问域名和数据中心](https://help.aliyun.com/document_detail/31837.html)
* **UseSecurityTokenService** (bool): 是否使用STS临时授权访问OSS,默认false. [STS临时授权访问OSS](https://help.aliyun.com/document_detail/100624.html)
* **RegionId** (string): STS服务的接入地址,每个地址的功能都相同,请尽量在同地域进行调用. [接入地址](https://help.aliyun.com/document_detail/66053.html)
* **RoleArn** ([NotNull]string): STS所需角色ARN. [STS临时授权访问OSS](https://help.aliyun.com/document_detail/100624.html)
* **RoleArn** ([NotNull]string): STS所需角色ARN.
* **RoleSessionName** ([NotNull]string): 用来标识临时访问凭证的名称,建议使用不同的应用程序用户来区分.
* **Policy** (string): 在扮演角色的时候额外添加的权限限制. 请参见[基于RAM Policy的权限控制](https://help.aliyun.com/document_detail/100680.html).
* **DurationSeconds** (int): 设置临时访问凭证的有效期,单位是s,最小为900,最大为3600. **注**:为0则使用子账号操作OSS.
* **DurationSeconds** (int): 设置临时访问凭证的有效期,单位是s,最小为900,最大为3600.
* **ContainerName** (string): 你可以在aliyun中指定容器名称. 如果没有指定它将使用 `BlogContainerName` 属性定义的BLOB容器的名称(请参阅[BLOB存储文档](Blob-Storing.md)). 请注意Aliyun有一些**命名容器的规则**,容器名称必须是有效的DNS名称,[符合以下命名规则](https://help.aliyun.com/knowledge_detail/39668.html):
* 只能包含小写字母,数字和短横线(-)
* 必须以小写字母和数字开头和结尾
* Bucket名称的长度限制在**3**到**63**个字符之间
* **CreateContainerIfNotExists** (bool): 默认值为 `false`, 如果aliyun中不存在容器, `AliyunBlobProvider` 将尝试创建它.
* **TemporaryCredentialsCacheKey** (bool): STS凭证缓存Key,默认Guid.NewGuid().ToString("N").
## Aliyun BLOB 名称计算器

81
docs/zh-Hans/Blob-Storing-Aws.md

@ -0,0 +1,81 @@
# BLOB Storing Aws提供程序
BLOB存储Aws提供程序可以将BLOB存储在[Amazon Simple Storage Service](https://aws.amazon.com/cn/s3/)中.
> 阅读[BLOB存储文档](Blob-Storing.md)了解如何使用BLOB存储系统. 本文档仅介绍如何为容器配置Aws提供程序.
## 安装
使用ABP CLI添加[Volo.Abp.BlobStoring.Aws](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Aws)NuGet包到你的项目:
* 安装 [ABP CLI](https://docs.abp.io/en/abp/latest/CLI), 如果你还没有安装.
* 在要添加 `Volo.Abp.BlobStoring.Aws` 包的 `.csproj` 文件目录打开命令行.
* 运行 `Volo.Abp.BlobStoring.Aws` 命令.
如果要手动安装,在你的项目中安装 `Volo.Abp.BlobStoring.Aws` NuGet包然后将`[DependsOn(typeof(AbpBlobStoringAwsModule))]`添加到项目内的[ABP模块](Module-Development-Basics.md)类中.
## 配置
如同[BLOB存储文档](Blob-Storing.md)所述,配置是在[模块](Module-Development-Basics.md)类的 `ConfigureServices` 方法完成的.
**示例: 配置为默认使用Aws存储提供程序**
````csharp
Configure<AbpBlobStoringOptions>(options =>
{
options.Containerscontainer.UseAws(Aws =>
{
Aws.AccessKeyId = "your Aws access key id";
Aws.SecretAccessKey = "your Aws access key secret";
Aws.UseCredentials = "set true to use credentials";
Aws.UseTemporaryCredentials = "set true to use temporary credentials";
Aws.UseTemporaryFederatedCredentials = "set true to use temporary federated credentials";
Aws.ProfileName = "the name of the profile to get credentials from";
Aws.ProfilesLocation = "the path to the aws credentials file to look at";
Aws.Region = "the system name of the service";
Aws.Name = "the name of the federated user";
Aws.Policy = "policy";
Aws.DurationSeconds = "expiration date";
Aws.ContainerName = "your Aws container name";
Aws.CreateContainerIfNotExists = false;
});
});
````
> 参阅[BLOB存储文档](Blob-Storing.md) 学习如何为指定容器配置提供程序.
### 选项
* **AccessKeyId** (string): AWS Access Key ID.
* **SecretAccessKey** (string): AWS Secret Access Key.
* **UseCredentials** (bool): 使用[本地凭证](https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/dev/AuthUsingAcctOrUserCredentials.html)访问AWS服务,默认: `false`.
* **UseTemporaryCredentials** (bool): 使用[临时凭证](https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/dev/AuthUsingTempSessionToken.html)访问AWS服务,默认: `false`.
* **UseTemporaryFederatedCredentials** (bool): 使用[联合身份用户临时凭证](https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/dev/AuthUsingTempFederationToken.html)访问AWS服务, 默认: `false`.
* **ProfileName** (string): [本地凭证配置文件](https://docs.aws.amazon.com/zh_cn/sdk-for-net/v3/developer-guide/net-dg-config-creds.html)名称.
* **ProfilesLocation** (string): 本地配置文件位置.
* **Region** (string): 服务的地区名称.
* **Policy** (string): JSON格式的IAM策略.
* **DurationSeconds** (int): 设置临时访问凭证的有效期,单位是s,最小为900,最大为129600.
* **ContainerName** (string): 你可以在Aws中指定容器名称. 如果没有指定它将使用 `BlogContainerName` 属性定义的BLOB容器的名称(请参阅[BLOB存储文档](Blob-Storing.md)). 请注意Aws有一些**命名容器的规则**,容器名称必须是有效的DNS名称,[符合以下命名规则](https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html):
* Bucket名称必须介于 3 到 63 个字符之间.
* Bucket名称只能由小写字母、数字、句点 (.) 和连字符 (-) 组成.
* Bucket名称必须以字母或数字开头和结尾.
* Bucket名称不能是ip (例如, 192.168.5.4).
* Bucket名称不能以 **xn--** 开头, (对于2020年2月以后创建的 Bucket).
* Bucket名称在分区中必须唯一 .
* Bucket 与 Amazon S3 Transfer Acceleration 一起使用时名称中不能有句点 (.).
* **CreateContainerIfNotExists** (bool): 默认值为 `false`, 如果Aws中不存在容器, `AwsBlobProvider` 将尝试创建它.
## Aws BLOB 名称计算器
Aws BLOB提供程序组织BLOB名称并实现一些约定. 默认情况下BLOB的全名由以下规则确定:
* 如果当前租户为 `null`(或容器禁用多租户 - 请参阅[BLOB存储文档](Blob-Storing.md) 了解如何禁用容器的多租户),则追加 `host` 字符串.
* 如果当前租户不为 `null`,则追加 `tenants/<tenant-id>` 字符串.
* 追加 BLOB 名称.
## 其他服务
* `AwsBlobProvider` 是实现Aws BLOB存储提供程序的主要服务,如果你想要通过[依赖注入](Dependency-Injection.md)覆盖/替换它(不要替换 `IBlobProvider` 接口,而是替换 `AwsBlobProvider` 类).
* `IAwsBlobNameCalculator` 服务用于计算文件路径. 默认实现是 `DefaultAwsBlobNameCalculator`. 如果你想自定义文件路径计算,可以替换/覆盖它.
* `IAmazonS3ClientFactory` 服务用于生成AWS S3客户端. 默认实现是 `DefaultAmazonS3ClientFactory` . 如果你想自定义AWS S3客户端生成,可以替换/覆盖它.

14
docs/zh-Hans/Blob-Storing-Minio.md

@ -41,13 +41,13 @@ Configure<AbpBlobStoringOptions>(options =>
* **AccessKey** (string): Access key是唯一标识你的账户的用户ID,
* **SecretKey** (string): Access key是唯一标识你的账户的用户ID
* **BucketName** (string):你可以指定bucket名称,如果没有指定,将使用 `BlogContainerName` 属性定义的BLOB容器的名称(查阅[BLOB storing document](Blob-Storing.md)),MinIO完全兼容S3标准,所以有一些 **bucket命名规则**,必须符合[规则](https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html):
* Bucket名字必须 **3** 到 **63** 字符长度.
* Bucket名字必须是 **小写** 的字母,数字,点号(.), 横杠 (-),
* Bucket名字必须是以数字和字母开头和结尾,
* Bucket名字不能是ip (例如, 192.168.5.4),
* Bucket名字不能以 **xn--** 开头, (2020年2月以后创建),
* Bucket名字必须区块唯一
* Buckets如果使用Amazon S3加速传输名字不能有点号(.),
* Bucket名称必须介于 3 到 63 个字符之间.
* Bucket名称只能由小写字母、数字、句点 (.) 和连字符 (-) 组成.
* Bucket名称必须以字母或数字开头和结尾.
* Bucket名称不能是ip (例如, 192.168.5.4).
* Bucket名称不能以 **xn--** 开头, (对于2020年2月以后创建的 Bucket).
* Bucket名称在分区中必须唯一 .
* Bucket 与 Amazon S3 Transfer Acceleration 一起使用时名称中不能有句点 (.).
* **WithSSL** (bool): 默认 `false`,代表使用HTTPS,
* **CreateContainerIfNotExists** (bool): 默认 `false`,如果不存在bucket, `MinioBlobProvider` 将会创建一个,

3
docs/zh-Hans/Blob-Storing.md

@ -19,8 +19,9 @@ ABP框架已经有以下存储提供程序的实现;
* [File System](Blob-Storing-File-System.md):将BLOB作为标准文件存储在本地文件系统的文件夹中.
* [Database](Blob-Storing-Database.md): 将BLOB存储在数据库中.
* [Azure](Blob-Storing-Azure.md): 将BLOG存储在 [Azure BLOB storage](https://azure.microsoft.com/en-us/services/storage/blobs/)中.
* [Aliyun](Blob-Storing-Aliyun.md): 将BLOB存储在[Aliyun Blob storage](https://help.aliyun.com/product/31815.html)中.
* [Aliyun](Blob-Storing-Aliyun.md): 将BLOB存储在[Aliyun Storage Service](https://help.aliyun.com/product/31815.html)中.
* [Ninio](Blob-Storing-Minio.md): 将BLOB存储在[MinIO Object storage](https://min.io/)中.
* [Aws](Blob-Storing-Aws.md): 将BLOB存储在[Amazon Simple Storage Service](https://min.io/)中.
以后会实现更多的提供程序,你可以为自己喜欢的提供程序创建[请求](https://github.com/abpframework/abp/issues/new),或者你也可以[自己实现](Blob-Storing-Custom-Provider.md)它并[贡献](Contribution/Index.md)到ABP框架.

4
docs/zh-Hans/docs-nav.json

@ -218,6 +218,10 @@
"text": "Minio提供程序",
"path": "Blob-Storing-Minio.md"
},
{
"text": "AWS提供程序",
"path": "Blob-Storing-Aws.md"
},
{
"text": "创建自定义提供程序",
"path": "Blob-Storing-Custom-Provider.md"

21
framework/src/Volo.Abp.BlobStoring.Aliyun/Volo/Abp/BlobStoring/Aliyun/AliyunBlobProviderConfiguration.cs

@ -25,6 +25,12 @@ namespace Volo.Abp.BlobStoring.Aliyun
set => _containerConfiguration.SetConfiguration(AliyunBlobProviderConfigurationNames.Endpoint, Check.NotNullOrWhiteSpace(value, nameof(value)));
}
public bool UseSecurityTokenService
{
get => _containerConfiguration.GetConfigurationOrDefault(AliyunBlobProviderConfigurationNames.UseSecurityTokenService, false);
set => _containerConfiguration.SetConfiguration(AliyunBlobProviderConfigurationNames.UseSecurityTokenService, value);
}
public string RegionId
{
get => _containerConfiguration.GetConfiguration<string>(AliyunBlobProviderConfigurationNames.RegionId);
@ -88,18 +94,19 @@ namespace Volo.Abp.BlobStoring.Aliyun
set => _containerConfiguration.SetConfiguration(AliyunBlobProviderConfigurationNames.CreateContainerIfNotExists, value);
}
private readonly BlobContainerConfiguration _containerConfiguration;
public AliyunBlobProviderConfiguration(BlobContainerConfiguration containerConfiguration)
private readonly string _temporaryCredentialsCacheKey;
public string TemporaryCredentialsCacheKey
{
_containerConfiguration = containerConfiguration;
get => _containerConfiguration.GetConfigurationOrDefault(AliyunBlobProviderConfigurationNames.TemporaryCredentialsCacheKey, _temporaryCredentialsCacheKey);
set => _containerConfiguration.SetConfiguration(AliyunBlobProviderConfigurationNames.TemporaryCredentialsCacheKey, value);
}
private readonly BlobContainerConfiguration _containerConfiguration;
public string ToKeyString()
public AliyunBlobProviderConfiguration(BlobContainerConfiguration containerConfiguration)
{
Uri uPoint = new Uri(Endpoint);
return $"blobstoring:aliyun:id:{AccessKeyId},sec:{AccessKeySecret},ept:{uPoint.Host.ToLower()},rid:{RegionId},ra:{RoleArn},rsn:{RoleSessionName},pl:{Policy}";
_containerConfiguration = containerConfiguration;
_temporaryCredentialsCacheKey = Guid.NewGuid().ToString("N");
}
}
}

4
framework/src/Volo.Abp.BlobStoring.Aliyun/Volo/Abp/BlobStoring/Aliyun/AliyunBlobProviderConfigurationNames.cs

@ -5,12 +5,14 @@
public const string AccessKeyId = "Aliyun.AccessKeyId";
public const string AccessKeySecret = "Aliyun.AccessKeySecret";
public const string Endpoint = "Aliyun.Endpoint";
public const string UseSecurityTokenService = "Aliyun.UseSecurityTokenService";
public const string RegionId = "Aliyun.RegionId";
public const string RoleArn = "Aliyun.RoleArn";
public const string RoleSessionName = "Aliyun.RoleSessionName";
public const string DurationSeconds = "Aliyun.DurationSeconds";
public const string Policy = "Aliyun.Policy";
public const string ContainerName = "Aliyun:ContainerName";
public const string ContainerName = "Aliyun.ContainerName";
public const string CreateContainerIfNotExists = "Aliyun.CreateContainerIfNotExists";
public const string TemporaryCredentialsCacheKey = "Aliyun.TemporaryCredentialsCacheKey";
}
}

7
framework/src/Volo.Abp.BlobStoring.Aliyun/Volo/Abp/BlobStoring/Aliyun/AssumeRoleCredentialsCacheItem.cs → framework/src/Volo.Abp.BlobStoring.Aliyun/Volo/Abp/BlobStoring/Aliyun/AliyunTemporaryCredentialsCacheItem.cs

@ -6,8 +6,7 @@ using Volo.Abp.Caching;
namespace Volo.Abp.BlobStoring.Aliyun
{
[Serializable]
[CacheName("AssumeRoleCredentials")]
public class AssumeRoleCredentialsCacheItem
public class AliyunTemporaryCredentialsCacheItem
{
public string AccessKeyId { get; set; }
@ -15,12 +14,12 @@ namespace Volo.Abp.BlobStoring.Aliyun
public string SecurityToken { get; set; }
public AssumeRoleCredentialsCacheItem()
public AliyunTemporaryCredentialsCacheItem()
{
}
public AssumeRoleCredentialsCacheItem(string accessKeyId,string accessKeySecret,string securityToken)
public AliyunTemporaryCredentialsCacheItem(string accessKeyId,string accessKeySecret,string securityToken)
{
AccessKeyId = accessKeyId;
AccessKeySecret = accessKeySecret;

104
framework/src/Volo.Abp.BlobStoring.Aliyun/Volo/Abp/BlobStoring/Aliyun/DefaultOssClientFactory.cs

@ -6,8 +6,11 @@ using Aliyun.OSS;
using Microsoft.Extensions.Caching.Distributed;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Security.Encryption;
using static Aliyun.Acs.Core.Auth.Sts.AssumeRoleResponse;
namespace Volo.Abp.BlobStoring.Aliyun
{
@ -16,53 +19,82 @@ namespace Volo.Abp.BlobStoring.Aliyun
/// </summary>
public class DefaultOssClientFactory : IOssClientFactory, ITransientDependency
{
protected IDistributedCache<AssumeRoleCredentialsCacheItem> Cache { get; }
protected IDistributedCache<AliyunTemporaryCredentialsCacheItem> Cache { get; }
protected IStringEncryptionService StringEncryptionService { get; }
public DefaultOssClientFactory(
IDistributedCache<AssumeRoleCredentialsCacheItem> cache)
IDistributedCache<AliyunTemporaryCredentialsCacheItem> cache,
IStringEncryptionService stringEncryptionService)
{
Cache = cache;
StringEncryptionService = stringEncryptionService;
}
public virtual IOss Create(AliyunBlobProviderConfiguration aliyunConfig)
public virtual IOss Create(AliyunBlobProviderConfiguration configuration)
{
//Sub-account
if (aliyunConfig.DurationSeconds <= 0)
Check.NotNullOrWhiteSpace(configuration.AccessKeyId, nameof(configuration.AccessKeyId));
Check.NotNullOrWhiteSpace(configuration.AccessKeySecret, nameof(configuration.AccessKeySecret));
Check.NotNullOrWhiteSpace(configuration.Endpoint, nameof(configuration.Endpoint));
if (configuration.UseSecurityTokenService)
{
return new OssClient(aliyunConfig.Endpoint, aliyunConfig.AccessKeyId, aliyunConfig.AccessKeySecret);
//STS temporary authorization to access OSS
return GetSecurityTokenClient(configuration);
}
else
//Sub-account
return new OssClient(configuration.Endpoint, configuration.AccessKeyId, configuration.AccessKeySecret);
}
protected virtual IOss GetSecurityTokenClient(AliyunBlobProviderConfiguration configuration)
{
Check.NotNullOrWhiteSpace(configuration.RoleArn, nameof(configuration.RoleArn));
Check.NotNullOrWhiteSpace(configuration.RoleSessionName, nameof(configuration.RoleSessionName));
var cacheItem = Cache.Get(configuration.TemporaryCredentialsCacheKey);
if (cacheItem == null)
{
//STS temporary authorization to access OSS
var key = aliyunConfig.ToKeyString();
var cacheItem = Cache.Get(key);
if (cacheItem == null)
IClientProfile profile = DefaultProfile.GetProfile(
configuration.RegionId,
configuration.AccessKeyId,
configuration.AccessKeySecret);
DefaultAcsClient client = new DefaultAcsClient(profile);
AssumeRoleRequest request = new AssumeRoleRequest
{
IClientProfile profile = DefaultProfile.GetProfile(
aliyunConfig.RegionId,
aliyunConfig.AccessKeyId,
aliyunConfig.AccessKeySecret);
DefaultAcsClient client = new DefaultAcsClient(profile);
AssumeRoleRequest request = new AssumeRoleRequest
{
AcceptFormat = FormatType.JSON,
//eg:acs:ram::$accountID:role/$roleName
RoleArn = aliyunConfig.RoleArn,
RoleSessionName = aliyunConfig.RoleSessionName,
//Set the validity period of the temporary access credential, the unit is s, the minimum is 900, and the maximum is 3600. default 3600
DurationSeconds = aliyunConfig.DurationSeconds,
//Set additional permission policy of Token; when acquiring Token, further reduce the permission of Token by setting an additional permission policy
Policy = aliyunConfig.Policy.IsNullOrEmpty() ? null : aliyunConfig.Policy,
};
var response = client.GetAcsResponse(request);
cacheItem = new AssumeRoleCredentialsCacheItem(response.Credentials.AccessKeyId, response.Credentials.AccessKeySecret, response.Credentials.SecurityToken);
Cache.Set(key, cacheItem, new DistributedCacheEntryOptions()
{
//Subtract 10 seconds of network request time.
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(aliyunConfig.DurationSeconds - 10)
});
}
return new OssClient(aliyunConfig.Endpoint, cacheItem.AccessKeyId, cacheItem.AccessKeySecret, cacheItem.SecurityToken);
AcceptFormat = FormatType.JSON,
//eg:acs:ram::$accountID:role/$roleName
RoleArn = configuration.RoleArn,
RoleSessionName = configuration.RoleSessionName,
//Set the validity period of the temporary access credential, the unit is s, the minimum is 900, and the maximum is 3600. default 3600
DurationSeconds = configuration.DurationSeconds,
//Set additional permission policy of Token; when acquiring Token, further reduce the permission of Token by setting an additional permission policy
Policy = configuration.Policy.IsNullOrEmpty() ? null : configuration.Policy,
};
var response = client.GetAcsResponse(request);
cacheItem = SetTemporaryCredentialsCache(configuration, response.Credentials);
}
return new OssClient(
configuration.Endpoint,
StringEncryptionService.Decrypt(cacheItem.AccessKeyId),
StringEncryptionService.Decrypt(cacheItem.AccessKeySecret),
StringEncryptionService.Decrypt(cacheItem.SecurityToken));
}
private AliyunTemporaryCredentialsCacheItem SetTemporaryCredentialsCache(
AliyunBlobProviderConfiguration configuration,
AssumeRole_Credentials credentials)
{
var temporaryCredentialsCache = new AliyunTemporaryCredentialsCacheItem(
StringEncryptionService.Encrypt(credentials.AccessKeyId),
StringEncryptionService.Encrypt(credentials.AccessKeySecret),
StringEncryptionService.Encrypt(credentials.SecurityToken));
Cache.Set(configuration.TemporaryCredentialsCacheKey, temporaryCredentialsCache,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(configuration.DurationSeconds - 10)
});
return temporaryCredentialsCache;
}
}
}

2
framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs

@ -37,7 +37,7 @@ namespace Volo.Abp.BlobStoring.Aws
var awsCredentials = GetAwsCredentials(configuration);
return awsCredentials == null
? new AmazonS3Client(region)
: new AmazonS3Client(GetAwsCredentials(configuration), region);
: new AmazonS3Client(awsCredentials, region);
}
if (configuration.UseTemporaryCredentials)

4
framework/test/Volo.Abp.BlobStoring.Aliyun.Tests/Volo/Abp/BlobStoring/Aliyun/AbpBlobStoringAliyunTestModule.cs

@ -50,14 +50,16 @@ namespace Volo.Abp.BlobStoring.Aliyun
aliyun.AccessKeySecret = accessKeySecret;
aliyun.Endpoint = endpoint;
//STS
aliyun.UseSecurityTokenService = true;
aliyun.RegionId = regionId;
aliyun.RoleArn = roleArn;
aliyun.RoleSessionName = Guid.NewGuid().ToString("N");
aliyun.DurationSeconds = 0;
aliyun.DurationSeconds = 900;
aliyun.Policy = String.Empty;
//Other
aliyun.CreateContainerIfNotExists = true;
aliyun.ContainerName = _randomContainerName;
aliyun.TemporaryCredentialsCacheKey = "297A96094D7048DBB2C28C3FDB20839A";
_configuration = aliyun;
});
});

4
modules/blogging/app/Volo.BloggingTestApp/BloggingTestAppModule.cs

@ -1,4 +1,4 @@
//#define MONGODB
//#define MONGODB
using System.Collections.Generic;
using System.Globalization;
@ -14,6 +14,7 @@ using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.Swagger;
using Volo.Abp;
using Volo.Abp.Account;
using Volo.Abp.Account.Web;
using Volo.Abp.AspNetCore.Mvc.UI;
using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap;
@ -51,6 +52,7 @@ namespace Volo.BloggingTestApp
typeof(BloggingTestAppEntityFrameworkCoreModule),
#endif
typeof(AbpAccountWebModule),
typeof(AbpAccountApplicationModule),
typeof(AbpIdentityWebModule),
typeof(AbpIdentityApplicationModule),
typeof(AbpPermissionManagementDomainIdentityModule),

1
modules/blogging/app/Volo.BloggingTestApp/Volo.BloggingTestApp.csproj

@ -33,6 +33,7 @@
<ProjectReference Include="..\..\..\..\modules\identity\src\Volo.Abp.PermissionManagement.Domain.Identity\Volo.Abp.PermissionManagement.Domain.Identity.csproj" />
<ProjectReference Include="..\..\..\..\modules\permission-management\src\Volo.Abp.PermissionManagement.Application\Volo.Abp.PermissionManagement.Application.csproj" />
<ProjectReference Include="..\..\..\..\modules\account\src\Volo.Abp.Account.Web\Volo.Abp.Account.Web.csproj" />
<ProjectReference Include="..\..\..\..\modules\account\src\Volo.Abp.Account.Application\Volo.Abp.Account.Application.csproj" />
<ProjectReference Include="..\Volo.BloggingTestApp.MongoDB\Volo.BloggingTestApp.MongoDB.csproj" />
</ItemGroup>

1
modules/blogging/src/Volo.Blogging.Web/Pages/Blogs/Posts/Edit.cshtml

@ -45,6 +45,7 @@
<abp-column size-sm="_9">
<div class="form-group">
<label for="CoverImageFile">@L["CoverImage"]</label>
<span> * </span>
<input class="form-control" type="file" id="CoverImageFile" />
</div>
</abp-column>

1
modules/blogging/src/Volo.Blogging.Web/Pages/Blogs/Posts/New.cshtml

@ -45,6 +45,7 @@
<abp-column size-sm="_9">
<div class="form-group">
<label for="CoverImageFile">@L["CoverImage"]</label>
<span> * </span>
<input class="form-control" type="file" id="CoverImageFile" />
</div>
</abp-column>

6
modules/docs/src/Volo.Docs.Web/DocsUiOptions.cs

@ -21,6 +21,12 @@ namespace Volo.Docs
/// </summary>
public bool ShowProjectsCombobox = true;
/// <summary>
/// If true, allows to create sections in document and show/hide sections according to user preferences.
/// Default value: True;
/// </summary>
public bool SectionRendering = true;
private string GetFormattedRoutePrefix()
{
if (string.IsNullOrWhiteSpace(_routePrefix))

11
modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml.cs

@ -405,12 +405,15 @@ namespace Volo.Docs.Pages.Documents.Project
private async Task ConvertDocumentContentToHtmlAsync()
{
await SetDocumentPreferencesAsync();
SetUserPreferences();
if (_uiOptions.SectionRendering)
{
await SetDocumentPreferencesAsync();
SetUserPreferences();
var partialTemplates = await GetDocumentPartialTemplatesAsync();
var partialTemplates = await GetDocumentPartialTemplatesAsync();
Document.Content = await _documentSectionRenderer.RenderAsync(Document.Content, UserPreferences, partialTemplates);
Document.Content = await _documentSectionRenderer.RenderAsync(Document.Content, UserPreferences, partialTemplates);
}
var converter = _documentToHtmlConverterFactory.Create(Document.Format ?? Project.Format);
var content = converter.Convert(Project, Document, GetSpecificVersionOrLatest(), LanguageCode);

23
modules/identity/src/Volo.Abp.Identity.Application.Contracts/Volo/Abp/Identity/AbpIdentityApplicationContractsModule.cs

@ -1,6 +1,8 @@
using Volo.Abp.Application;
using Volo.Abp.Authorization;
using Volo.Abp.Modularity;
using Volo.Abp.ObjectExtending;
using Volo.Abp.ObjectExtending.Modularity;
using Volo.Abp.PermissionManagement;
using Volo.Abp.Users;
@ -19,5 +21,26 @@ namespace Volo.Abp.Identity
{
}
public override void PostConfigureServices(ServiceConfigurationContext context)
{
ModuleExtensionConfigurationHelper
.ApplyEntityConfigurationToApi(
IdentityModuleExtensionConsts.ModuleName,
IdentityModuleExtensionConsts.EntityNames.Role,
getApiTypes: new[] { typeof(IdentityRoleDto) },
createApiTypes: new[] { typeof(IdentityRoleCreateDto) },
updateApiTypes: new[] { typeof(IdentityRoleUpdateDto) }
);
ModuleExtensionConfigurationHelper
.ApplyEntityConfigurationToApi(
IdentityModuleExtensionConsts.ModuleName,
IdentityModuleExtensionConsts.EntityNames.User,
getApiTypes: new[] { typeof(IdentityUserDto) },
createApiTypes: new[] { typeof(IdentityUserCreateDto) },
updateApiTypes: new[] { typeof(IdentityUserUpdateDto) }
);
}
}
}

5
modules/identity/src/Volo.Abp.Identity.Application.Contracts/Volo/Abp/Identity/IdentityRoleCreateOrUpdateDtoBase.cs

@ -13,5 +13,10 @@ namespace Volo.Abp.Identity
public bool IsDefault { get; set; }
public bool IsPublic { get; set; }
protected IdentityRoleCreateOrUpdateDtoBase() : base(false)
{
}
}
}

5
modules/identity/src/Volo.Abp.Identity.Application.Contracts/Volo/Abp/Identity/IdentityUserCreateOrUpdateDtoBase.cs

@ -32,5 +32,10 @@ namespace Volo.Abp.Identity
[CanBeNull]
public string[] RoleNames { get; set; }
protected IdentityUserCreateOrUpdateDtoBase() : base(false)
{
}
}
}

8
modules/identity/src/Volo.Abp.Identity.Web/AbpIdentityWebAutoMapperProfile.cs

@ -22,7 +22,7 @@ namespace Volo.Abp.Identity.Web
//CreateModal
CreateMap<CreateUserModalModel.UserInfoViewModel, IdentityUserCreateDto>()
.Ignore(x => x.ExtraProperties)
.MapExtraProperties()
.ForMember(dest => dest.RoleNames, opt => opt.Ignore());
CreateMap<IdentityRoleDto, CreateUserModalModel.AssignedRoleViewModel>()
@ -30,7 +30,7 @@ namespace Volo.Abp.Identity.Web
//EditModal
CreateMap<EditUserModalModel.UserInfoViewModel, IdentityUserUpdateDto>()
.Ignore(x => x.ExtraProperties)
.MapExtraProperties()
.ForMember(dest => dest.RoleNames, opt => opt.Ignore());
CreateMap<IdentityRoleDto, EditUserModalModel.AssignedRoleViewModel>()
@ -44,11 +44,11 @@ namespace Volo.Abp.Identity.Web
//CreateModal
CreateMap<CreateModalModel.RoleInfoModel, IdentityRoleCreateDto>()
.Ignore(x => x.ExtraProperties);
.MapExtraProperties();
//EditModal
CreateMap<EditModalModel.RoleInfoModel, IdentityRoleUpdateDto>()
.Ignore(x => x.ExtraProperties);
.MapExtraProperties();
}
}
}

21
modules/identity/src/Volo.Abp.Identity.Web/AbpIdentityWebModule.cs

@ -7,6 +7,8 @@ using Volo.Abp.AutoMapper;
using Volo.Abp.Identity.Localization;
using Volo.Abp.Identity.Web.Navigation;
using Volo.Abp.Modularity;
using Volo.Abp.ObjectExtending;
using Volo.Abp.ObjectExtending.Modularity;
using Volo.Abp.PermissionManagement.Web;
using Volo.Abp.UI.Navigation;
using Volo.Abp.VirtualFileSystem;
@ -62,5 +64,24 @@ namespace Volo.Abp.Identity.Web
options.Conventions.AuthorizePage("/Identity/Roles/EditModal", IdentityPermissions.Roles.Update);
});
}
public override void PostConfigureServices(ServiceConfigurationContext context)
{
ModuleExtensionConfigurationHelper
.ApplyEntityConfigurationToUi(
IdentityModuleExtensionConsts.ModuleName,
IdentityModuleExtensionConsts.EntityNames.Role,
createFormTypes: new[] { typeof(Volo.Abp.Identity.Web.Pages.Identity.Roles.CreateModalModel.RoleInfoModel) },
editFormTypes: new[] { typeof(Volo.Abp.Identity.Web.Pages.Identity.Roles.EditModalModel.RoleInfoModel) }
);
ModuleExtensionConfigurationHelper
.ApplyEntityConfigurationToUi(
IdentityModuleExtensionConsts.ModuleName,
IdentityModuleExtensionConsts.EntityNames.User,
createFormTypes: new[] { typeof(Volo.Abp.Identity.Web.Pages.Identity.Users.CreateModalModel.UserInfoViewModel) },
editFormTypes: new[] { typeof(Volo.Abp.Identity.Web.Pages.Identity.Users.EditModalModel.UserInfoViewModel) }
);
}
}
}

26
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/CreateModal.cshtml

@ -1,9 +1,14 @@
@page
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@using Volo.Abp.Identity.Localization
@model Volo.Abp.Identity.Web.Pages.Identity.Roles.CreateModalModel
@using Volo.Abp.Identity.Web.Pages.Identity.Roles
@using Volo.Abp.Localization
@using Volo.Abp.ObjectExtending
@model CreateModalModel
@inject IHtmlLocalizer<IdentityResource> L
@inject IStringLocalizerFactory StringLocalizerFactory
@{
Layout = null;
}
@ -12,8 +17,27 @@
<abp-modal-header title="@L["NewRole"].Value"></abp-modal-header>
<abp-modal-body>
<abp-input asp-for="Role.Name" />
<abp-input asp-for="Role.IsDefault" />
<abp-input asp-for="Role.IsPublic" />
@foreach (var propertyInfo in ObjectExtensionManager.Instance.GetProperties<CreateModalModel.RoleInfoModel>())
{
if (propertyInfo.Type.IsEnum)
{
<abp-select asp-for="Role.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"></abp-select>
}
else
{
<abp-input type="@propertyInfo.GetInputType()"
asp-for="Role.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"
asp-format="@propertyInfo.GetInputFormatOrNull()"
value="@propertyInfo.GetInputValueOrNull(Model.Role.ExtraProperties[propertyInfo.Name])" />
}
}
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>

5
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/CreateModal.cshtml.cs

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.ObjectExtending;
using Volo.Abp.Validation;
namespace Volo.Abp.Identity.Web.Pages.Identity.Roles
@ -19,6 +20,8 @@ namespace Volo.Abp.Identity.Web.Pages.Identity.Roles
public virtual Task<IActionResult> OnGetAsync()
{
Role = new RoleInfoModel();
return Task.FromResult<IActionResult>(Page());
}
@ -32,7 +35,7 @@ namespace Volo.Abp.Identity.Web.Pages.Identity.Roles
return NoContent();
}
public class RoleInfoModel
public class RoleInfoModel : ExtensibleObject
{
[Required]
[DynamicStringLength(typeof(IdentityRoleConsts), nameof(IdentityRoleConsts.MaxNameLength))]

28
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/EditModal.cshtml

@ -1,9 +1,14 @@
@page
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@using Volo.Abp.Identity.Localization
@model Volo.Abp.Identity.Web.Pages.Identity.Roles.EditModalModel
@using Volo.Abp.Identity.Web.Pages.Identity.Roles
@using Volo.Abp.Localization
@using Volo.Abp.ObjectExtending
@model EditModalModel
@inject IHtmlLocalizer<IdentityResource> L
@inject IStringLocalizerFactory StringLocalizerFactory
@{
Layout = null;
}
@ -12,7 +17,9 @@
<abp-modal-header title="@L["Edit"].Value"></abp-modal-header>
<abp-modal-body>
<abp-input asp-for="Role.Id" />
<abp-input asp-for="Role.ConcurrencyStamp" />
@if (Model.Role.IsStatic)
{
<abp-input asp-for="Role.Name" readonly="true" />
@ -21,8 +28,27 @@
{
<abp-input asp-for="Role.Name" />
}
<abp-input asp-for="Role.IsDefault" />
<abp-input asp-for="Role.IsPublic" />
@foreach (var propertyInfo in ObjectExtensionManager.Instance.GetProperties<CreateModalModel.RoleInfoModel>())
{
if (propertyInfo.Type.IsEnum)
{
<abp-select asp-for="Role.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"></abp-select>
}
else
{
<abp-input type="@propertyInfo.GetInputType()"
asp-for="Role.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"
asp-format="@propertyInfo.GetInputFormatOrNull()"
value="@propertyInfo.GetInputValueOrNull(Model.Role.ExtraProperties[propertyInfo.Name])" />
}
}
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>

3
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/EditModal.cshtml.cs

@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Domain.Entities;
using Volo.Abp.ObjectExtending;
using Volo.Abp.Validation;
namespace Volo.Abp.Identity.Web.Pages.Identity.Roles
@ -36,7 +37,7 @@ namespace Volo.Abp.Identity.Web.Pages.Identity.Roles
return NoContent();
}
public class RoleInfoModel : IHasConcurrencyStamp
public class RoleInfoModel : ExtensibleObject, IHasConcurrencyStamp
{
[HiddenInput]
public Guid Id { get; set; }

9
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/Index.cshtml

@ -41,13 +41,6 @@
</abp-row>
</abp-card-header>
<abp-card-body>
<abp-table striped-rows="true" class="nowrap">
<thead>
<tr>
<th>@L["Actions"]</th>
<th>@L["RoleName"]</th>
</tr>
</thead>
</abp-table>
<abp-table striped-rows="true" class="nowrap"></abp-table>
</abp-card-body>
</abp-card>

155
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Roles/index.js

@ -12,76 +12,76 @@
abp.appPath + 'Identity/Roles/CreateModal'
);
$(function () {
var _$wrapper = $('#IdentityRolesWrapper');
var _$table = _$wrapper.find('table');
var _dataTable = null;
var _dataTable = _$table.DataTable(
abp.libs.datatables.normalizeConfiguration({
order: [[1, 'asc']],
searching: false,
processing: true,
serverSide: true,
scrollX: true,
paging: true,
ajax: abp.libs.datatables.createAjax(
_identityRoleAppService.getList
),
columnDefs: [
abp.ui.extensions.entityActions.get('identity.role').addContributor(
function(actionList) {
return actionList.addManyTail(
[
{
rowAction: {
items: [
{
text: l('Edit'),
visible: abp.auth.isGranted(
'AbpIdentity.Roles.Update'
),
action: function (data) {
_editModal.open({
id: data.record.id,
});
},
},
{
text: l('Permissions'),
visible: abp.auth.isGranted(
'AbpIdentity.Roles.ManagePermissions'
),
action: function (data) {
_permissionsModal.open({
providerName: 'R',
providerKey: data.record.name,
});
},
},
{
text: l('Delete'),
visible: function (data) {
return (
!data.isStatic &&
abp.auth.isGranted(
'AbpIdentity.Roles.Delete'
)
); //TODO: Check permission
},
confirmMessage: function (data) {
return l(
'RoleDeletionConfirmationMessage',
data.record.name
);
},
action: function (data) {
_identityRoleAppService
.delete(data.record.id)
.then(function () {
_dataTable.ajax.reload();
});
},
},
],
text: l('Edit'),
visible: abp.auth.isGranted(
'AbpIdentity.Roles.Update'
),
action: function (data) {
_editModal.open({
id: data.record.id,
});
},
},
{
text: l('Permissions'),
visible: abp.auth.isGranted(
'AbpIdentity.Roles.ManagePermissions'
),
action: function (data) {
_permissionsModal.open({
providerName: 'R',
providerKey: data.record.name,
});
},
},
{
text: l('Delete'),
visible: function (data) {
return (
!data.isStatic &&
abp.auth.isGranted(
'AbpIdentity.Roles.Delete'
)
); //TODO: Check permission
},
confirmMessage: function (data) {
return l(
'RoleDeletionConfirmationMessage',
data.record.name
);
},
action: function (data) {
_identityRoleAppService
.delete(data.record.id)
.then(function () {
_dataTable.ajax.reload();
});
},
}
]
);
}
);
abp.ui.extensions.tableColumns.get('identity.role').addContributor(
function (columnList) {
columnList.addManyTail(
[
{
title: l("Actions"),
rowAction: {
items: abp.ui.extensions.entityActions.get('identity.role').actions.toArray()
}
},
{
title: l('RoleName'),
data: 'name',
render: function (data, type, row) {
var name = '<span>' + data + '</span>';
@ -99,8 +99,29 @@
}
return name;
},
},
],
}
]
);
},
0 //adds as the first contributor
);
$(function () {
var _$wrapper = $('#IdentityRolesWrapper');
var _$table = _$wrapper.find('table');
_dataTable = _$table.DataTable(
abp.libs.datatables.normalizeConfiguration({
order: [[1, 'asc']],
searching: false,
processing: true,
serverSide: true,
scrollX: true,
paging: true,
ajax: abp.libs.datatables.createAjax(
_identityRoleAppService.getList
),
columnDefs: abp.ui.extensions.tableColumns.get('identity.role').columns.toArray()
})
);

24
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/CreateModal.cshtml

@ -1,9 +1,14 @@
@page
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@using Volo.Abp.Identity.Localization
@model Volo.Abp.Identity.Web.Pages.Identity.Users.CreateModalModel
@using Volo.Abp.Identity.Web.Pages.Identity.Users
@using Volo.Abp.Localization
@using Volo.Abp.ObjectExtending
@model CreateModalModel
@inject IHtmlLocalizer<IdentityResource> L
@inject IStringLocalizerFactory StringLocalizerFactory
@{
Layout = null;
}
@ -22,6 +27,23 @@
<abp-input asp-for="UserInfo.PhoneNumber" />
<abp-input asp-for="UserInfo.LockoutEnabled" />
<abp-input asp-for="UserInfo.TwoFactorEnabled" />
@foreach (var propertyInfo in ObjectExtensionManager.Instance.GetProperties<CreateModalModel.UserInfoViewModel>())
{
if (propertyInfo.Type.IsEnum)
{
<abp-select asp-for="UserInfo.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"></abp-select>
}
else
{
<abp-input type="@propertyInfo.GetInputType()"
asp-for="UserInfo.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"
asp-format="@propertyInfo.GetInputFormatOrNull()"
value="@propertyInfo.GetInputValueOrNull(Model.UserInfo.ExtraProperties[propertyInfo.Name])" />
}
}
</abp-tab>
<abp-tab title="@L["Roles"].Value">
@for (var i = 0; i < Model.Roles.Length; i++)

3
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/CreateModal.cshtml.cs

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Auditing;
using Volo.Abp.Application.Dtos;
using Volo.Abp.ObjectExtending;
using Volo.Abp.Validation;
namespace Volo.Abp.Identity.Web.Pages.Identity.Users
@ -52,7 +53,7 @@ namespace Volo.Abp.Identity.Web.Pages.Identity.Users
return NoContent();
}
public class UserInfoViewModel
public class UserInfoViewModel : ExtensibleObject
{
[Required]
[DynamicStringLength(typeof(IdentityUserConsts), nameof(IdentityUserConsts.MaxUserNameLength))]

25
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/EditModal.cshtml

@ -1,9 +1,14 @@
@page
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@using Volo.Abp.Identity.Localization
@model Volo.Abp.Identity.Web.Pages.Identity.Users.EditModalModel
@using Volo.Abp.Identity.Web.Pages.Identity.Users
@using Volo.Abp.Localization
@using Volo.Abp.ObjectExtending
@model EditModalModel
@inject IHtmlLocalizer<IdentityResource> L
@inject IStringLocalizerFactory StringLocalizerFactory
@{
Layout = null;
}
@ -24,6 +29,24 @@
<abp-input asp-for="UserInfo.PhoneNumber" />
<abp-input asp-for="UserInfo.LockoutEnabled" />
<abp-input asp-for="UserInfo.TwoFactorEnabled" />
@foreach (var propertyInfo in ObjectExtensionManager.Instance.GetProperties<EditModalModel.UserInfoViewModel>())
{
if (propertyInfo.Type.IsEnum)
{
<abp-select asp-for="UserInfo.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"></abp-select>
}
else
{
<abp-input type="@propertyInfo.GetInputType()"
asp-for="UserInfo.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"
asp-format="@propertyInfo.GetInputFormatOrNull()"
value="@propertyInfo.GetInputValueOrNull(Model.UserInfo.ExtraProperties[propertyInfo.Name])" />
}
}
</abp-tab>
<abp-tab title="@L["Roles"].Value">
@for (var i = 0; i < Model.Roles.Length; i++)

3
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/EditModal.cshtml.cs

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Auditing;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Domain.Entities;
using Volo.Abp.ObjectExtending;
using Volo.Abp.Validation;
namespace Volo.Abp.Identity.Web.Pages.Identity.Users
@ -55,7 +56,7 @@ namespace Volo.Abp.Identity.Web.Pages.Identity.Users
return NoContent();
}
public class UserInfoViewModel : IHasConcurrencyStamp
public class UserInfoViewModel : ExtensibleObject, IHasConcurrencyStamp
{
[HiddenInput]
public Guid Id { get; set; }

11
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/Index.cshtml

@ -42,15 +42,6 @@
</abp-row>
</abp-card-header>
<abp-card-body>
<abp-table striped-rows="true" class="nowrap">
<thead>
<tr>
<th>@L["Actions"]</th>
<th>@L["UserName"]</th>
<th>@L["EmailAddress"]</th>
<th>@L["PhoneNumber"]</th>
</tr>
</thead>
</abp-table>
<abp-table striped-rows="true" class="nowrap"></abp-table>
</abp-card-body>
</abp-card>

143
modules/identity/src/Volo.Abp.Identity.Web/Pages/Identity/Users/index.js

@ -12,10 +12,91 @@
abp.appPath + 'AbpPermissionManagement/PermissionManagementModal'
);
var _dataTable = null;
abp.ui.extensions.entityActions.get('identity.user').addContributor(
function(actionList) {
return actionList.addManyTail(
[
{
text: l('Edit'),
visible: abp.auth.isGranted(
'AbpIdentity.Users.Update'
),
action: function (data) {
_editModal.open({
id: data.record.id,
});
},
},
{
text: l('Permissions'),
visible: abp.auth.isGranted(
'AbpIdentity.Users.ManagePermissions'
),
action: function (data) {
_permissionsModal.open({
providerName: 'U',
providerKey: data.record.id,
});
},
},
{
text: l('Delete'),
visible: abp.auth.isGranted(
'AbpIdentity.Users.Delete'
),
confirmMessage: function (data) {
return l(
'UserDeletionConfirmationMessage',
data.record.userName
);
},
action: function (data) {
_identityUserAppService
.delete(data.record.id)
.then(function () {
_dataTable.ajax.reload();
});
},
}
]
);
}
);
abp.ui.extensions.tableColumns.get('identity.user').addContributor(
function (columnList) {
columnList.addManyTail(
[
{
title: l("Actions"),
rowAction: {
items: abp.ui.extensions.entityActions.get('identity.user').actions.toArray()
}
},
{
title: l('UserName'),
data: 'userName',
},
{
title: l('EmailAddress'),
data: 'email',
},
{
title: l('PhoneNumber'),
data: 'phoneNumber',
}
]
);
},
0 //adds as the first contributor
);
$(function () {
var _$wrapper = $('#IdentityUsersWrapper');
var _$table = _$wrapper.find('table');
var _dataTable = _$table.DataTable(
_dataTable = _$table.DataTable(
abp.libs.datatables.normalizeConfiguration({
order: [[1, 'asc']],
processing: true,
@ -25,65 +106,7 @@
ajax: abp.libs.datatables.createAjax(
_identityUserAppService.getList
),
columnDefs: [
{
rowAction: {
items: [
{
text: l('Edit'),
visible: abp.auth.isGranted(
'AbpIdentity.Users.Update'
),
action: function (data) {
_editModal.open({
id: data.record.id,
});
},
},
{
text: l('Permissions'),
visible: abp.auth.isGranted(
'AbpIdentity.Users.ManagePermissions'
),
action: function (data) {
_permissionsModal.open({
providerName: 'U',
providerKey: data.record.id,
});
},
},
{
text: l('Delete'),
visible: abp.auth.isGranted(
'AbpIdentity.Users.Delete'
),
confirmMessage: function (data) {
return l(
'UserDeletionConfirmationMessage',
data.record.userName
);
},
action: function (data) {
_identityUserAppService
.delete(data.record.id)
.then(function () {
_dataTable.ajax.reload();
});
},
},
],
},
},
{
data: 'userName',
},
{
data: 'email',
},
{
data: 'phoneNumber',
},
],
columnDefs: abp.ui.extensions.tableColumns.get('identity.user').columns.toArray()
})
);

14
modules/tenant-management/src/Volo.Abp.TenantManagement.Application.Contracts/Volo/Abp/TenantManagement/AbpTenantManagementApplicationContractsModule.cs

@ -1,5 +1,7 @@
using Volo.Abp.Application;
using Volo.Abp.Modularity;
using Volo.Abp.ObjectExtending;
using Volo.Abp.ObjectExtending.Modularity;
namespace Volo.Abp.TenantManagement
{
@ -8,6 +10,16 @@ namespace Volo.Abp.TenantManagement
typeof(AbpTenantManagementDomainSharedModule))]
public class AbpTenantManagementApplicationContractsModule : AbpModule
{
public override void PostConfigureServices(ServiceConfigurationContext context)
{
ModuleExtensionConfigurationHelper
.ApplyEntityConfigurationToApi(
TenantManagementModuleExtensionConsts.ModuleName,
TenantManagementModuleExtensionConsts.EntityNames.Tenant,
getApiTypes: new[] { typeof(TenantDto) },
createApiTypes: new[] { typeof(TenantCreateDto) },
updateApiTypes: new[] { typeof(TenantUpdateDto) }
);
}
}
}

5
modules/tenant-management/src/Volo.Abp.TenantManagement.Application.Contracts/Volo/Abp/TenantManagement/TenantCreateOrUpdateDtoBase.cs

@ -9,5 +9,10 @@ namespace Volo.Abp.TenantManagement
[Required]
[DynamicStringLength(typeof(TenantConsts), nameof(TenantConsts.MaxNameLength))]
public string Name { get; set; }
public TenantCreateOrUpdateDtoBase() : base(false)
{
}
}
}

17
modules/tenant-management/src/Volo.Abp.TenantManagement.Domain.Shared/Volo/Abp/ObjectExtending/TenantManagementModuleExtensionConfiguration.cs

@ -0,0 +1,17 @@
using System;
using Volo.Abp.ObjectExtending.Modularity;
namespace Volo.Abp.ObjectExtending
{
public class TenantManagementModuleExtensionConfiguration : ModuleExtensionConfiguration
{
public TenantManagementModuleExtensionConfiguration ConfigureTenant(
Action<EntityExtensionConfiguration> configureAction)
{
return this.ConfigureEntity(
TenantManagementModuleExtensionConsts.EntityNames.Tenant,
configureAction
);
}
}
}

18
modules/tenant-management/src/Volo.Abp.TenantManagement.Domain.Shared/Volo/Abp/ObjectExtending/TenantManagementModuleExtensionConfigurationDictionaryExtensions.cs

@ -0,0 +1,18 @@
using System;
using Volo.Abp.ObjectExtending.Modularity;
namespace Volo.Abp.ObjectExtending
{
public static class TenantManagementModuleExtensionConfigurationDictionaryExtensions
{
public static ModuleExtensionConfigurationDictionary ConfigureTenantManagement(
this ModuleExtensionConfigurationDictionary modules,
Action<TenantManagementModuleExtensionConfiguration> configureAction)
{
return modules.ConfigureModule(
TenantManagementModuleExtensionConsts.ModuleName,
configureAction
);
}
}
}

12
modules/tenant-management/src/Volo.Abp.TenantManagement.Domain.Shared/Volo/Abp/ObjectExtending/TenantManagementModuleExtensionConsts.cs

@ -0,0 +1,12 @@
namespace Volo.Abp.ObjectExtending
{
public class TenantManagementModuleExtensionConsts
{
public const string ModuleName = "TenantManagement";
public static class EntityNames
{
public const string Tenant = "Tenant";
}
}
}

11
modules/tenant-management/src/Volo.Abp.TenantManagement.Domain/Volo/Abp/TenantManagement/AbpTenantManagementDomainModule.cs

@ -5,6 +5,8 @@ using Volo.Abp.Domain;
using Volo.Abp.Domain.Entities.Events.Distributed;
using Volo.Abp.Modularity;
using Volo.Abp.MultiTenancy;
using Volo.Abp.ObjectExtending;
using Volo.Abp.ObjectExtending.Modularity;
namespace Volo.Abp.TenantManagement
{
@ -29,5 +31,14 @@ namespace Volo.Abp.TenantManagement
options.EtoMappings.Add<Tenant, TenantEto>();
});
}
public override void PostConfigureServices(ServiceConfigurationContext context)
{
ModuleExtensionConfigurationHelper.ApplyEntityConfigurationToEntity(
TenantManagementModuleExtensionConsts.ModuleName,
TenantManagementModuleExtensionConsts.EntityNames.Tenant,
typeof(Tenant)
);
}
}
}

4
modules/tenant-management/src/Volo.Abp.TenantManagement.Web/AbpTenantManagementWebAutoMapperProfile.cs

@ -13,11 +13,11 @@ namespace Volo.Abp.TenantManagement.Web
//CreateModal
CreateMap<CreateModalModel.TenantInfoModel, TenantCreateDto>()
.Ignore(x => x.ExtraProperties);
.MapExtraProperties();
//EditModal
CreateMap<EditModalModel.TenantInfoModel, TenantUpdateDto>()
.Ignore(x => x.ExtraProperties);
.MapExtraProperties();
}
}
}

13
modules/tenant-management/src/Volo.Abp.TenantManagement.Web/AbpTenantManagementWebModule.cs

@ -5,6 +5,8 @@ using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap;
using Volo.Abp.AutoMapper;
using Volo.Abp.FeatureManagement;
using Volo.Abp.Modularity;
using Volo.Abp.ObjectExtending;
using Volo.Abp.ObjectExtending.Modularity;
using Volo.Abp.TenantManagement.Localization;
using Volo.Abp.TenantManagement.Web.Navigation;
using Volo.Abp.UI.Navigation;
@ -57,5 +59,16 @@ namespace Volo.Abp.TenantManagement.Web
options.Conventions.AuthorizePage("/TenantManagement/Tenants/ConnectionStrings", TenantManagementPermissions.Tenants.ManageConnectionStrings);
});
}
public override void PostConfigureServices(ServiceConfigurationContext context)
{
ModuleExtensionConfigurationHelper
.ApplyEntityConfigurationToUi(
TenantManagementModuleExtensionConsts.ModuleName,
TenantManagementModuleExtensionConsts.EntityNames.Tenant,
createFormTypes: new[] { typeof(Volo.Abp.TenantManagement.Web.Pages.TenantManagement.Tenants.CreateModalModel.TenantInfoModel) },
editFormTypes: new[] { typeof(Volo.Abp.TenantManagement.Web.Pages.TenantManagement.Tenants.EditModalModel.TenantInfoModel) }
);
}
}
}

25
modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml

@ -1,22 +1,43 @@
@page
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@using Volo.Abp.Localization
@using Volo.Abp.ObjectExtending
@using Volo.Abp.TenantManagement.Localization
@using Volo.Abp.TenantManagement.Web.Pages.TenantManagement.Tenants
@model CreateModalModel
@inject IStringLocalizer<AbpTenantManagementResource> L
@inject IHtmlLocalizer<AbpTenantManagementResource> L
@inject IStringLocalizerFactory StringLocalizerFactory
@{
Layout = null;
}
<form method="post" asp-page="/TenantManagement/Tenants/CreateModal">
<abp-modal>
<abp-modal-header title="@L["NewTenant"]"></abp-modal-header>
<abp-modal-header title="@L["NewTenant"].Value"></abp-modal-header>
<abp-modal-body>
<abp-input asp-for="Tenant.Name" />
<abp-input asp-for="Tenant.AdminEmailAddress" />
<abp-input asp-for="Tenant.AdminPassword" />
@foreach (var propertyInfo in ObjectExtensionManager.Instance.GetProperties<CreateModalModel.TenantInfoModel>())
{
if (propertyInfo.Type.IsEnum)
{
<abp-select asp-for="Tenant.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"></abp-select>
}
else
{
<abp-input type="@propertyInfo.GetInputType()"
asp-for="Tenant.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"
asp-format="@propertyInfo.GetInputFormatOrNull()"
value="@propertyInfo.GetInputValueOrNull(Model.Tenant.ExtraProperties[propertyInfo.Name])" />
}
}
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>

4
modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.ObjectExtending;
using Volo.Abp.Validation;
namespace Volo.Abp.TenantManagement.Web.Pages.TenantManagement.Tenants
@ -19,6 +20,7 @@ namespace Volo.Abp.TenantManagement.Web.Pages.TenantManagement.Tenants
public virtual Task<IActionResult> OnGetAsync()
{
Tenant = new TenantInfoModel();
return Task.FromResult<IActionResult>(Page());
}
@ -32,7 +34,7 @@ namespace Volo.Abp.TenantManagement.Web.Pages.TenantManagement.Tenants
return NoContent();
}
public class TenantInfoModel
public class TenantInfoModel: ExtensibleObject
{
[Required]
[DynamicStringLength(typeof(TenantConsts), nameof(TenantConsts.MaxNameLength))]

20
modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml

@ -1,10 +1,14 @@
@page
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal
@using Volo.Abp.Localization
@using Volo.Abp.ObjectExtending
@using Volo.Abp.TenantManagement.Localization
@using Volo.Abp.TenantManagement.Web.Pages.TenantManagement.Tenants
@model EditModalModel
@inject IHtmlLocalizer<AbpTenantManagementResource> L
@inject IStringLocalizerFactory StringLocalizerFactory
@{
Layout = null;
}
@ -14,6 +18,22 @@
<abp-modal-body>
<input asp-for="Tenant.Id" />
<abp-input asp-for="Tenant.Name" label="@L["TenantName"].Value" />
@foreach (var propertyInfo in ObjectExtensionManager.Instance.GetProperties<EditModalModel.TenantInfoModel>())
{
if (propertyInfo.Type.IsEnum)
{
<abp-select asp-for="Tenant.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"></abp-select>
}
else
{
<abp-input type="@propertyInfo.GetInputType()"
asp-for="Tenant.ExtraProperties[propertyInfo.Name]"
label="@propertyInfo.GetLocalizedDisplayName(StringLocalizerFactory)"
asp-format="@propertyInfo.GetInputFormatOrNull()"
value="@propertyInfo.GetInputValueOrNull(Model.Tenant.ExtraProperties[propertyInfo.Name])" />
}
}
</abp-modal-body>
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer>
</abp-modal>

3
modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs

@ -2,6 +2,7 @@ using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.ObjectExtending;
using Volo.Abp.Validation;
namespace Volo.Abp.TenantManagement.Web.Pages.TenantManagement.Tenants
@ -37,7 +38,7 @@ namespace Volo.Abp.TenantManagement.Web.Pages.TenantManagement.Tenants
return NoContent();
}
public class TenantInfoModel
public class TenantInfoModel : ExtensibleObject
{
[HiddenInput]
public Guid Id { get; set; }

9
modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml

@ -37,13 +37,6 @@
</abp-row>
</abp-card-header>
<abp-card-body>
<abp-table striped-rows="true" class="nowrap">
<thead>
<tr>
<th>@L["Actions"]</th>
<th>@L["TenantName"]</th>
</tr>
</thead>
</abp-table>
<abp-table striped-rows="true" class="nowrap"></abp-table>
</abp-card-body>
</abp-card>

151
modules/tenant-management/src/Volo.Abp.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js

@ -17,10 +17,94 @@
modalClass: 'TenantConnectionStringManagement',
});
var _dataTable = null;
abp.ui.extensions.entityActions.get('tenantManagement.tenant').addContributor(
function(actionList) {
return actionList.addManyTail(
[
{
text: l('Edit'),
visible: abp.auth.isGranted(
'AbpTenantManagement.Tenants.Update'
),
action: function (data) {
_editModal.open({
id: data.record.id,
});
},
},
{
text: l('ConnectionStrings'),
visible: abp.auth.isGranted(
'AbpTenantManagement.Tenants.ManageConnectionStrings'
),
action: function (data) {
_connectionStringsModal.open({
id: data.record.id,
});
},
},
{
text: l('Features'),
visible: abp.auth.isGranted(
'AbpTenantManagement.Tenants.ManageFeatures'
),
action: function (data) {
_featuresModal.open({
providerName: 'T',
providerKey: data.record.id,
});
},
},
{
text: l('Delete'),
visible: abp.auth.isGranted(
'AbpTenantManagement.Tenants.Delete'
),
confirmMessage: function (data) {
return l(
'TenantDeletionConfirmationMessage',
data.record.name
);
},
action: function (data) {
_tenantAppService
.delete(data.record.id)
.then(function () {
_dataTable.ajax.reload();
});
},
}
]
);
}
);
abp.ui.extensions.tableColumns.get('tenantManagement.tenant').addContributor(
function (columnList) {
columnList.addManyTail(
[
{
title: l("Actions"),
rowAction: {
items: abp.ui.extensions.entityActions.get('tenantManagement.tenant').actions.toArray()
}
},
{
title: l("TenantName"),
data: 'name',
}
]
);
},
0 //adds as the first contributor
);
$(function () {
var _$wrapper = $('#TenantsWrapper');
var _dataTable = _$wrapper.find('table').DataTable(
_dataTable = _$wrapper.find('table').DataTable(
abp.libs.datatables.normalizeConfiguration({
order: [[1, 'asc']],
processing: true,
@ -28,70 +112,7 @@
scrollX: true,
serverSide: true,
ajax: abp.libs.datatables.createAjax(_tenantAppService.getList),
columnDefs: [
{
rowAction: {
items: [
{
text: l('Edit'),
visible: abp.auth.isGranted(
'AbpTenantManagement.Tenants.Update'
),
action: function (data) {
_editModal.open({
id: data.record.id,
});
},
},
{
text: l('ConnectionStrings'),
visible: abp.auth.isGranted(
'AbpTenantManagement.Tenants.ManageConnectionStrings'
),
action: function (data) {
_connectionStringsModal.open({
id: data.record.id,
});
},
},
{
text: l('Features'),
visible: abp.auth.isGranted(
'AbpTenantManagement.Tenants.ManageFeatures'
),
action: function (data) {
_featuresModal.open({
providerName: 'T',
providerKey: data.record.id,
});
},
},
{
text: l('Delete'),
visible: abp.auth.isGranted(
'AbpTenantManagement.Tenants.Delete'
),
confirmMessage: function (data) {
return l(
'TenantDeletionConfirmationMessage',
data.record.name
);
},
action: function (data) {
_tenantAppService
.delete(data.record.id)
.then(function () {
_dataTable.ajax.reload();
});
},
},
],
},
},
{
data: 'name',
},
],
columnDefs: abp.ui.extensions.tableColumns.get('tenantManagement.tenant').columns.toArray(),
})
);

27
npm/ng-packs/packages/account/src/lib/components/auth-wrapper/auth-wrapper.component.ts

@ -1,21 +1,18 @@
import { ConfigState, takeUntilDestroy } from '@abp/ng.core';
import { Component, Input, OnDestroy, OnInit, TemplateRef } from '@angular/core';
import { ConfigState, SubscriptionService } from '@abp/ng.core';
import { Component, Input, OnInit, TemplateRef } from '@angular/core';
import { Select, Store } from '@ngxs/store';
import { Observable } from 'rxjs';
import { Account } from '../../models/account';
import { eAccountComponents } from '../../enums/components';
import { Account } from '../../models/account';
@Component({
selector: 'abp-auth-wrapper',
templateUrl: './auth-wrapper.component.html',
exportAs: 'abpAuthWrapper',
providers: [SubscriptionService],
})
export class AuthWrapperComponent
implements
Account.AuthWrapperComponentInputs,
Account.AuthWrapperComponentOutputs,
OnInit,
OnDestroy {
implements Account.AuthWrapperComponentInputs, Account.AuthWrapperComponentOutputs, OnInit {
@Input()
readonly mainContentRef: TemplateRef<any>;
@ -29,18 +26,16 @@ export class AuthWrapperComponent
tenantBoxKey = eAccountComponents.TenantBox;
constructor(private store: Store) {}
constructor(private store: Store, private subscription: SubscriptionService) {}
ngOnInit() {
this.store
.select(ConfigState.getSetting('Abp.Account.EnableLocalLogin'))
.pipe(takeUntilDestroy(this))
.subscribe(value => {
this.subscription.addOne(
this.store.select(ConfigState.getSetting('Abp.Account.EnableLocalLogin')),
value => {
if (value) {
this.enableLocalLogin = value.toLowerCase() !== 'false';
}
});
},
);
}
ngOnDestroy() {}
}

14
npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts

@ -1,4 +1,4 @@
import { Component, Injector, OnDestroy, Optional, SkipSelf, Type } from '@angular/core';
import { Component, Injector, Optional, SkipSelf, Type } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngxs/store';
import { eLayoutType } from '../enums/common';
@ -6,9 +6,9 @@ import { ABP } from '../models';
import { ReplaceableComponents } from '../models/replaceable-components';
import { LocalizationService } from '../services/localization.service';
import { RoutesService } from '../services/routes.service';
import { SubscriptionService } from '../services/subscription.service';
import { ReplaceableComponentsState } from '../states/replaceable-components.state';
import { findRoute, getRoutePath } from '../utils/route-utils';
import { takeUntilDestroy } from '../utils/rxjs-utils';
import { TreeNode } from '../utils/tree-utils';
@Component({
@ -20,8 +20,9 @@ import { TreeNode } from '../utils/tree-utils';
><ng-container *ngIf="isLayoutVisible" [ngComponentOutlet]="layout"></ng-container
></ng-template>
`,
providers: [SubscriptionService],
})
export class DynamicLayoutComponent implements OnDestroy {
export class DynamicLayoutComponent {
layout: Type<any>;
// TODO: Consider a shared enum (eThemeSharedComponents) for known layouts
@ -37,6 +38,7 @@ export class DynamicLayoutComponent implements OnDestroy {
injector: Injector,
private localizationService: LocalizationService,
private store: Store,
private subscription: SubscriptionService,
@Optional() @SkipSelf() dynamicLayoutComponent: DynamicLayoutComponent,
) {
if (dynamicLayoutComponent) return;
@ -44,7 +46,7 @@ export class DynamicLayoutComponent implements OnDestroy {
const router = injector.get(Router);
const routes = injector.get(RoutesService);
router.events.pipe(takeUntilDestroy(this)).subscribe(event => {
this.subscription.addOne(router.events, event => {
if (event instanceof NavigationEnd) {
let expectedLayout = (route.snapshot.data || {}).layout;
@ -73,7 +75,7 @@ export class DynamicLayoutComponent implements OnDestroy {
}
private listenToLanguageChange() {
this.localizationService.languageChange.pipe(takeUntilDestroy(this)).subscribe(() => {
this.subscription.addOne(this.localizationService.languageChange, () => {
this.isLayoutVisible = false;
setTimeout(() => (this.isLayoutVisible = true), 0);
});
@ -82,6 +84,4 @@ export class DynamicLayoutComponent implements OnDestroy {
private getComponent(key: string): ReplaceableComponents.ReplaceableComponent {
return this.store.selectSnapshot(ReplaceableComponentsState.getComponent(key));
}
ngOnDestroy() {}
}

28
npm/ng-packs/packages/core/src/lib/components/replaceable-route-container.component.ts

@ -1,39 +1,45 @@
import { Component, OnDestroy, OnInit, Type } from '@angular/core';
import { Component, OnInit, Type } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngxs/store';
import { distinctUntilChanged } from 'rxjs/operators';
import { ABP } from '../models/common';
import { ReplaceableComponents } from '../models/replaceable-components';
import { SubscriptionService } from '../services/subscription.service';
import { ReplaceableComponentsState } from '../states/replaceable-components.state';
import { takeUntilDestroy } from '../utils/rxjs-utils';
@Component({
selector: 'abp-replaceable-route-container',
template: `
<ng-container *ngComponentOutlet="externalComponent || defaultComponent"></ng-container>
`,
providers: [SubscriptionService],
})
export class ReplaceableRouteContainerComponent implements OnInit, OnDestroy {
export class ReplaceableRouteContainerComponent implements OnInit {
defaultComponent: Type<any>;
componentKey: string;
externalComponent: Type<any>;
constructor(private route: ActivatedRoute, private store: Store) {}
constructor(
private route: ActivatedRoute,
private store: Store,
private subscription: SubscriptionService,
) {}
ngOnInit() {
this.defaultComponent = this.route.snapshot.data.replaceableComponent.defaultComponent;
this.componentKey = (this.route.snapshot.data
.replaceableComponent as ReplaceableComponents.RouteData).key;
this.store
const component$ = this.store
.select(ReplaceableComponentsState.getComponent(this.componentKey))
.pipe(takeUntilDestroy(this), distinctUntilChanged())
.subscribe((res = {} as ReplaceableComponents.ReplaceableComponent) => {
.pipe(distinctUntilChanged());
this.subscription.addOne(
component$,
(res = {} as ReplaceableComponents.ReplaceableComponent) => {
this.externalComponent = res.component;
});
},
);
}
ngOnDestroy() {}
}

29
npm/ng-packs/packages/core/src/lib/directives/debounce.directive.ts

@ -1,34 +1,25 @@
import {
Directive,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
} from '@angular/core';
import { takeUntilDestroy } from '../utils/rxjs-utils';
import { Directive, ElementRef, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { fromEvent } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { SubscriptionService } from '../services/subscription.service';
@Directive({
// tslint:disable-next-line: directive-selector
selector: '[input.debounce]',
providers: [SubscriptionService],
})
export class InputEventDebounceDirective implements OnInit, OnDestroy {
export class InputEventDebounceDirective implements OnInit {
@Input() debounce = 300;
@Output('input.debounce') readonly debounceEvent = new EventEmitter<Event>();
constructor(private el: ElementRef) {}
constructor(private el: ElementRef, private subscription: SubscriptionService) {}
ngOnInit(): void {
fromEvent(this.el.nativeElement, 'input')
.pipe(debounceTime(this.debounce), takeUntilDestroy(this))
.subscribe((event: Event) => {
this.debounceEvent.emit(event);
});
}
const input$ = fromEvent(this.el.nativeElement, 'input').pipe(debounceTime(this.debounce));
ngOnDestroy(): void {}
this.subscription.addOne(input$, (event: Event) => {
this.debounceEvent.emit(event);
});
}
}

36
npm/ng-packs/packages/core/src/lib/directives/form-submit.directive.ts

@ -4,7 +4,6 @@ import {
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
Self,
@ -12,15 +11,16 @@ import {
import { FormControl, FormGroup, FormGroupDirective } from '@angular/forms';
import { fromEvent } from 'rxjs';
import { debounceTime, filter } from 'rxjs/operators';
import { takeUntilDestroy } from '../utils';
import { SubscriptionService } from '../services/subscription.service';
type Controls = { [key: string]: FormControl } | FormGroup[];
@Directive({
// tslint:disable-next-line: directive-selector
selector: 'form[ngSubmit][formGroup]',
providers: [SubscriptionService],
})
export class FormSubmitDirective implements OnInit, OnDestroy {
export class FormSubmitDirective implements OnInit {
@Input()
debounce = 200;
@ -35,30 +35,30 @@ export class FormSubmitDirective implements OnInit, OnDestroy {
@Self() private formGroupDirective: FormGroupDirective,
private host: ElementRef<HTMLFormElement>,
private cdRef: ChangeDetectorRef,
private subscription: SubscriptionService,
) {}
ngOnInit() {
this.formGroupDirective.ngSubmit.pipe(takeUntilDestroy(this)).subscribe(() => {
this.subscription.addOne(this.formGroupDirective.ngSubmit, () => {
this.markAsDirty();
this.executedNgSubmit = true;
});
fromEvent(this.host.nativeElement as HTMLElement, 'keyup')
.pipe(
debounceTime(this.debounce),
filter((key: KeyboardEvent) => key && key.key === 'Enter'),
takeUntilDestroy(this),
)
.subscribe(() => {
if (!this.executedNgSubmit) {
this.host.nativeElement.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
const keyup$ = fromEvent(this.host.nativeElement as HTMLElement, 'keyup').pipe(
debounceTime(this.debounce),
filter((key: KeyboardEvent) => key && key.key === 'Enter'),
);
this.executedNgSubmit = false;
});
}
this.subscription.addOne(keyup$, () => {
if (!this.executedNgSubmit) {
this.host.nativeElement.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
);
}
ngOnDestroy(): void {}
this.executedNgSubmit = false;
});
}
markAsDirty() {
const { form } = this.formGroupDirective;

21
npm/ng-packs/packages/core/src/lib/directives/permission.directive.ts

@ -2,19 +2,18 @@ import {
Directive,
ElementRef,
Input,
OnChanges,
OnDestroy,
OnInit,
Renderer2,
ViewContainerRef,
TemplateRef,
Optional,
Renderer2,
SimpleChanges,
OnChanges,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { Store } from '@ngxs/store';
import { ConfigState } from '../states';
import { takeUntilDestroy } from '../utils';
import { Subscription } from 'rxjs';
import { ConfigState } from '../states';
@Directive({
selector: '[abpPermission]',
@ -39,7 +38,6 @@ export class PermissionDirective implements OnInit, OnDestroy, OnChanges {
this.subscription = this.store
.select(ConfigState.getGrantedPolicy(this.condition))
.pipe(takeUntilDestroy(this))
.subscribe(isGranted => {
if (this.templateRef && isGranted) {
this.vcRef.clear();
@ -47,7 +45,10 @@ export class PermissionDirective implements OnInit, OnDestroy, OnChanges {
} else if (this.templateRef && !isGranted) {
this.vcRef.clear();
} else if (!isGranted && !this.templateRef) {
this.renderer.removeChild((this.elRef.nativeElement as HTMLElement).parentElement, this.elRef.nativeElement);
this.renderer.removeChild(
(this.elRef.nativeElement as HTMLElement).parentElement,
this.elRef.nativeElement,
);
}
});
}
@ -58,7 +59,9 @@ export class PermissionDirective implements OnInit, OnDestroy, OnChanges {
}
}
ngOnDestroy(): void {}
ngOnDestroy(): void {
if (this.subscription) this.subscription.unsubscribe();
}
ngOnChanges({ condition }: SimpleChanges) {
if ((condition || { currentValue: null }).currentValue) {

27
npm/ng-packs/packages/core/src/lib/directives/replaceable-template.directive.ts

@ -4,7 +4,6 @@ import {
Injector,
Input,
OnChanges,
OnDestroy,
OnInit,
SimpleChanges,
TemplateRef,
@ -12,17 +11,17 @@ import {
ViewContainerRef,
} from '@angular/core';
import { Store } from '@ngxs/store';
import compare from 'just-compare';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import snq from 'snq';
import { ABP } from '../models/common';
import { ReplaceableComponents } from '../models/replaceable-components';
import { SubscriptionService } from '../services/subscription.service';
import { ReplaceableComponentsState } from '../states/replaceable-components.state';
import { takeUntilDestroy } from '../utils/rxjs-utils';
import compare from 'just-compare';
import snq from 'snq';
@Directive({ selector: '[abpReplaceableTemplate]' })
export class ReplaceableTemplateDirective implements OnInit, OnDestroy, OnChanges {
@Directive({ selector: '[abpReplaceableTemplate]', providers: [SubscriptionService] })
export class ReplaceableTemplateDirective implements OnInit, OnChanges {
@Input('abpReplaceableTemplate')
data: ReplaceableComponents.ReplaceableTemplateDirectiveInput<any, any>;
@ -47,6 +46,7 @@ export class ReplaceableTemplateDirective implements OnInit, OnDestroy, OnChange
private cfRes: ComponentFactoryResolver,
private vcRef: ViewContainerRef,
private store: Store,
private subscription: SubscriptionService,
) {
this.context = {
initTemplate: ref => {
@ -58,16 +58,18 @@ export class ReplaceableTemplateDirective implements OnInit, OnDestroy, OnChange
}
ngOnInit() {
this.store
const component$ = this.store
.select(ReplaceableComponentsState.getComponent(this.data.componentKey))
.pipe(
filter(
(res = {} as ReplaceableComponents.ReplaceableComponent) =>
!this.initialized || !compare(res.component, this.externalComponent),
),
takeUntilDestroy(this),
)
.subscribe((res = {} as ReplaceableComponents.ReplaceableComponent) => {
);
this.subscription.addOne(
component$,
(res = {} as ReplaceableComponents.ReplaceableComponent) => {
this.vcRef.clear();
this.externalComponent = res.component;
if (this.defaultComponentRef) {
@ -90,7 +92,8 @@ export class ReplaceableTemplateDirective implements OnInit, OnDestroy, OnChange
}
this.initialized = true;
});
},
);
}
ngOnChanges(changes: SimpleChanges) {
@ -99,8 +102,6 @@ export class ReplaceableTemplateDirective implements OnInit, OnDestroy, OnChange
}
}
ngOnDestroy() {}
setDefaultComponentInputs() {
if (!this.defaultComponentRef || (!this.data.inputs && !this.data.outputs)) return;

21
npm/ng-packs/packages/core/src/lib/directives/stop-propagation.directive.ts

@ -1,24 +1,21 @@
import { Directive, ElementRef, EventEmitter, OnInit, Output, OnDestroy } from '@angular/core';
import { Directive, ElementRef, EventEmitter, OnInit, Output } from '@angular/core';
import { fromEvent } from 'rxjs';
import { takeUntilDestroy } from '../utils/rxjs-utils';
import { SubscriptionService } from '../services/subscription.service';
@Directive({
// tslint:disable-next-line: directive-selector
selector: '[click.stop]',
providers: [SubscriptionService],
})
export class StopPropagationDirective implements OnInit, OnDestroy {
export class StopPropagationDirective implements OnInit {
@Output('click.stop') readonly stopPropEvent = new EventEmitter<MouseEvent>();
constructor(private el: ElementRef) {}
constructor(private el: ElementRef, private subscription: SubscriptionService) {}
ngOnInit(): void {
fromEvent(this.el.nativeElement, 'click')
.pipe(takeUntilDestroy(this))
.subscribe((event: MouseEvent) => {
event.stopPropagation();
this.stopPropEvent.emit(event);
});
this.subscription.addOne(fromEvent(this.el.nativeElement, 'click'), (event: MouseEvent) => {
event.stopPropagation();
this.stopPropEvent.emit(event);
});
}
ngOnDestroy(): void {}
}

1
npm/ng-packs/packages/core/src/lib/services/index.ts

@ -11,4 +11,5 @@ export * from './profile.service';
export * from './rest.service';
export * from './routes.service';
export * from './session-state.service';
export * from './subscription.service';
export * from './track-by.service';

21
npm/ng-packs/packages/core/src/lib/services/list.service.ts

@ -1,10 +1,17 @@
import { Inject, Injectable, OnDestroy, Optional } from '@angular/core';
import { BehaviorSubject, Observable, of, ReplaySubject } from 'rxjs';
import { catchError, debounceTime, filter, shareReplay, switchMap, tap } from 'rxjs/operators';
import { BehaviorSubject, Observable, of, ReplaySubject, Subject } from 'rxjs';
import {
catchError,
debounceTime,
filter,
shareReplay,
switchMap,
takeUntil,
tap,
} from 'rxjs/operators';
import { ABP } from '../models/common';
import { PagedResultDto } from '../models/dtos';
import { LIST_QUERY_DEBOUNCE_TIME } from '../tokens/list.token';
import { takeUntilDestroy } from '../utils/rxjs-utils';
@Injectable()
export class ListService<QueryParamsType = ABP.PageQueryParams> implements OnDestroy {
@ -65,6 +72,8 @@ export class ListService<QueryParamsType = ABP.PageQueryParams> implements OnDes
private _isLoading$ = new BehaviorSubject(false);
private destroy$ = new Subject();
get isLoading$(): Observable<boolean> {
return this._isLoading$.asObservable();
}
@ -92,11 +101,13 @@ export class ListService<QueryParamsType = ABP.PageQueryParams> implements OnDes
filter(Boolean),
tap(() => this._isLoading$.next(false)),
shareReplay({ bufferSize: 1, refCount: true }),
takeUntilDestroy(this),
takeUntil(this.destroy$),
);
}
ngOnDestroy() {}
ngOnDestroy() {
this.destroy$.next();
}
}
export type QueryStreamCreatorCallback<T, QueryParamsType = ABP.PageQueryParams> = (

12
npm/ng-packs/packages/core/src/lib/services/routes.service.ts

@ -1,10 +1,9 @@
import { Injectable, OnDestroy } from '@angular/core';
import { Actions, ofActionSuccessful, Store } from '@ngxs/store';
import { BehaviorSubject, Observable } from 'rxjs';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { GetAppConfiguration } from '../actions/config.actions';
import { ABP } from '../models/common';
import { ConfigState } from '../states/config.state';
import { takeUntilDestroy } from '../utils/rxjs-utils';
import { pushValueTo } from '../utils/array-utils';
import { BaseTreeNode, createTreeFromList, TreeNode } from '../utils/tree-utils';
@ -129,6 +128,7 @@ export abstract class AbstractTreeService<T extends object> {
@Injectable()
export abstract class AbstractNavTreeService<T extends ABP.Nav> extends AbstractTreeService<T>
implements OnDestroy {
private subscription: Subscription;
readonly id = 'name';
readonly parentId = 'parentName';
readonly hide = (item: T) => item.invisible || !this.isGranted(item);
@ -142,8 +142,8 @@ export abstract class AbstractNavTreeService<T extends ABP.Nav> extends Abstract
constructor(protected actions: Actions, protected store: Store) {
super();
this.actions
.pipe(takeUntilDestroy(this), ofActionSuccessful(GetAppConfiguration))
this.subscription = this.actions
.pipe(ofActionSuccessful(GetAppConfiguration))
.subscribe(() => this.refresh());
}
@ -162,7 +162,9 @@ export abstract class AbstractNavTreeService<T extends ABP.Nav> extends Abstract
}
/* istanbul ignore next */
ngOnDestroy() {}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}
@Injectable({ providedIn: 'root' })

54
npm/ng-packs/packages/core/src/lib/services/subscription.service.ts

@ -0,0 +1,54 @@
import { Injectable } from '@angular/core';
import type { OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import type { Observable, PartialObserver } from 'rxjs';
@Injectable()
export class SubscriptionService implements OnDestroy {
private subscription = new Subscription();
get isClosed() {
return this.subscription.closed;
}
addOne<T extends unknown>(
source$: Observable<T>,
next?: (value: T) => void,
error?: (error: any) => void,
): Subscription;
addOne<T extends unknown>(source$: Observable<T>, observer?: PartialObserver<T>): Subscription;
addOne<T extends unknown>(
source$: Observable<T>,
nextOrObserver?: PartialObserver<T> | Next<T>,
error?: (error: any) => void,
): Subscription {
const subscription = source$.subscribe(nextOrObserver as Next<T>, error);
this.subscription.add(subscription);
return subscription;
}
closeAll() {
this.subscription.unsubscribe();
}
closeOne(subscription: Subscription | undefined | null) {
this.removeOne(subscription);
subscription.unsubscribe();
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
removeOne(subscription: Subscription | undefined | null) {
if (!subscription) return;
this.subscription.remove(subscription);
}
reset() {
this.subscription.unsubscribe();
this.subscription = new Subscription();
}
}
type Next<T> = (value: T) => void;

109
npm/ng-packs/packages/core/src/lib/tests/subscription.service.spec.ts

@ -0,0 +1,109 @@
import { of, Subscription, timer } from 'rxjs';
import { SubscriptionService } from '../services/subscription.service';
describe('SubscriptionService', () => {
let service: SubscriptionService;
beforeEach(() => {
service = new SubscriptionService();
});
afterEach(() => {
service['subscription'].unsubscribe();
});
describe('#addOne', () => {
it('should subscribe to given observable with next and error functions and return the Subscription instance', () => {
const next = jest.fn();
const error = jest.fn();
const subscription = service.addOne(of(null), next, error);
expect(subscription).toBeInstanceOf(Subscription);
expect(next).toHaveBeenCalledWith(null);
expect(next).toHaveBeenCalledTimes(1);
expect(error).not.toHaveBeenCalled();
});
it('should subscribe to given observable with observer and return the Subscription instance', () => {
const observer = { next: jest.fn(), complete: jest.fn() };
const subscription = service.addOne(of(null), observer);
expect(subscription).toBeInstanceOf(Subscription);
expect(observer.next).toHaveBeenCalledWith(null);
expect(observer.next).toHaveBeenCalledTimes(1);
expect(observer.complete).toHaveBeenCalledTimes(1);
});
});
describe('#isClosed', () => {
it('should return true if subscriptions are alive and false if not', () => {
service.addOne(timer(1000), () => {});
expect(service.isClosed).toBe(false);
service['subscription'].unsubscribe();
expect(service.isClosed).toBe(true);
});
});
describe('#closeAll', () => {
it('should close all subscriptions and the parent subscription', () => {
const sub1 = service.addOne(timer(1000), () => {});
const sub2 = service.addOne(timer(1000), () => {});
expect(sub1.closed).toBe(false);
expect(sub2.closed).toBe(false);
expect(service.isClosed).toBe(false);
service.closeAll();
expect(sub1.closed).toBe(true);
expect(sub2.closed).toBe(true);
expect(service.isClosed).toBe(true);
});
});
describe('#reset', () => {
it('should close all subscriptions but not the parent subscription', () => {
const sub1 = service.addOne(timer(1000), () => {});
const sub2 = service.addOne(timer(1000), () => {});
expect(sub1.closed).toBe(false);
expect(sub2.closed).toBe(false);
expect(service.isClosed).toBe(false);
service.reset();
expect(sub1.closed).toBe(true);
expect(sub2.closed).toBe(true);
expect(service.isClosed).toBe(false);
});
});
describe('#closeOne', () => {
it('should unsubscribe from given subscription only', () => {
const sub1 = service.addOne(timer(1000), () => {});
const sub2 = service.addOne(timer(1000), () => {});
expect(service.isClosed).toBe(false);
service.closeOne(sub1);
expect(sub1.closed).toBe(true);
expect(service.isClosed).toBe(false);
service.closeOne(sub2);
expect(sub2.closed).toBe(true);
expect(service.isClosed).toBe(false);
});
});
describe('#removeOne', () => {
it('should remove given subscription from list of subscriptions', () => {
const sub1 = service.addOne(timer(1000), () => {});
const sub2 = service.addOne(timer(1000), () => {});
expect(service.isClosed).toBe(false);
service.removeOne(sub1);
expect(sub1.closed).toBe(false);
expect(service.isClosed).toBe(false);
sub1.unsubscribe();
});
});
});

8
npm/ng-packs/packages/core/src/lib/utils/rxjs-utils.ts

@ -1,3 +1,4 @@
// tslint:disable: max-line-length
import { Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -5,13 +6,16 @@ function isFunction(value) {
return typeof value === 'function';
}
/**
* @deprecated no longer working, please use SubscriptionService (https://docs.abp.io/en/abp/latest/UI/Angular/Subscription-Service) instead.
*/
export const takeUntilDestroy = (componentInstance, destroyMethodName = 'ngOnDestroy') => <T>(
source: Observable<T>
source: Observable<T>,
) => {
const originalDestroy = componentInstance[destroyMethodName];
if (isFunction(originalDestroy) === false) {
throw new Error(
`${componentInstance.constructor.name} is using untilDestroyed but doesn't implement ${destroyMethodName}`
`${componentInstance.constructor.name} is using untilDestroyed but doesn't implement ${destroyMethodName}`,
);
}
if (!componentInstance['__takeUntilDestroy']) {

12
npm/ng-packs/packages/theme-basic/src/lib/components/application-layout/application-layout.component.ts

@ -1,4 +1,4 @@
import { eLayoutType, takeUntilDestroy } from '@abp/ng.core';
import { eLayoutType, SubscriptionService } from '@abp/ng.core';
import { collapseWithMargin, slideFromBottom } from '@abp/ng.theme.shared';
import { AfterViewInit, Component, OnDestroy } from '@angular/core';
import { fromEvent } from 'rxjs';
@ -9,6 +9,7 @@ import { eThemeBasicComponents } from '../../enums/components';
selector: 'abp-layout-application',
templateUrl: './application-layout.component.html',
animations: [slideFromBottom, collapseWithMargin],
providers: [SubscriptionService],
})
export class ApplicationLayoutComponent implements AfterViewInit, OnDestroy {
// required for dynamic component
@ -24,6 +25,8 @@ export class ApplicationLayoutComponent implements AfterViewInit, OnDestroy {
navItemsComponentKey = eThemeBasicComponents.NavItems;
constructor(private subscription: SubscriptionService) {}
private checkWindowWidth() {
setTimeout(() => {
if (window.innerWidth < 992) {
@ -43,11 +46,8 @@ export class ApplicationLayoutComponent implements AfterViewInit, OnDestroy {
ngAfterViewInit() {
this.checkWindowWidth();
fromEvent(window, 'resize')
.pipe(takeUntilDestroy(this), debounceTime(150))
.subscribe(() => {
this.checkWindowWidth();
});
const resize$ = fromEvent(window, 'resize').pipe(debounceTime(150));
this.subscription.addOne(resize$, () => this.checkWindowWidth());
}
ngOnDestroy() {}

28
npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts

@ -1,11 +1,5 @@
import { ABP, getRoutePath, RoutesService, takeUntilDestroy, TreeNode } from '@abp/ng.core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
} from '@angular/core';
import { ABP, getRoutePath, RoutesService, TreeNode, SubscriptionService } from '@abp/ng.core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter, map, startWith } from 'rxjs/operators';
import { eThemeSharedRouteNames } from '../../enums';
@ -14,28 +8,27 @@ import { eThemeSharedRouteNames } from '../../enums';
selector: 'abp-breadcrumb',
templateUrl: './breadcrumb.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [SubscriptionService],
})
export class BreadcrumbComponent implements OnDestroy, OnInit {
export class BreadcrumbComponent implements OnInit {
segments: Partial<ABP.Route>[] = [];
constructor(
public readonly cdRef: ChangeDetectorRef,
private router: Router,
private routes: RoutesService,
private subscription: SubscriptionService,
) {}
ngOnDestroy() {}
ngOnInit(): void {
this.router.events
.pipe(
takeUntilDestroy(this),
this.subscription.addOne(
this.router.events.pipe(
filter<NavigationEnd>(event => event instanceof NavigationEnd),
// tslint:disable-next-line:deprecation
startWith(null),
map(() => this.routes.search({ path: getRoutePath(this.router) })),
)
.subscribe(route => {
),
route => {
this.segments = [];
if (route) {
let node = { parent: route } as TreeNode<ABP.Route>;
@ -48,7 +41,8 @@ export class BreadcrumbComponent implements OnDestroy, OnInit {
this.cdRef.detectChanges();
}
});
},
);
}
}

19
npm/ng-packs/packages/theme-shared/src/lib/components/http-error-wrapper/http-error-wrapper.component.ts

@ -1,4 +1,4 @@
import { Config, takeUntilDestroy } from '@abp/ng.core';
import { Config, SubscriptionService } from '@abp/ng.core';
import {
AfterViewInit,
ApplicationRef,
@ -20,6 +20,7 @@ import snq from 'snq';
selector: 'abp-http-error-wrapper',
templateUrl: './http-error-wrapper.component.html',
styleUrls: ['http-error-wrapper.component.scss'],
providers: [SubscriptionService],
})
export class HttpErrorWrapperComponent implements AfterViewInit, OnDestroy, OnInit {
appRef: ApplicationRef;
@ -51,6 +52,8 @@ export class HttpErrorWrapperComponent implements AfterViewInit, OnDestroy, OnIn
return this.status ? `[${this.status}]` : '';
}
constructor(private subscription: SubscriptionService) {}
ngOnInit() {
this.backgroundColor =
snq(() => window.getComputedStyle(document.body).getPropertyValue('background-color')) ||
@ -71,15 +74,11 @@ export class HttpErrorWrapperComponent implements AfterViewInit, OnDestroy, OnIn
customComponentRef.changeDetectorRef.detectChanges();
}
fromEvent(document, 'keyup')
.pipe(
takeUntilDestroy(this),
debounceTime(150),
filter((key: KeyboardEvent) => key && key.key === 'Escape'),
)
.subscribe(() => {
this.destroy();
});
const keyup$ = fromEvent(document, 'keyup').pipe(
debounceTime(150),
filter((key: KeyboardEvent) => key && key.key === 'Escape'),
);
this.subscription.addOne(keyup$, () => this.destroy());
}
ngOnDestroy() {}

36
npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.ts

@ -1,7 +1,6 @@
import { StartLoader, StopLoader } from '@abp/ng.core';
import { StartLoader, StopLoader, SubscriptionService } from '@abp/ng.core';
import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { NavigationEnd, NavigationError, NavigationStart, Router } from '@angular/router';
import { takeUntilDestroy } from '@ngx-validate/core';
import { Actions, ofActionSuccessful } from '@ngxs/store';
import { Subscription, timer } from 'rxjs';
import { filter } from 'rxjs/operators';
@ -22,6 +21,7 @@ import { filter } from 'rxjs/operators';
</div>
`,
styleUrls: ['./loader-bar.component.scss'],
providers: [SubscriptionService],
})
export class LoaderBarComponent implements OnDestroy, OnInit {
protected _isLoading: boolean;
@ -77,36 +77,38 @@ export class LoaderBarComponent implements OnDestroy, OnInit {
return `0 0 10px rgba(${this.color}, 0.5)`;
}
constructor(private actions: Actions, private router: Router, private cdRef: ChangeDetectorRef) {}
constructor(
private actions: Actions,
private router: Router,
private cdRef: ChangeDetectorRef,
private subscription: SubscriptionService,
) {}
private subscribeToLoadActions() {
this.actions
.pipe(
ofActionSuccessful(StartLoader, StopLoader),
filter(this.filter),
takeUntilDestroy(this),
)
.subscribe(action => {
this.subscription.addOne(
this.actions.pipe(ofActionSuccessful(StartLoader, StopLoader), filter(this.filter)),
action => {
if (action instanceof StartLoader) this.startLoading();
else this.stopLoading();
});
},
);
}
private subscribeToRouterEvents() {
this.router.events
.pipe(
this.subscription.addOne(
this.router.events.pipe(
filter(
event =>
event instanceof NavigationStart ||
event instanceof NavigationEnd ||
event instanceof NavigationError,
),
takeUntilDestroy(this),
)
.subscribe(event => {
),
event => {
if (event instanceof NavigationStart) this.startLoading();
else this.stopLoading();
});
},
);
}
ngOnInit() {

17
npm/ng-packs/packages/theme-shared/src/lib/components/modal/modal.component.ts

@ -1,4 +1,4 @@
import { takeUntilDestroy } from '@abp/ng.core';
import { SubscriptionService } from '@abp/ng.core';
import {
Component,
ContentChild,
@ -27,7 +27,7 @@ export type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
templateUrl: './modal.component.html',
animations: [fadeAnimation],
styleUrls: ['./modal.component.scss'],
providers: [ModalService],
providers: [ModalService, SubscriptionService],
})
export class ModalComponent implements OnDestroy {
@Input()
@ -60,11 +60,11 @@ export class ModalComponent implements OnDestroy {
@ContentChild(ButtonComponent, { static: false, read: ButtonComponent })
abpSubmit: ButtonComponent;
@ContentChild('abpHeader', {static: false}) abpHeader: TemplateRef<any>;
@ContentChild('abpHeader', { static: false }) abpHeader: TemplateRef<any>;
@ContentChild('abpBody', {static: false}) abpBody: TemplateRef<any>;
@ContentChild('abpBody', { static: false }) abpBody: TemplateRef<any>;
@ContentChild('abpFooter', {static: false}) abpFooter: TemplateRef<any>;
@ContentChild('abpFooter', { static: false }) abpFooter: TemplateRef<any>;
@ContentChild('abpClose', { static: false, read: ElementRef })
abpClose: ElementRef<any>;
@ -103,14 +103,15 @@ export class ModalComponent implements OnDestroy {
private renderer: Renderer2,
private confirmationService: ConfirmationService,
private modalService: ModalService,
private subscription: SubscriptionService,
) {
this.initToggleStream();
}
private initToggleStream() {
this.toggle$
.pipe(takeUntilDestroy(this), debounceTime(0), distinctUntilChanged())
.subscribe(value => this.toggle(value));
this.subscription.addOne(this.toggle$.pipe(debounceTime(0), distinctUntilChanged()), value =>
this.toggle(value),
);
}
private toggle(value: boolean) {

11
npm/ng-packs/packages/theme-shared/src/lib/tests/loader-bar.component.spec.ts

@ -1,9 +1,15 @@
import { Router, RouteReuseStrategy, NavigationStart, NavigationEnd, NavigationError } from '@angular/router';
import {
Router,
RouteReuseStrategy,
NavigationStart,
NavigationEnd,
NavigationError,
} from '@angular/router';
import { createHostFactory, SpectatorHost, SpyObject } from '@ngneat/spectator/jest';
import { Actions, NgxsModule, Store } from '@ngxs/store';
import { Subject, Subscription, Observable, Subscriber, timer } from 'rxjs';
import { LoaderBarComponent } from '../components/loader-bar/loader-bar.component';
import { StartLoader, StopLoader } from '@abp/ng.core';
import { StartLoader, StopLoader, SubscriptionService } from '@abp/ng.core';
import { HttpRequest } from '@angular/common/http';
describe('LoaderBarComponent', () => {
@ -16,6 +22,7 @@ describe('LoaderBarComponent', () => {
mocks: [Router],
imports: [NgxsModule.forRoot()],
detectChanges: false,
providers: [SubscriptionService],
});
beforeEach(() => {

Loading…
Cancel
Save