mirror of https://github.com/abpframework/abp.git
63 changed files with 1838 additions and 440 deletions
@ -0,0 +1,31 @@ |
|||
using System.Net; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Mvc.Abstractions; |
|||
using Microsoft.AspNetCore.Mvc.Filters; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Response |
|||
{ |
|||
public class AbpNoContentActionFilter : IAsyncActionFilter, ITransientDependency |
|||
{ |
|||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) |
|||
{ |
|||
if (!context.ActionDescriptor.IsControllerAction()) |
|||
{ |
|||
await next(); |
|||
return; |
|||
} |
|||
|
|||
await next(); |
|||
|
|||
if (context.HttpContext.Response.StatusCode == (int)HttpStatusCode.OK) |
|||
{ |
|||
var returnType = context.ActionDescriptor.GetReturnType(); |
|||
if (returnType == typeof(Task) || returnType == typeof(void)) |
|||
{ |
|||
context.HttpContext.Response.StatusCode = (int)HttpStatusCode.NoContent; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Response |
|||
{ |
|||
[Route("api/NoContent-Test")] |
|||
public class NoContentTestController : AbpController |
|||
{ |
|||
[HttpGet] |
|||
[Route("TestMethod")] |
|||
public void TestMethod() |
|||
{ |
|||
} |
|||
|
|||
[HttpGet] |
|||
[Route("TestMethodWithReturn")] |
|||
public string TestMethodWithReturn() |
|||
{ |
|||
return "TestReturn"; |
|||
} |
|||
|
|||
[HttpGet] |
|||
[Route("TestCustomHttpStatusCodeMethod")] |
|||
public void TestCustomHttpStatusCodeMethod() |
|||
{ |
|||
Response.Redirect("/"); |
|||
} |
|||
|
|||
[HttpGet] |
|||
[Route("TestAsyncMethod")] |
|||
public async Task TestAsyncMethod() |
|||
{ |
|||
await Task.CompletedTask; |
|||
} |
|||
|
|||
[HttpGet] |
|||
[Route("TestAsyncMethodWithReturn")] |
|||
public async Task<string> TestAsyncMethodWithReturn() |
|||
{ |
|||
return await Task.FromResult("TestReturn"); |
|||
} |
|||
|
|||
[HttpGet] |
|||
[Route("TestAsyncCustomHttpStatusCodeMethod")] |
|||
public async Task TestAsyncCustomHttpStatusCodeMethod() |
|||
{ |
|||
Response.Redirect("/"); |
|||
await Task.CompletedTask; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,58 @@ |
|||
using System.Net; |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.Response |
|||
{ |
|||
public class NoContentTestController_Tests : AspNetCoreMvcTestBase |
|||
{ |
|||
[Fact] |
|||
public async Task Should_Set_No_Content_For_Void_Action() |
|||
{ |
|||
var result = await GetResponseAsync("/api/NoContent-Test/TestMethod", HttpStatusCode.NoContent) |
|||
.ConfigureAwait(false); |
|||
result.StatusCode.ShouldBe(HttpStatusCode.NoContent); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Set_No_Content_For_Not_Void_Action() |
|||
{ |
|||
var result = await GetResponseAsync("/api/NoContent-Test/TestMethodWithReturn") |
|||
.ConfigureAwait(false); |
|||
result.StatusCode.ShouldBe(HttpStatusCode.OK); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Set_No_Content_For_Custom_Http_Status_Code_Action() |
|||
{ |
|||
var result = await GetResponseAsync("/api/NoContent-Test/TestCustomHttpStatusCodeMethod", HttpStatusCode.Redirect) |
|||
.ConfigureAwait(false); |
|||
result.StatusCode.ShouldBe(HttpStatusCode.Redirect); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Set_No_Content_For_Task_Action() |
|||
{ |
|||
var result = await GetResponseAsync("/api/NoContent-Test/TestAsyncMethod", HttpStatusCode.NoContent) |
|||
.ConfigureAwait(false); |
|||
result.StatusCode.ShouldBe(HttpStatusCode.NoContent); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Set_No_Content_For_Not_Task_Action() |
|||
{ |
|||
var result = await GetResponseAsync("/api/NoContent-Test/TestAsyncMethodWithReturn") |
|||
.ConfigureAwait(false); |
|||
result.StatusCode.ShouldBe(HttpStatusCode.OK); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Set_No_Content_For_Custom_Http_Status_Code_Async_Action() |
|||
{ |
|||
var result = await GetResponseAsync("/api/NoContent-Test/TestAsyncCustomHttpStatusCodeMethod", HttpStatusCode.Redirect) |
|||
.ConfigureAwait(false); |
|||
result.StatusCode.ShouldBe(HttpStatusCode.Redirect); |
|||
} |
|||
} |
|||
} |
|||
@ -1,13 +1,16 @@ |
|||
import { Component, Input, TemplateRef } from '@angular/core'; |
|||
import { Account } from '../../models/account'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-auth-wrapper', |
|||
templateUrl: './auth-wrapper.component.html', |
|||
exportAs: 'abpAuthWrapper', |
|||
}) |
|||
export class AuthWrapperComponent { |
|||
export class AuthWrapperComponent |
|||
implements Account.AuthWrapperComponentInputs, Account.AuthWrapperComponentOutputs { |
|||
@Input() |
|||
mainContentRef: TemplateRef<any>; |
|||
readonly mainContentRef: TemplateRef<any>; |
|||
|
|||
@Input() |
|||
cancelContentRef: TemplateRef<any>; |
|||
readonly cancelContentRef: TemplateRef<any>; |
|||
} |
|||
|
|||
@ -1,50 +1,74 @@ |
|||
<abp-auth-wrapper [mainContentRef]="mainContentRef" [cancelContentRef]="cancelContentRef"> |
|||
<ng-template #mainContentRef> |
|||
<h4>{{ 'AbpAccount::Login' | abpLocalization }}</h4> |
|||
<strong> |
|||
{{ 'AbpAccount::AreYouANewUser' | abpLocalization }} |
|||
<a class="text-decoration-none" routerLink="/account/register">{{ 'AbpAccount::Register' | abpLocalization }}</a> |
|||
</strong> |
|||
<form [formGroup]="form" (ngSubmit)="onSubmit()" validateOnSubmit class="mt-4"> |
|||
<div class="form-group"> |
|||
<label for="login-input-user-name-or-email-address">{{ |
|||
'AbpAccount::UserNameOrEmailAddress' | abpLocalization |
|||
}}</label> |
|||
<abp-auth-wrapper |
|||
*abpReplaceableTemplate="{ |
|||
componentKey: 'Account.AuthWrapperComponent', |
|||
inputs: { |
|||
mainContentRef: { value: mainContentRef }, |
|||
cancelContentRef: { value: cancelContentRef } |
|||
} |
|||
}" |
|||
[mainContentRef]="mainContentRef" |
|||
[cancelContentRef]="cancelContentRef" |
|||
> |
|||
</abp-auth-wrapper> |
|||
<ng-template #mainContentRef> |
|||
<h4>{{ 'AbpAccount::Login' | abpLocalization }}</h4> |
|||
<strong> |
|||
{{ 'AbpAccount::AreYouANewUser' | abpLocalization }} |
|||
<a class="text-decoration-none" routerLink="/account/register">{{ |
|||
'AbpAccount::Register' | abpLocalization |
|||
}}</a> |
|||
</strong> |
|||
<form [formGroup]="form" (ngSubmit)="onSubmit()" validateOnSubmit class="mt-4"> |
|||
<div class="form-group"> |
|||
<label for="login-input-user-name-or-email-address">{{ |
|||
'AbpAccount::UserNameOrEmailAddress' | abpLocalization |
|||
}}</label> |
|||
<input |
|||
class="form-control" |
|||
type="text" |
|||
id="login-input-user-name-or-email-address" |
|||
formControlName="username" |
|||
autocomplete="username" |
|||
autofocus |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="login-input-password">{{ 'AbpAccount::Password' | abpLocalization }}</label> |
|||
<input |
|||
class="form-control" |
|||
type="password" |
|||
id="login-input-password" |
|||
formControlName="password" |
|||
autocomplete="current-password" |
|||
/> |
|||
</div> |
|||
<div class="form-check" validationTarget validationStyle> |
|||
<label class="form-check-label" for="login-input-remember-me"> |
|||
<input |
|||
class="form-control" |
|||
type="text" |
|||
id="login-input-user-name-or-email-address" |
|||
formControlName="username" |
|||
autofocus |
|||
class="form-check-input" |
|||
type="checkbox" |
|||
id="login-input-remember-me" |
|||
formControlName="remember" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="login-input-password">{{ 'AbpAccount::Password' | abpLocalization }}</label> |
|||
<input class="form-control" type="password" id="login-input-password" formControlName="password" /> |
|||
</div> |
|||
<div class="form-check" validationTarget validationStyle> |
|||
<label class="form-check-label" for="login-input-remember-me"> |
|||
<input class="form-check-input" type="checkbox" id="login-input-remember-me" formControlName="remember" /> |
|||
{{ 'AbpAccount::RememberMe' | abpLocalization }} |
|||
</label> |
|||
</div> |
|||
<abp-button |
|||
[loading]="inProgress" |
|||
buttonType="submit" |
|||
name="Action" |
|||
buttonClass="btn-block btn-lg mt-3 btn btn-primary" |
|||
> |
|||
{{ 'AbpAccount::Login' | abpLocalization }} |
|||
</abp-button> |
|||
</form> |
|||
</ng-template> |
|||
<ng-template #cancelContentRef> |
|||
<div class="card-footer text-center border-0"> |
|||
<a routerLink="/"> |
|||
<button type="button" name="Action" value="Cancel" class="px-2 py-0 btn btn-link"> |
|||
{{ 'AbpAccount::Cancel' | abpLocalization }} |
|||
</button> |
|||
</a> |
|||
{{ 'AbpAccount::RememberMe' | abpLocalization }} |
|||
</label> |
|||
</div> |
|||
</ng-template> |
|||
</abp-auth-wrapper> |
|||
<abp-button |
|||
[loading]="inProgress" |
|||
buttonType="submit" |
|||
name="Action" |
|||
buttonClass="btn-block btn-lg mt-3 btn btn-primary" |
|||
> |
|||
{{ 'AbpAccount::Login' | abpLocalization }} |
|||
</abp-button> |
|||
</form> |
|||
</ng-template> |
|||
<ng-template #cancelContentRef> |
|||
<div class="card-footer text-center border-0"> |
|||
<a routerLink="/"> |
|||
<button type="button" name="Action" value="Cancel" class="px-2 py-0 btn btn-link"> |
|||
{{ 'AbpAccount::Cancel' | abpLocalization }} |
|||
</button> |
|||
</a> |
|||
</div> |
|||
</ng-template> |
|||
|
|||
@ -1,32 +1,57 @@ |
|||
<abp-auth-wrapper [mainContentRef]="mainContentRef"> |
|||
<ng-template #mainContentRef> |
|||
<h4>{{ 'AbpAccount::Register' | abpLocalization }}</h4> |
|||
<strong> |
|||
{{ 'AbpAccount::AlreadyRegistered' | abpLocalization }} |
|||
<a class="text-decoration-none" routerLink="/account/login">{{ 'AbpAccount::Login' | abpLocalization }}</a> |
|||
</strong> |
|||
<form [formGroup]="form" (ngSubmit)="onSubmit()" validateOnSubmit class="mt-4"> |
|||
<div class="form-group"> |
|||
<label for="input-user-name">{{ 'AbpAccount::UserName' | abpLocalization }}</label |
|||
><span> * </span |
|||
><input autofocus type="text" id="input-user-name" class="form-control" formControlName="username" /> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="input-email-address">{{ 'AbpAccount::EmailAddress' | abpLocalization }}</label |
|||
><span> * </span><input type="email" id="input-email-address" class="form-control" formControlName="email" /> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="input-password">{{ 'AbpAccount::Password' | abpLocalization }}</label |
|||
><span> * </span><input type="password" id="input-password" class="form-control" formControlName="password" /> |
|||
</div> |
|||
<abp-button |
|||
[loading]="inProgress" |
|||
buttonType="submit" |
|||
name="Action" |
|||
buttonClass="btn-block btn-lg mt-3 btn btn-primary" |
|||
> |
|||
{{ 'AbpAccount::Register' | abpLocalization }} |
|||
</abp-button> |
|||
</form> |
|||
</ng-template> |
|||
<abp-auth-wrapper |
|||
*abpReplaceableTemplate="{ |
|||
componentKey: 'Account.AuthWrapperComponent', |
|||
inputs: { |
|||
mainContentRef: { value: mainContentRef } |
|||
} |
|||
}" |
|||
[mainContentRef]="mainContentRef" |
|||
> |
|||
</abp-auth-wrapper> |
|||
<ng-template #mainContentRef> |
|||
<h4>{{ 'AbpAccount::Register' | abpLocalization }}</h4> |
|||
<strong> |
|||
{{ 'AbpAccount::AlreadyRegistered' | abpLocalization }} |
|||
<a class="text-decoration-none" routerLink="/account/login">{{ |
|||
'AbpAccount::Login' | abpLocalization |
|||
}}</a> |
|||
</strong> |
|||
<form [formGroup]="form" (ngSubmit)="onSubmit()" validateOnSubmit class="mt-4"> |
|||
<div class="form-group"> |
|||
<label for="input-user-name">{{ 'AbpAccount::UserName' | abpLocalization }}</label |
|||
><span> * </span |
|||
><input |
|||
autofocus |
|||
type="text" |
|||
id="input-user-name" |
|||
class="form-control" |
|||
formControlName="username" |
|||
autocomplete="username" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="input-email-address">{{ 'AbpAccount::EmailAddress' | abpLocalization }}</label |
|||
><span> * </span |
|||
><input type="email" id="input-email-address" class="form-control" formControlName="email" /> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="input-password">{{ 'AbpAccount::Password' | abpLocalization }}</label |
|||
><span> * </span |
|||
><input |
|||
type="password" |
|||
id="input-password" |
|||
class="form-control" |
|||
formControlName="password" |
|||
autocomplete="current-password" |
|||
/> |
|||
</div> |
|||
<abp-button |
|||
[loading]="inProgress" |
|||
buttonType="submit" |
|||
name="Action" |
|||
buttonClass="btn-block btn-lg mt-3 btn btn-primary" |
|||
> |
|||
{{ 'AbpAccount::Register' | abpLocalization }} |
|||
</abp-button> |
|||
</form> |
|||
</ng-template> |
|||
|
|||
@ -0,0 +1,29 @@ |
|||
import { TemplateRef } from '@angular/core'; |
|||
|
|||
export namespace Account { |
|||
export interface AuthWrapperComponentInputs { |
|||
readonly mainContentRef: TemplateRef<any>; |
|||
readonly cancelContentRef?: TemplateRef<any>; |
|||
} |
|||
|
|||
// tslint:disable-next-line: no-empty-interface
|
|||
export interface AuthWrapperComponentOutputs {} |
|||
|
|||
// tslint:disable-next-line: no-empty-interface
|
|||
export interface TenantBoxComponentInputs {} |
|||
|
|||
// tslint:disable-next-line: no-empty-interface
|
|||
export interface TenantBoxComponentOutputs {} |
|||
|
|||
// tslint:disable-next-line: no-empty-interface
|
|||
export interface PersonalSettingsComponentInputs {} |
|||
|
|||
// tslint:disable-next-line: no-empty-interface
|
|||
export interface PersonalSettingsComponentOutputs {} |
|||
|
|||
// tslint:disable-next-line: no-empty-interface
|
|||
export interface ChangePasswordComponentInputs {} |
|||
|
|||
// tslint:disable-next-line: no-empty-interface
|
|||
export interface ChangePasswordComponentOutputs {} |
|||
} |
|||
@ -1,3 +1,4 @@ |
|||
export * from './account'; |
|||
export * from './options'; |
|||
export * from './user'; |
|||
export * from './tenant'; |
|||
|
|||
@ -1,5 +1,6 @@ |
|||
export * from './config.actions'; |
|||
export * from './loader.actions'; |
|||
export * from './profile.actions'; |
|||
export * from './replaceable-components.actions'; |
|||
export * from './rest.actions'; |
|||
export * from './session.actions'; |
|||
|
|||
@ -0,0 +1,6 @@ |
|||
import { ReplaceableComponents } from '../models/replaceable-components'; |
|||
|
|||
export class AddReplaceableComponent { |
|||
static readonly type = '[ReplaceableComponents] Add'; |
|||
constructor(public payload: ReplaceableComponents.ReplaceableComponent) {} |
|||
} |
|||
@ -1,2 +1,3 @@ |
|||
export * from './dynamic-layout.component'; |
|||
export * from './replaceable-route-container.component'; |
|||
export * from './router-outlet.component'; |
|||
|
|||
@ -0,0 +1,39 @@ |
|||
import { Component, OnDestroy, 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 { 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> |
|||
`,
|
|||
}) |
|||
export class ReplaceableRouteContainerComponent implements OnInit, OnDestroy { |
|||
defaultComponent: Type<any>; |
|||
|
|||
componentKey: string; |
|||
|
|||
externalComponent: Type<any>; |
|||
|
|||
constructor(private route: ActivatedRoute, private store: Store) {} |
|||
|
|||
ngOnInit() { |
|||
this.defaultComponent = this.route.snapshot.data.replaceableComponent.defaultComponent; |
|||
this.componentKey = (this.route.snapshot.data |
|||
.replaceableComponent as ReplaceableComponents.RouteData).key; |
|||
|
|||
this.store |
|||
.select(ReplaceableComponentsState.getComponent(this.componentKey)) |
|||
.pipe(takeUntilDestroy(this), distinctUntilChanged()) |
|||
.subscribe((res = {} as ReplaceableComponents.ReplaceableComponent) => { |
|||
this.externalComponent = res.component; |
|||
}); |
|||
} |
|||
|
|||
ngOnDestroy() {} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import { Directive, Output, EventEmitter, ElementRef, AfterViewInit } from '@angular/core'; |
|||
|
|||
@Directive({ selector: '[abpInit]' }) |
|||
export class InitDirective implements AfterViewInit { |
|||
@Output('abpInit') readonly init = new EventEmitter<ElementRef<any>>(); |
|||
|
|||
constructor(private elRef: ElementRef) {} |
|||
|
|||
ngAfterViewInit() { |
|||
this.init.emit(this.elRef); |
|||
} |
|||
} |
|||
@ -0,0 +1,164 @@ |
|||
import { |
|||
ComponentFactoryResolver, |
|||
Directive, |
|||
Injector, |
|||
Input, |
|||
OnChanges, |
|||
OnDestroy, |
|||
OnInit, |
|||
SimpleChanges, |
|||
TemplateRef, |
|||
Type, |
|||
ViewContainerRef, |
|||
} from '@angular/core'; |
|||
import { Store } from '@ngxs/store'; |
|||
import { Subscription } from 'rxjs'; |
|||
import { filter } from 'rxjs/operators'; |
|||
import { ABP } from '../models/common'; |
|||
import { ReplaceableComponents } from '../models/replaceable-components'; |
|||
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 { |
|||
@Input('abpReplaceableTemplate') |
|||
data: ReplaceableComponents.ReplaceableTemplateDirectiveInput<any, any>; |
|||
|
|||
providedData = { inputs: {}, outputs: {} } as ReplaceableComponents.ReplaceableTemplateData< |
|||
any, |
|||
any |
|||
>; |
|||
|
|||
context = {} as any; |
|||
|
|||
externalComponent: Type<any>; |
|||
|
|||
defaultComponentRef: any; |
|||
|
|||
defaultComponentSubscriptions = {} as ABP.Dictionary<Subscription>; |
|||
|
|||
initialized = false; |
|||
|
|||
constructor( |
|||
private injector: Injector, |
|||
private templateRef: TemplateRef<any>, |
|||
private cfRes: ComponentFactoryResolver, |
|||
private vcRef: ViewContainerRef, |
|||
private store: Store, |
|||
) { |
|||
this.context = { |
|||
initTemplate: ref => { |
|||
this.resetDefaultComponent(); |
|||
this.defaultComponentRef = ref; |
|||
this.setDefaultComponentInputs(); |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
ngOnInit() { |
|||
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.vcRef.clear(); |
|||
this.externalComponent = res.component; |
|||
if (this.defaultComponentRef) { |
|||
this.resetDefaultComponent(); |
|||
} |
|||
|
|||
if (res.component) { |
|||
this.setProvidedData(); |
|||
const customInjector = Injector.create({ |
|||
providers: [{ provide: 'REPLACEABLE_DATA', useValue: this.providedData }], |
|||
parent: this.injector, |
|||
}); |
|||
this.vcRef.createComponent( |
|||
this.cfRes.resolveComponentFactory(res.component), |
|||
0, |
|||
customInjector, |
|||
); |
|||
} else { |
|||
this.vcRef.createEmbeddedView(this.templateRef, this.context); |
|||
} |
|||
|
|||
this.initialized = true; |
|||
}); |
|||
} |
|||
|
|||
ngOnChanges(changes: SimpleChanges) { |
|||
if (snq(() => changes.data.currentValue.inputs) && this.defaultComponentRef) { |
|||
this.setDefaultComponentInputs(); |
|||
} |
|||
} |
|||
|
|||
ngOnDestroy() {} |
|||
|
|||
setDefaultComponentInputs() { |
|||
if (!this.defaultComponentRef || (!this.data.inputs && !this.data.outputs)) return; |
|||
|
|||
if (this.data.inputs) { |
|||
for (const key in this.data.inputs) { |
|||
if (this.data.inputs.hasOwnProperty(key)) { |
|||
if (!compare(this.defaultComponentRef[key], this.data.inputs[key].value)) { |
|||
this.defaultComponentRef[key] = this.data.inputs[key].value; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (this.data.outputs) { |
|||
for (const key in this.data.outputs) { |
|||
if (this.data.outputs.hasOwnProperty(key)) { |
|||
if (!this.defaultComponentSubscriptions[key]) { |
|||
this.defaultComponentSubscriptions[key] = this.defaultComponentRef[key].subscribe( |
|||
value => { |
|||
this.data.outputs[key](value); |
|||
}, |
|||
); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
setProvidedData() { |
|||
this.providedData = { ...this.data, inputs: {} }; |
|||
|
|||
if (!this.data.inputs) return; |
|||
Object.defineProperties(this.providedData.inputs, { |
|||
...Object.keys(this.data.inputs).reduce( |
|||
(acc, key) => ({ |
|||
...acc, |
|||
[key]: { |
|||
enumerable: true, |
|||
configurable: true, |
|||
get: () => this.data.inputs[key].value, |
|||
...(this.data.inputs[key].twoWay && { |
|||
set: newValue => { |
|||
this.data.inputs[key].value = newValue; |
|||
this.data.outputs[`${key}Change`](newValue); |
|||
}, |
|||
}), |
|||
}, |
|||
}), |
|||
{}, |
|||
), |
|||
}); |
|||
} |
|||
|
|||
resetDefaultComponent() { |
|||
Object.keys(this.defaultComponentSubscriptions).forEach(key => { |
|||
this.defaultComponentSubscriptions[key].unsubscribe(); |
|||
}); |
|||
this.defaultComponentSubscriptions = {} as ABP.Dictionary<Subscription>; |
|||
this.defaultComponentRef = null; |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
import { Type, EventEmitter } from '@angular/core'; |
|||
import { ABP } from './common'; |
|||
import { Subject, BehaviorSubject } from 'rxjs'; |
|||
|
|||
export namespace ReplaceableComponents { |
|||
export interface State { |
|||
replaceableComponents: ReplaceableComponent[]; |
|||
} |
|||
|
|||
export interface ReplaceableComponent { |
|||
component: Type<any>; |
|||
key: string; |
|||
} |
|||
|
|||
export interface ReplaceableTemplateDirectiveInput< |
|||
I, |
|||
O extends { [K in keyof O]: EventEmitter<any> | Subject<any> } |
|||
> { |
|||
inputs: { -readonly [K in keyof I]: { value: I[K]; twoWay?: boolean } }; |
|||
outputs: { -readonly [K in keyof O]: (value: ABP.ExtractFromOutput<O[K]>) => void }; |
|||
componentKey: string; |
|||
} |
|||
|
|||
export interface ReplaceableTemplateData< |
|||
I, |
|||
O extends { [K in keyof O]: EventEmitter<any> | Subject<any> } |
|||
> { |
|||
inputs: ReplaceableTemplateInputs<I>; |
|||
outputs: ReplaceableTemplateOutputs<O>; |
|||
componentKey: string; |
|||
} |
|||
|
|||
export type ReplaceableTemplateInputs<T> = { |
|||
[K in keyof T]: T[K]; |
|||
}; |
|||
|
|||
export type ReplaceableTemplateOutputs< |
|||
T extends { [K in keyof T]: EventEmitter<any> | Subject<any> } |
|||
> = { |
|||
[K in keyof T]: (value: ABP.ExtractFromOutput<T[K]>) => void; |
|||
}; |
|||
|
|||
export interface RouteData<T = any> { |
|||
key: string; |
|||
defaultComponent: Type<T>; |
|||
} |
|||
} |
|||
@ -1,3 +1,4 @@ |
|||
export * from './profile.state'; |
|||
export * from './replaceable-components.state'; |
|||
export * from './config.state'; |
|||
export * from './profile.state'; |
|||
export * from './session.state'; |
|||
|
|||
@ -0,0 +1,50 @@ |
|||
import { State, Action, StateContext, Selector, createSelector } from '@ngxs/store'; |
|||
import { AddReplaceableComponent } from '../actions/replaceable-components.actions'; |
|||
import { ReplaceableComponents } from '../models/replaceable-components'; |
|||
import snq from 'snq'; |
|||
|
|||
@State<ReplaceableComponents.State>({ |
|||
name: 'ReplaceableComponentsState', |
|||
defaults: { replaceableComponents: [] } as ReplaceableComponents.State, |
|||
}) |
|||
export class ReplaceableComponentsState { |
|||
@Selector() |
|||
static getAll({ |
|||
replaceableComponents, |
|||
}: ReplaceableComponents.State): ReplaceableComponents.ReplaceableComponent[] { |
|||
return replaceableComponents || []; |
|||
} |
|||
|
|||
static getComponent(key: string) { |
|||
const selector = createSelector( |
|||
[ReplaceableComponentsState], |
|||
(state: ReplaceableComponents.State): ReplaceableComponents.ReplaceableComponent => { |
|||
return snq(() => state.replaceableComponents.find(component => component.key === key)); |
|||
}, |
|||
); |
|||
|
|||
return selector; |
|||
} |
|||
|
|||
@Action(AddReplaceableComponent) |
|||
replaceableComponentsAction( |
|||
{ getState, patchState }: StateContext<ReplaceableComponents.State>, |
|||
{ payload }: AddReplaceableComponent, |
|||
) { |
|||
let { replaceableComponents } = getState(); |
|||
|
|||
const index = snq( |
|||
() => replaceableComponents.findIndex(component => component.key === payload.key), |
|||
-1, |
|||
); |
|||
if (index > -1) { |
|||
replaceableComponents[index] = payload; |
|||
} else { |
|||
replaceableComponents = [...replaceableComponents, payload]; |
|||
} |
|||
|
|||
patchState({ |
|||
replaceableComponents, |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; |
|||
import { NgxsModule, Store } from '@ngxs/store'; |
|||
import { ReplaceableComponentsState } from '../states/replaceable-components.state'; |
|||
import { Component } from '@angular/core'; |
|||
import { AddReplaceableComponent } from '../actions'; |
|||
|
|||
@Component({ selector: 'abp-dummy', template: 'dummy works' }) |
|||
class DummyComponent {} |
|||
|
|||
describe('ReplaceableComponentsState', () => { |
|||
let spectator: SpectatorHost<DummyComponent>; |
|||
const createHost = createHostFactory({ |
|||
component: DummyComponent, |
|||
imports: [NgxsModule.forRoot([ReplaceableComponentsState])], |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
spectator = createHost('<abp-dummy></abp-dummy>'); |
|||
}); |
|||
|
|||
it('should add a component to the state', () => { |
|||
const store = spectator.get(Store); |
|||
expect(store.selectSnapshot(ReplaceableComponentsState.getAll)).toEqual([]); |
|||
store.dispatch(new AddReplaceableComponent({ component: DummyComponent, key: 'Dummy' })); |
|||
expect(store.selectSnapshot(ReplaceableComponentsState.getComponent('Dummy'))).toEqual({ |
|||
component: DummyComponent, |
|||
key: 'Dummy', |
|||
}); |
|||
}); |
|||
|
|||
it('should replace a exist component', () => { |
|||
const store = spectator.get(Store); |
|||
store.dispatch(new AddReplaceableComponent({ component: DummyComponent, key: 'Dummy' })); |
|||
store.dispatch(new AddReplaceableComponent({ component: null, key: 'Dummy' })); |
|||
expect(store.selectSnapshot(ReplaceableComponentsState.getComponent('Dummy'))).toEqual({ |
|||
component: null, |
|||
key: 'Dummy', |
|||
}); |
|||
expect(store.selectSnapshot(ReplaceableComponentsState.getAll)).toHaveLength(1); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,66 @@ |
|||
import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; |
|||
import { Component } from '@angular/core'; |
|||
import { ActivatedRoute } from '@angular/router'; |
|||
import { Store } from '@ngxs/store'; |
|||
import { of, Subject, BehaviorSubject } from 'rxjs'; |
|||
import { ReplaceableRouteContainerComponent } from '../components/replaceable-route-container.component'; |
|||
import { ReplaceableComponentsState } from '../states'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-external-component', |
|||
template: '<p>external</p>', |
|||
}) |
|||
export class ExternalComponent {} |
|||
|
|||
@Component({ |
|||
selector: 'abp-default-component', |
|||
template: '<p>default</p>', |
|||
}) |
|||
export class DefaultComponent {} |
|||
|
|||
const activatedRouteMock = { |
|||
snapshot: { |
|||
data: { |
|||
replaceableComponent: { |
|||
defaultComponent: DefaultComponent, |
|||
key: 'TestModule.TestComponent', |
|||
}, |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
describe('ReplaceableRouteContainerComponent', () => { |
|||
const selectResponse = new BehaviorSubject(undefined); |
|||
const mockSelect = jest.fn(() => selectResponse); |
|||
|
|||
let spectator: SpectatorHost<ReplaceableRouteContainerComponent>; |
|||
const createHost = createHostFactory({ |
|||
component: ReplaceableRouteContainerComponent, |
|||
providers: [ |
|||
{ provide: ActivatedRoute, useValue: activatedRouteMock }, |
|||
{ provide: Store, useValue: { select: mockSelect } }, |
|||
], |
|||
declarations: [ExternalComponent, DefaultComponent], |
|||
entryComponents: [DefaultComponent, ExternalComponent], |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
spectator = createHost('<abp-replaceable-route-container></abp-replaceable-route-container>', { |
|||
detectChanges: true, |
|||
}); |
|||
}); |
|||
|
|||
it('should display the default component', () => { |
|||
expect(spectator.query('p')).toHaveText('default'); |
|||
}); |
|||
|
|||
it("should display the external component if it's available in store.", () => { |
|||
selectResponse.next({ component: ExternalComponent }); |
|||
spectator.detectChanges(); |
|||
expect(spectator.query('p')).toHaveText('external'); |
|||
|
|||
selectResponse.next({ component: null }); |
|||
spectator.detectChanges(); |
|||
expect(spectator.query('p')).toHaveText('default'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,175 @@ |
|||
import { Component, EventEmitter, Inject, Input, OnInit, Optional, Output } from '@angular/core'; |
|||
import { createDirectiveFactory, SpectatorDirective } from '@ngneat/spectator/jest'; |
|||
import { Store } from '@ngxs/store'; |
|||
import { Subject } from 'rxjs'; |
|||
import { ReplaceableTemplateDirective } from '../directives'; |
|||
import { ReplaceableComponents } from '../models'; |
|||
|
|||
@Component({ |
|||
selector: 'abp-default-component', |
|||
template: ` |
|||
<p>default</p> |
|||
`,
|
|||
exportAs: 'abpDefaultComponent', |
|||
}) |
|||
class DefaultComponent implements OnInit { |
|||
@Input() |
|||
oneWay; |
|||
|
|||
@Input() |
|||
twoWay: boolean; |
|||
|
|||
@Output() |
|||
readonly twoWayChange = new EventEmitter<boolean>(); |
|||
|
|||
@Output() |
|||
readonly someOutput = new EventEmitter<string>(); |
|||
|
|||
ngOnInit() {} |
|||
|
|||
setTwoWay(value) { |
|||
this.twoWay = value; |
|||
this.twoWayChange.emit(value); |
|||
} |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'abp-external-component', |
|||
template: ` |
|||
<p>external</p> |
|||
`,
|
|||
}) |
|||
class ExternalComponent { |
|||
constructor( |
|||
@Optional() |
|||
@Inject('REPLACEABLE_DATA') |
|||
public data: ReplaceableComponents.ReplaceableTemplateData<any, any>, |
|||
) {} |
|||
} |
|||
|
|||
describe('ReplaceableTemplateDirective', () => { |
|||
const selectResponse = new Subject(); |
|||
const mockSelect = jest.fn(() => selectResponse); |
|||
|
|||
let spectator: SpectatorDirective<ReplaceableTemplateDirective>; |
|||
const createDirective = createDirectiveFactory({ |
|||
directive: ReplaceableTemplateDirective, |
|||
providers: [{ provide: Store, useValue: { select: mockSelect } }], |
|||
declarations: [DefaultComponent, ExternalComponent], |
|||
entryComponents: [ExternalComponent], |
|||
}); |
|||
|
|||
describe('without external component', () => { |
|||
const twoWayChange = jest.fn(a => a); |
|||
const someOutput = jest.fn(a => a); |
|||
|
|||
beforeEach(() => { |
|||
spectator = createDirective( |
|||
` |
|||
<div *abpReplaceableTemplate="{inputs: {oneWay: {value: oneWay}, twoWay: {value: twoWay, twoWay: true}}, outputs: {twoWayChange: twoWayChange, someOutput: someOutput}, componentKey: 'TestModule.TestComponent'}; let initTemplate = initTemplate"> |
|||
<abp-default-component #defaultComponent="abpDefaultComponent"></abp-default-component> |
|||
</div> |
|||
`,
|
|||
{ hostProps: { oneWay: { label: 'Test' }, twoWay: false, twoWayChange, someOutput } }, |
|||
); |
|||
selectResponse.next(undefined); |
|||
const component = spectator.query(DefaultComponent); |
|||
spectator.directive.context.initTemplate(component); |
|||
spectator.detectChanges(); |
|||
}); |
|||
|
|||
afterEach(() => twoWayChange.mockClear()); |
|||
|
|||
it('should display the default template when store response is undefined', () => { |
|||
expect(spectator.query('abp-default-component')).toBeTruthy(); |
|||
}); |
|||
|
|||
it('should be setted inputs and outputs', () => { |
|||
const component = spectator.query(DefaultComponent); |
|||
expect(component.oneWay).toEqual({ label: 'Test' }); |
|||
expect(component.twoWay).toEqual(false); |
|||
}); |
|||
|
|||
it('should change the component inputs', () => { |
|||
const component = spectator.query(DefaultComponent); |
|||
spectator.setHostInput({ oneWay: 'test' }); |
|||
component.setTwoWay(true); |
|||
component.someOutput.emit('someOutput emitted'); |
|||
expect(component.oneWay).toBe('test'); |
|||
expect(twoWayChange).toHaveBeenCalledWith(true); |
|||
expect(someOutput).toHaveBeenCalledWith('someOutput emitted'); |
|||
}); |
|||
}); |
|||
|
|||
describe('with external component', () => { |
|||
const twoWayChange = jest.fn(a => a); |
|||
const someOutput = jest.fn(a => a); |
|||
|
|||
beforeEach(() => { |
|||
spectator = createDirective( |
|||
` |
|||
<div *abpReplaceableTemplate="{inputs: {oneWay: {value: oneWay}, twoWay: {value: twoWay, twoWay: true}}, outputs: {twoWayChange: twoWayChange, someOutput: someOutput}, componentKey: 'TestModule.TestComponent'}; let initTemplate = initTemplate"> |
|||
<abp-default-component #defaultComponent="abpDefaultComponent"></abp-default-component> |
|||
</div> |
|||
`,
|
|||
{ hostProps: { oneWay: { label: 'Test' }, twoWay: false, twoWayChange, someOutput } }, |
|||
); |
|||
selectResponse.next({ component: ExternalComponent, key: 'TestModule.TestComponent' }); |
|||
}); |
|||
|
|||
afterEach(() => twoWayChange.mockClear()); |
|||
|
|||
it('should display the external component', () => { |
|||
expect(spectator.query('p')).toHaveText('external'); |
|||
}); |
|||
|
|||
it('should be injected the data object', () => { |
|||
const externalComponent = spectator.query(ExternalComponent); |
|||
expect(externalComponent.data).toEqual({ |
|||
componentKey: 'TestModule.TestComponent', |
|||
inputs: { oneWay: { label: 'Test' }, twoWay: false }, |
|||
outputs: { someOutput, twoWayChange }, |
|||
}); |
|||
}); |
|||
|
|||
it('should be worked all data properties', () => { |
|||
const externalComponent = spectator.query(ExternalComponent); |
|||
spectator.setHostInput({ oneWay: 'test' }); |
|||
externalComponent.data.inputs.twoWay = true; |
|||
externalComponent.data.outputs.someOutput('someOutput emitted'); |
|||
expect(externalComponent.data.inputs.oneWay).toBe('test'); |
|||
expect(twoWayChange).toHaveBeenCalledWith(true); |
|||
expect(someOutput).toHaveBeenCalledWith('someOutput emitted'); |
|||
|
|||
spectator.setHostInput({ twoWay: 'twoWay test' }); |
|||
expect(externalComponent.data.inputs.twoWay).toBe('twoWay test'); |
|||
}); |
|||
|
|||
it('should be worked correctly the default component when the external component has been removed from store', () => { |
|||
expect(spectator.query('p')).toHaveText('external'); |
|||
const externalComponent = spectator.query(ExternalComponent); |
|||
spectator.setHostInput({ oneWay: 'test' }); |
|||
externalComponent.data.inputs.twoWay = true; |
|||
selectResponse.next({ component: null, key: 'TestModule.TestComponent' }); |
|||
spectator.detectChanges(); |
|||
const component = spectator.query(DefaultComponent); |
|||
spectator.directive.context.initTemplate(component); |
|||
expect(spectator.query('abp-default-component')).toBeTruthy(); |
|||
|
|||
expect(component.oneWay).toEqual('test'); |
|||
expect(component.twoWay).toEqual(true); |
|||
}); |
|||
|
|||
it('should reset default component subscriptions', () => { |
|||
selectResponse.next({ component: null, key: 'TestModule.TestComponent' }); |
|||
const component = spectator.query(DefaultComponent); |
|||
spectator.directive.context.initTemplate(component); |
|||
spectator.detectChanges(); |
|||
const unsubscribe = jest.fn(() => {}); |
|||
spectator.directive.defaultComponentSubscriptions.twoWayChange.unsubscribe = unsubscribe; |
|||
|
|||
selectResponse.next({ component: ExternalComponent, key: 'TestModule.TestComponent' }); |
|||
expect(unsubscribe).toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
}); |
|||
File diff suppressed because it is too large
Loading…
Reference in new issue