diff --git a/README.md b/README.md index 14b044f8b1..7dd11a5362 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # ABP -[![Build Status](http://vjenkins.dynu.net:5480/job/abp/badge/icon)](http://ci.volosoft.com:5480/blue/organizations/jenkins/abp/activity) +[![Build & Test](https://github.com/abpframework/abp/workflows/Main/badge.svg)](https://github.com/abpframework/abp/actions?query=workflow%3AMain) [![NuGet](https://img.shields.io/nuget/v/Volo.Abp.Core.svg?style=flat-square)](https://www.nuget.org/packages/Volo.Abp.Core) -[![NuGet Download](https://img.shields.io/nuget/dt/Volo.Abp.Core.svg?style=flat-square)](https://www.nuget.org/packages/Volo.Abp.Core) [![MyGet (with prereleases)](https://img.shields.io/myget/abp-nightly/vpre/Volo.Abp.svg?style=flat-square)](https://docs.abp.io/en/abp/latest/Nightly-Builds) +[![NuGet Download](https://img.shields.io/nuget/dt/Volo.Abp.Core.svg?style=flat-square)](https://www.nuget.org/packages/Volo.Abp.Core) This project is the next generation of the [ASP.NET Boilerplate](https://aspnetboilerplate.com/) web application framework. See [the announcement](https://blog.abp.io/abp/Abp-vNext-Announcement). diff --git a/docs/en/UI/Angular/List-Service.md b/docs/en/UI/Angular/List-Service.md new file mode 100644 index 0000000000..b3c8571866 --- /dev/null +++ b/docs/en/UI/Angular/List-Service.md @@ -0,0 +1,165 @@ +# Querying Lists Easily with ListService + +`ListService` is a utility service to provide an easy pagination, sorting, and search implementation. + + + +## Getting Started + +`ListService` is **not provided in root**. The reason is, this way, it will clear any subscriptions on component destroy. You may use the optional `LIST_QUERY_DEBOUNCE_TIME` token to adjust the debounce behavior. + +```js +import { ListService } from '@abp/ng.core'; +import { BookDto } from '../models'; +import { BookService } from '../services'; + +@Component({ + /* class metadata here */ + providers: [ + // [Required] + ListService, + + // [Optional] + // Provide this token if you want a different debounce time. + // Default is 300. Cannot be 0. Any value below 100 is not recommended. + { provide: LIST_QUERY_DEBOUNCE_TIME, useValue: 500 }, + ], + template: ` + + `, +}) +class BookComponent { + items: BookDto[] = []; + count = 0; + + constructor( + public readonly list: ListService, + private bookService: BookService, + ) {} + + ngOnInit() { + // A function that gets query and returns an observable + const bookStreamCreator = query => this.bookService.getList(query); + + this.list.hookToQuery(bookStreamCreator).subscribe( + response => { + this.items = response.items; + this.count = response.count; + // If you use OnPush change detection strategy, + // call detectChanges method of ChangeDetectorRef here. + } + ); // Subscription is auto-cleared on destroy. + } +} +``` + +> Noticed `list` is `public` and `readonly`? That is because we will use `ListService` properties directly in the component's template. That may be considered as an anti-pattern, but it is much quicker to implement. You can always use public component properties instead. + +Place `ListService` properties into the template like this: + +```html + + + + + + + {%{{{ '::Name' | abpLocalization }}}%} + + + + + + + + {%{{{ data.name }}}%} + + +``` + +## Usage with Observables + +You may use observables in combination with [AsyncPipe](https://angular.io/guide/observables-in-angular#async-pipe) of Angular instead. Here are some possibilities: + +```ts + book$ = this.list.hookToQuery(query => this.bookService.getListByInput(query)); +``` + +```html + + + + + + +``` + +...or... + + +```ts + @Select(BookState.getBooks) + books$: Observable; + + @Select(BookState.getBookCount) + bookCount$: Observable; + + ngOnInit() { + this.list.hookToQuery((query) => this.store.dispatch(new GetBooks(query))).subscribe(); + } +``` + +```html + + + + +``` + +## How to Refresh Table on Create/Update/Delete + +`ListService` exposes a `get` method to trigger a request with the current query. So, basically, whenever a create, update, or delete action resolves, you can call `this.list.get();` and it will call hooked stream creator again. + +```ts +this.store.dispatch(new DeleteBook(id)).subscribe(this.list.get); +``` + +...or... + +```ts +this.bookService.createByInput(form.value) + .subscribe(() => { + this.list.get(); + + // Other subscription logic here + }) +``` + +## How to Implement Server-Side Search in a Table + +`ListService` exposes a `filter` property that will trigger a request with the current query and the given search string. All you need to do is to bind it to an input element with two-way binding. + +```html + + + +``` diff --git a/docs/en/UI/Angular/Track-By-Service.md b/docs/en/UI/Angular/Track-By-Service.md index e6b560a2eb..447cc4505a 100644 --- a/docs/en/UI/Angular/Track-By-Service.md +++ b/docs/en/UI/Angular/Track-By-Service.md @@ -111,3 +111,9 @@ class DemoComponent { trackByTenantAccountId = trackByDeep('tenant', 'account', 'id'); } ``` + + + +## What's Next? + +- [ListService](./List-Service.md) diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 5f6203054e..47789023c4 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -383,6 +383,10 @@ { "text": "TrackByService", "path": "UI/Angular/Track-By-Service.md" + }, + { + "text": "ListService", + "path": "UI/Angular/List-Service.md" } ] }, diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ExtensionPropertyAttributeDto.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ExtensionPropertyAttributeDto.cs index c1c19350c2..12941a3b89 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ExtensionPropertyAttributeDto.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ExtensionPropertyAttributeDto.cs @@ -1,36 +1,13 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using Volo.Abp.Reflection; namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ObjectExtending { [Serializable] public class ExtensionPropertyAttributeDto { - public string Type { get; set; } public string TypeSimple { get; set; } - public Dictionary Configuration { get; set; } - public static ExtensionPropertyAttributeDto Create(Attribute attribute) - { - var attributeType = attribute.GetType(); - var dto = new ExtensionPropertyAttributeDto - { - Type = TypeHelper.GetFullNameHandlingNullableAndGenerics(attributeType), - TypeSimple = TypeHelper.GetSimplifiedName(attributeType), - Configuration = new Dictionary() - }; - - if (attribute is StringLengthAttribute stringLengthAttribute) - { - dto.Configuration["MaximumLength"] = stringLengthAttribute.MaximumLength; - dto.Configuration["MinimumLength"] = stringLengthAttribute.MinimumLength; - } - - //TODO: Others! - - return dto; - } + public Dictionary Config { get; set; } } } \ No newline at end of file diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/JsTree/JQueryFormScriptContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/JsTree/JQueryFormScriptContributor.cs new file mode 100644 index 0000000000..ca131c8aac --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/JsTree/JQueryFormScriptContributor.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Volo.Abp.AspNetCore.Mvc.UI.Bundling; +using Volo.Abp.AspNetCore.Mvc.UI.Packages.JQuery; +using Volo.Abp.Modularity; + +namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.JsTree +{ + [DependsOn(typeof(JQueryScriptContributor))] + public class JsTreeScriptContributor : BundleContributor + { + public override void ConfigureBundle(BundleConfigurationContext context) + { + context.Files.AddIfNotContains("/libs/jstree/jstree.min.js"); + } + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/JsTree/JsTreeOptions.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/JsTree/JsTreeOptions.cs new file mode 100644 index 0000000000..d1dbe812bf --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/JsTree/JsTreeOptions.cs @@ -0,0 +1,13 @@ +namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.JsTree +{ + public class JsTreeOptions + { + /// + /// Path of the style file for the JsTree library. + /// Setting to null ignores the style file. + /// + /// Default value: "/libs/jstree/themes/default/style.min.css". + /// + public string StylePath { get; set; } = "/libs/jstree/themes/default/style.min.css"; + } +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/JsTree/JsTreeStyleContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/JsTree/JsTreeStyleContributor.cs new file mode 100644 index 0000000000..5944310c2c --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/JsTree/JsTreeStyleContributor.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Volo.Abp.AspNetCore.Mvc.UI.Bundling; + +namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.JsTree +{ + public class JsTreeStyleContributor : BundleContributor + { + public override void ConfigureBundle(BundleConfigurationContext context) + { + var options = context + .ServiceProvider + .GetRequiredService>() + .Value; + + if (options.StylePath.IsNullOrEmpty()) + { + return; + } + + context.Files.AddIfNotContains(options.StylePath); + } + } +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/CachedObjectExtensionsDtoService.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/CachedObjectExtensionsDtoService.cs similarity index 89% rename from framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/CachedObjectExtensionsDtoService.cs rename to framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/CachedObjectExtensionsDtoService.cs index f87fef31aa..6e72a6863e 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/CachedObjectExtensionsDtoService.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/CachedObjectExtensionsDtoService.cs @@ -10,23 +10,29 @@ namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ObjectExtending { public class CachedObjectExtensionsDtoService : ICachedObjectExtensionsDtoService, ISingletonDependency { - private volatile ObjectExtensionsDto _cachedValue; - private readonly object _syncLock = new object(); + protected IExtensionPropertyAttributeDtoFactory ExtensionPropertyAttributeDtoFactory { get; } + protected volatile ObjectExtensionsDto CachedValue; + protected readonly object SyncLock = new object(); + + public CachedObjectExtensionsDtoService(IExtensionPropertyAttributeDtoFactory extensionPropertyAttributeDtoFactory) + { + ExtensionPropertyAttributeDtoFactory = extensionPropertyAttributeDtoFactory; + } public virtual ObjectExtensionsDto Get() { - if (_cachedValue == null) + if (CachedValue == null) { - lock (_syncLock) + lock (SyncLock) { - if (_cachedValue == null) + if (CachedValue == null) { - _cachedValue = GenerateCacheValue(); + CachedValue = GenerateCacheValue(); } } } - return _cachedValue; + return CachedValue; } protected virtual ObjectExtensionsDto GenerateCacheValue() @@ -137,7 +143,7 @@ namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ObjectExtending foreach (var attribute in propertyConfig.Attributes) { extensionPropertyDto.Attributes.Add( - ExtensionPropertyAttributeDto.Create(attribute) + ExtensionPropertyAttributeDtoFactory.Create(attribute) ); } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ExtensionPropertyAttributeDtoFactory.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ExtensionPropertyAttributeDtoFactory.cs new file mode 100644 index 0000000000..18c5f62591 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ExtensionPropertyAttributeDtoFactory.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Reflection; + +namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ObjectExtending +{ + public class ExtensionPropertyAttributeDtoFactory : IExtensionPropertyAttributeDtoFactory, ITransientDependency + { + public virtual ExtensionPropertyAttributeDto Create(Attribute attribute) + { + return new ExtensionPropertyAttributeDto + { + TypeSimple = GetSimplifiedName(attribute), + Config = CreateConfiguration(attribute) + }; + } + + protected virtual string GetSimplifiedName(Attribute attribute) + { + return attribute.GetType().Name.ToCamelCase().RemovePostFix("Attribute"); + } + + protected virtual Dictionary CreateConfiguration(Attribute attribute) + { + var configuration = new Dictionary(); + + AddPropertiesToConfiguration(attribute, configuration); + + return configuration; + } + + protected virtual void AddPropertiesToConfiguration(Attribute attribute, Dictionary configuration) + { + var properties = attribute + .GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public); + + foreach (var property in properties) + { + if (IgnoreProperty(attribute, property)) + { + continue; + } + + var value = GetPropertyValue(attribute, property); + if (value == null) + { + continue; + } + + configuration[property.Name.ToCamelCase()] = value; + } + } + + protected virtual bool IgnoreProperty(Attribute attribute, PropertyInfo property) + { + if (property.DeclaringType == null || + property.DeclaringType.IsIn(typeof(ValidationAttribute), typeof(Attribute), typeof(object))) + { + return true; + } + + if (property.PropertyType == typeof(DisplayFormatAttribute)) + { + return true; + } + + return false; + } + + protected virtual object GetPropertyValue(Attribute attribute, PropertyInfo property) + { + var value = property.GetValue(attribute); + if (value == null) + { + return null; + } + + if (property.PropertyType.IsEnum) + { + return Enum.GetName(property.PropertyType, value); + } + + if (property.PropertyType == typeof(Type)) + { + return TypeHelper.GetSimplifiedName((Type) value); + } + + return value; + } + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ICachedObjectExtensionsDtoService.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ICachedObjectExtensionsDtoService.cs similarity index 100% rename from framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ICachedObjectExtensionsDtoService.cs rename to framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/ICachedObjectExtensionsDtoService.cs diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/IExtensionPropertyAttributeDtoFactory.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/IExtensionPropertyAttributeDtoFactory.cs new file mode 100644 index 0000000000..5e01077604 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ObjectExtending/IExtensionPropertyAttributeDtoFactory.cs @@ -0,0 +1,9 @@ +using System; + +namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ObjectExtending +{ + public interface IExtensionPropertyAttributeDtoFactory + { + ExtensionPropertyAttributeDto Create(Attribute attribute); + } +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpExtraPropertyModelBinder.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpExtraPropertyModelBinder.cs index d3f570b1ce..c94ad08e74 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpExtraPropertyModelBinder.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpExtraPropertyModelBinder.cs @@ -1,7 +1,9 @@ using System; +using System.ComponentModel; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Volo.Abp.ObjectExtending; +using Volo.Abp.Reflection; namespace Volo.Abp.AspNetCore.Mvc.ModelBinding { @@ -59,7 +61,7 @@ namespace Volo.Abp.AspNetCore.Mvc.ModelBinding return value; } - return Convert.ChangeType(value, propertyInfo.Type); + return TypeHelper.ConvertFromString(propertyInfo.Type, value); } } } \ No newline at end of file diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/ObjectExtending/ObjectExtendingPropertyInfoExtensions.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/ObjectExtending/ObjectExtendingPropertyInfoExtensions.cs index 9bb6c903a9..04339eedd3 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/ObjectExtending/ObjectExtendingPropertyInfoExtensions.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/ObjectExtending/ObjectExtendingPropertyInfoExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; @@ -6,6 +7,31 @@ namespace Volo.Abp.ObjectExtending { public static class ObjectExtensionPropertyInfoAspNetCoreMvcExtensions { + private static readonly HashSet NumberTypes = new HashSet { + typeof(int), + typeof(long), + typeof(byte), + typeof(sbyte), + typeof(short), + typeof(ushort), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(float), + typeof(double), + typeof(int?), + typeof(long?), + typeof(byte?), + typeof(sbyte?), + typeof(short?), + typeof(ushort?), + typeof(uint?), + typeof(long?), + typeof(ulong?), + typeof(float?), + typeof(double?), + }; + public static string GetInputType(this ObjectExtensionPropertyInfo propertyInfo) { foreach (var attribute in propertyInfo.Attributes) @@ -79,15 +105,7 @@ namespace Volo.Abp.ObjectExtending return "datetime-local"; } - if (type == typeof(int) || - type == typeof(long) || - type == typeof(byte) || - type == typeof(sbyte) || - type == typeof(short) || - type == typeof(ushort) || - type == typeof(uint) || - type == typeof(long) || - type == typeof(ulong)) + if (NumberTypes.Contains(type)) { return "number"; } diff --git a/framework/src/Volo.Abp.Core/Volo/Abp/Reflection/TypeHelper.cs b/framework/src/Volo.Abp.Core/Volo/Abp/Reflection/TypeHelper.cs index 11f43f852e..63b3538c3a 100644 --- a/framework/src/Volo.Abp.Core/Volo/Abp/Reflection/TypeHelper.cs +++ b/framework/src/Volo.Abp.Core/Volo/Abp/Reflection/TypeHelper.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Reflection; using JetBrains.Annotations; @@ -264,5 +265,17 @@ namespace Volo.Abp.Reflection return type.FullName; } + + public static object ConvertFromString(string value) + { + return ConvertFromString(typeof(TTargetType), value); + } + + public static object ConvertFromString(Type targetType, string value) + { + return TypeDescriptor + .GetConverter(targetType) + .ConvertFromString(value); + } } } diff --git a/framework/test/Volo.Abp.Core.Tests/Volo/Abp/Reflection/TypeHelper_Tests.cs b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/Reflection/TypeHelper_Tests.cs index 23eba093a6..02056aab20 100644 --- a/framework/test/Volo.Abp.Core.Tests/Volo/Abp/Reflection/TypeHelper_Tests.cs +++ b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/Reflection/TypeHelper_Tests.cs @@ -75,6 +75,14 @@ namespace Volo.Abp.Reflection TypeHelper.GetDefaultValue(typeof(string)).ShouldBeNull(); } + [Fact] + public void ConvertFromString() + { + TypeHelper.ConvertFromString("42").ShouldBe(42); + TypeHelper.ConvertFromString("42").ShouldBe((int?)42); + TypeHelper.ConvertFromString(null).ShouldBeNull(); + } + public class MyDictionary : Dictionary { diff --git a/npm/ng-packs/package.json b/npm/ng-packs/package.json index ca6df69ebc..dd0fc7d23f 100644 --- a/npm/ng-packs/package.json +++ b/npm/ng-packs/package.json @@ -22,20 +22,20 @@ "generate:changelog": "conventional-changelog -p angular -i CHANGELOG.md -s" }, "devDependencies": { - "@abp/ng.account": "~2.6.2", - "@abp/ng.account.config": "~2.6.2", - "@abp/ng.core": "^2.6.2", - "@abp/ng.feature-management": "^2.6.2", - "@abp/ng.identity": "~2.6.2", - "@abp/ng.identity.config": "~2.6.2", - "@abp/ng.permission-management": "^2.6.2", - "@abp/ng.setting-management": "~2.6.2", - "@abp/ng.setting-management.config": "~2.6.2", - "@abp/ng.tenant-management": "~2.6.2", - "@abp/ng.tenant-management.config": "~2.6.2", - "@abp/ng.theme.basic": "~2.6.2", - "@abp/ng.theme.shared": "^2.6.2", - "@abp/utils": "^2.6.0", + "@abp/ng.account": "~2.7.0", + "@abp/ng.account.config": "~2.7.0", + "@abp/ng.core": "^2.7.0", + "@abp/ng.feature-management": "^2.7.0", + "@abp/ng.identity": "~2.7.0", + "@abp/ng.identity.config": "~2.7.0", + "@abp/ng.permission-management": "~2.7.0", + "@abp/ng.setting-management": "~2.7.0", + "@abp/ng.setting-management.config": "~2.7.0", + "@abp/ng.tenant-management": "~2.7.0", + "@abp/ng.tenant-management.config": "~2.7.0", + "@abp/ng.theme.basic": "~2.7.0", + "@abp/ng.theme.shared": "^2.7.0", + "@abp/utils": "^2.7.0", "@angular-builders/jest": "^8.2.0", "@angular-devkit/build-angular": "~0.803.21", "@angular-devkit/build-ng-packagr": "~0.803.21", diff --git a/npm/ng-packs/packages/core/package.json b/npm/ng-packs/packages/core/package.json index c16e9ce986..fe2f39f717 100644 --- a/npm/ng-packs/packages/core/package.json +++ b/npm/ng-packs/packages/core/package.json @@ -7,7 +7,7 @@ "url": "https://github.com/abpframework/abp.git" }, "dependencies": { - "@abp/utils": "^2.6.0", + "@abp/utils": "^2.7.0", "@angular/localize": "~9.1.0", "@ngxs/router-plugin": "^3.6.2", "@ngxs/storage-plugin": "^3.6.2", diff --git a/npm/ng-packs/packages/core/src/lib/services/index.ts b/npm/ng-packs/packages/core/src/lib/services/index.ts index f8b016bbd1..f01dc876de 100644 --- a/npm/ng-packs/packages/core/src/lib/services/index.ts +++ b/npm/ng-packs/packages/core/src/lib/services/index.ts @@ -4,6 +4,7 @@ export * from './config-state.service'; export * from './content-projection.service'; export * from './dom-insertion.service'; export * from './lazy-load.service'; +export * from './list.service'; export * from './localization.service'; export * from './profile-state.service'; export * from './profile.service'; diff --git a/npm/ng-packs/packages/core/src/lib/services/list.service.ts b/npm/ng-packs/packages/core/src/lib/services/list.service.ts new file mode 100644 index 0000000000..944368ad88 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/list.service.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable, OnDestroy, Optional } from '@angular/core'; +import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs'; +import { debounceTime, shareReplay, switchMap, 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 implements OnDestroy { + private _filter = ''; + set filter(value: string) { + this._filter = value; + this.get(); + } + get filter(): string { + return this._filter; + } + + private _maxResultCount = 10; + set maxResultCount(value: number) { + this._maxResultCount = value; + this.get(); + } + get maxResultCount(): number { + return this._maxResultCount; + } + + private _page = 1; + set page(value: number) { + this._page = value; + this.get(); + } + get page(): number { + return this._page; + } + + private _sortKey = ''; + set sortKey(value: string) { + this._sortKey = value; + this.get(); + } + get sortKey(): string { + return this._sortKey; + } + + private _sortOrder = ''; + set sortOrder(value: string) { + this._sortOrder = value; + this.get(); + } + get sortOrder(): string { + return this._sortOrder; + } + + private _query$ = new ReplaySubject(1); + + get query$(): Observable { + return this._query$ + .asObservable() + .pipe(debounceTime(this.delay || 300), shareReplay({ bufferSize: 1, refCount: true })); + } + + private _isLoading$ = new BehaviorSubject(false); + + get isLoading$(): Observable { + return this._isLoading$.asObservable(); + } + + get = () => { + this._query$.next({ + filter: this._filter || undefined, + maxResultCount: this._maxResultCount, + skipCount: (this._page - 1) * this._maxResultCount, + sorting: this._sortOrder ? `${this._sortKey} ${this._sortOrder}` : undefined, + }); + }; + + constructor(@Optional() @Inject(LIST_QUERY_DEBOUNCE_TIME) private delay: number) { + this.get(); + } + + hookToQuery( + streamCreatorCallback: QueryStreamCreatorCallback, + ): Observable> { + this._isLoading$.next(true); + + return this.query$.pipe( + switchMap(streamCreatorCallback), + tap(() => this._isLoading$.next(false)), + shareReplay({ bufferSize: 1, refCount: true }), + takeUntilDestroy(this), + ); + } + + ngOnDestroy() {} +} + +export type QueryStreamCreatorCallback = ( + query: ABP.PageQueryParams, +) => Observable>; diff --git a/npm/ng-packs/packages/core/src/lib/tests/list.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/list.service.spec.ts new file mode 100644 index 0000000000..c2118d7f3a --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/list.service.spec.ts @@ -0,0 +1,153 @@ +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { bufferCount, take } from 'rxjs/operators'; +import { ABP } from '../models'; +import { ListService, QueryStreamCreatorCallback } from '../services/list.service'; +import { LIST_QUERY_DEBOUNCE_TIME } from '../tokens'; + +describe('ListService', () => { + let spectator: SpectatorService; + let service: ListService; + + const createService = createServiceFactory({ + service: ListService, + providers: [ + { + provide: LIST_QUERY_DEBOUNCE_TIME, + useValue: 0, + }, + ], + }); + + beforeEach(() => { + spectator = createService(); + service = spectator.service; + }); + + describe('#filter', () => { + it('should initially be empty string', () => { + expect(service.filter).toBe(''); + }); + + it('should be changed', () => { + service.filter = 'foo'; + + expect(service.filter).toBe('foo'); + }); + }); + + describe('#maxResultCount', () => { + it('should initially be 10', () => { + expect(service.maxResultCount).toBe(10); + }); + + it('should be changed', () => { + service.maxResultCount = 20; + + expect(service.maxResultCount).toBe(20); + }); + }); + + describe('#page', () => { + it('should initially be 1', () => { + expect(service.page).toBe(1); + }); + + it('should be changed', () => { + service.page = 9; + + expect(service.page).toBe(9); + }); + }); + + describe('#sortKey', () => { + it('should initially be empty string', () => { + expect(service.sortKey).toBe(''); + }); + + it('should be changed', () => { + service.sortKey = 'foo'; + + expect(service.sortKey).toBe('foo'); + }); + }); + + describe('#sortOrder', () => { + it('should initially be empty string', () => { + expect(service.sortOrder).toBe(''); + }); + + it('should be changed', () => { + service.sortOrder = 'foo'; + + expect(service.sortOrder).toBe('foo'); + }); + }); + + describe('#query$', () => { + it('should initially emit default query', done => { + service.query$.pipe(take(1)).subscribe(query => { + expect(query).toEqual({ + filter: undefined, + maxResultCount: 10, + skipCount: 0, + sorting: undefined, + }); + + done(); + }); + }); + + it('should emit a query based on params set', done => { + service.filter = 'foo'; + service.sortKey = 'bar'; + service.sortOrder = 'baz'; + service.maxResultCount = 20; + service.page = 9; + + service.query$.pipe(take(1)).subscribe(query => { + expect(query).toEqual({ + filter: 'foo', + sorting: 'bar baz', + maxResultCount: 20, + skipCount: 160, + }); + + done(); + }); + }); + }); + + describe('#hookToQuery', () => { + it('should call given callback with the query', done => { + const callback: QueryStreamCreatorCallback = query => + of({ items: [query], totalCount: 1 }); + + service.hookToQuery(callback).subscribe(({ items: [query] }) => { + expect(query).toEqual({ + filter: undefined, + maxResultCount: 10, + skipCount: 0, + sorting: undefined, + }); + + done(); + }); + }); + + it('should emit isLoading as side effect', done => { + const callback: QueryStreamCreatorCallback = query => + of({ items: [query], totalCount: 1 }); + + service.isLoading$.pipe(bufferCount(3)).subscribe(([idle, init, end]) => { + expect(idle).toBe(false); + expect(init).toBe(true); + expect(end).toBe(false); + + done(); + }); + + service.hookToQuery(callback).subscribe(); + }); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/tokens/index.ts b/npm/ng-packs/packages/core/src/lib/tokens/index.ts index 683bc4b3db..8d23d581a5 100644 --- a/npm/ng-packs/packages/core/src/lib/tokens/index.ts +++ b/npm/ng-packs/packages/core/src/lib/tokens/index.ts @@ -1 +1,2 @@ +export * from './list.token'; export * from './options.token'; diff --git a/npm/ng-packs/packages/core/src/lib/tokens/list.token.ts b/npm/ng-packs/packages/core/src/lib/tokens/list.token.ts new file mode 100644 index 0000000000..b51ffb9fb3 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tokens/list.token.ts @@ -0,0 +1,3 @@ +import { InjectionToken } from '@angular/core'; + +export const LIST_QUERY_DEBOUNCE_TIME = new InjectionToken('LIST_QUERY_DEBOUNCE_TIME'); diff --git a/npm/ng-packs/packages/setting-management/src/lib/setting-management-routing.module.ts b/npm/ng-packs/packages/setting-management/src/lib/setting-management-routing.module.ts index f394abc962..55a056c411 100644 --- a/npm/ng-packs/packages/setting-management/src/lib/setting-management-routing.module.ts +++ b/npm/ng-packs/packages/setting-management/src/lib/setting-management-routing.module.ts @@ -2,6 +2,7 @@ import { DynamicLayoutComponent, ReplaceableComponents, ReplaceableRouteContainerComponent, + AuthGuard, } from '@abp/ng.core'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; @@ -12,6 +13,7 @@ const routes: Routes = [ { path: '', component: DynamicLayoutComponent, + canActivate: [AuthGuard], children: [ { path: '', diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.scss b/npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.scss index 2dd3482e27..19a4f2ee8f 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.scss +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.scss @@ -16,6 +16,9 @@ left: 0; position: fixed; top: 0; - transition: width 0.4s ease; + + &.progressing { + transition: width 0.4s ease; + } } } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.ts index 441359e476..41a5850131 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/loader-bar/loader-bar.component.ts @@ -3,7 +3,7 @@ import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular import { NavigationEnd, NavigationError, NavigationStart, Router } from '@angular/router'; import { takeUntilDestroy } from '@ngx-validate/core'; import { Actions, ofActionSuccessful } from '@ngxs/store'; -import { interval, Subscription, timer } from 'rxjs'; +import { Subscription, timer } from 'rxjs'; import { filter } from 'rxjs/operators'; @Component({ @@ -12,6 +12,7 @@ import { filter } from 'rxjs/operators';
+ { + var currentPrincipalAccessor = ctx.RequestServices.GetRequiredService(); + var map = new Dictionary() + { + { "sub", AbpClaimTypes.UserId }, + { "role", AbpClaimTypes.Role }, + { "email", AbpClaimTypes.Email }, + //any other map + }; + var mapClaims = currentPrincipalAccessor.Principal.Claims.Where(p => map.Keys.Contains(p.Type)).ToList(); + currentPrincipalAccessor.Principal.AddIdentity(new ClaimsIdentity(mapClaims.Select(p => new Claim(map[p.Type], p.Value, p.ValueType, p.Issuer)))); + await next(); + }); app.UseAuthorization(); app.UseAbpRequestLocalization(); app.UseSwagger();