diff --git a/README.md b/README.md
index cae9171..8667e45 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
-# ngx-context (a.k.a. Angular Context)
+# Angular Context (ngx-context)
+
@@ -7,21 +8,22 @@
+
Angular Context is a library to bind data to deeply nested child components **without passing properties through other components or getting blocked by a router outlet**.
If you would like to have further information on why you need a library like this, you may find the [reasons](#reasons-to-use-this-library) below. Otherwise, skip to the [quickstart](#quickstart) or [usage](#usage) section.
-Check [sample application](https://stackblitz.com/edit/ngx-context) out for a preview.
+Check [demo application](https://stackblitz.com/edit/ngx-context) out for a preview.
## Reasons to Use This Library
Data-binding and input properties are great. However, working with them has some challenges:
-- Passing properties through several layers of the component tree is known as prop-drilling and it is time consuming and difficult and error prone to maintain.
+- Passing properties through several layers of the component tree is known as prop-drilling and it is time consuming and error prone to maintain.
+- The intermediary components become bloated with properties and methods just to pass data from parent to child and vice versa.
- When a component is loaded via `router-outlet`, data-binding is not available and prop-drilling is no longer an option.
-- Providing data through state management has its own caveat: Since connecting presentational (dumb) components directly to a specific state breaks their reusability, they have to be wrapped by container (smart) components instead and that usually is additional work.
-This library is designed to improve developer experience by fixing all issues above. It provides context through dependency injection system behind-the-scenes and lets your deeply nested dumb components consume this context easily. It is conceptually influenced by [React Context](https://reactjs.org/docs/context.html), but differs in implementation and is 100% tailored for Angular.
+This library is designed to improve developer experience by fixing all issues above. It provides context through dependency injection system behind-the-scenes and lets your deeply nested dumb components consume this context easily. It is inspired by [React Context](https://reactjs.org/docs/context.html), but differs in implementation and is 100% tailored for Angular.
![](./assets/context.svg)
@@ -272,6 +274,78 @@ Consumed property names can be mapped.
```
+### ContextDisposerDirective
+
+There are some cases where you will need the context on a higher level and end up putting properties on a middle component's class. For example, in order to make [reactive forms](https://angular.io/guide/reactive-forms) work, a `ContextConsumerComponent` will most likely be used and the consumed properties will have to be added to the wrapper component. This is usually not the preferred result. After all, we are trying to keep intermediary components as clean as possible. In such a case, you can use `ContextDisposerDirective` on an `` and make use of [template input variables](https://angular.io/guide/structural-directives#template-input-variable).
+
+```HTML
+
+
+
+
+
+
+```
+
+The name of specific props to be disposed can be set by `contextDisposer` input and it can take `string` or `Array` values.
+
+```HTML
+
+
+
+
+
+
+```
+
+— or —
+
+```HTML
+
+
+
+
+
+
+```
+
+Properties to dispose can be dynamically set.
+
+```HTML
+
+
+
+
+
+
+```
+
+Disposed property names can be individually assigned to template input variables.
+
+```HTML
+
+
+
+
+
+
+```
+
+Note: If you are wondering how you can implement reactive forms using Angular Context, please refer to the [demo application](https://stackblitz.com/edit/ngx-context).
+
## Caveats / Trade-offs
There are several issues which are simply not addressed yet or impossible with currently available tools.
@@ -287,6 +361,8 @@ There are several issues which are simply not addressed yet or impossible with c
- [x] Component and directive to consume context
+- [x] Directive to dispose context
+
- [x] Test coverage
- [x] Documentation & examples
@@ -302,3 +378,5 @@ There are several issues which are simply not addressed yet or impossible with c
- [x] CI integrations
- [ ] Benchmarks
+
+- [ ] Optimization
diff --git a/package.json b/package.json
index a74227b..88d4841 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ngx-context",
- "version": "1.0.0",
+ "version": "1.1.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
diff --git a/src/lib/consumer.abstract.ts b/src/lib/consumer.abstract.ts
index 328cbc1..6e978c5 100644
--- a/src/lib/consumer.abstract.ts
+++ b/src/lib/consumer.abstract.ts
@@ -1,6 +1,6 @@
import { ChangeDetectorRef, Input, OnChanges, OnDestroy, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
-import { takeUntil } from 'rxjs/operators';
+import { filter, takeUntil } from 'rxjs/operators';
import { parseKeys } from './internals';
import { ContextProviderComponent } from './provider.component';
import { ContextMap } from './symbols';
@@ -46,7 +46,10 @@ export abstract class AbstractContextConsumer implements OnChanges, OnDestroy
if (this.provider.provide.length)
this.provider.change$
- .pipe(takeUntil(this.destroy$))
+ .pipe(
+ takeUntil(this.destroy$),
+ filter(key => !!key),
+ )
.subscribe(providerKey => this.syncProperties(consumed, providerKey));
}
diff --git a/src/lib/context.module.ts b/src/lib/context.module.ts
index 548c887..7f76c2b 100644
--- a/src/lib/context.module.ts
+++ b/src/lib/context.module.ts
@@ -1,17 +1,20 @@
import { NgModule } from '@angular/core';
import { ContextConsumerComponent } from './consumer.component';
import { ContextConsumerDirective } from './consumer.directive';
+import { ContextDisposerDirective } from './disposer.directive';
import { ContextProviderComponent } from './provider.component';
@NgModule({
declarations: [
ContextConsumerComponent,
ContextConsumerDirective,
+ ContextDisposerDirective,
ContextProviderComponent,
],
exports: [
ContextConsumerComponent,
ContextConsumerDirective,
+ ContextDisposerDirective,
ContextProviderComponent,
],
})
diff --git a/src/lib/disposer.directive.ts b/src/lib/disposer.directive.ts
new file mode 100644
index 0000000..8d822ee
--- /dev/null
+++ b/src/lib/disposer.directive.ts
@@ -0,0 +1,89 @@
+import {
+ Directive,
+ EmbeddedViewRef,
+ Input,
+ Optional,
+ SkipSelf,
+ TemplateRef,
+ ViewContainerRef,
+} from '@angular/core';
+import { Subject } from 'rxjs';
+import { filter, takeUntil } from 'rxjs/operators';
+import { parseKeys } from './internals';
+import { ContextProviderComponent } from './provider.component';
+
+@Directive({
+ selector: '[contextDisposer]',
+})
+export class ContextDisposerDirective {
+ private destroy$ = new Subject();
+ private _dispose: string | string[] = '';
+ private view: EmbeddedViewRef;
+
+ @Input('contextDisposer')
+ set dispose(dispose: string | string[]) {
+ this._dispose = dispose || '';
+ }
+ get dispose(): string | string[] {
+ return this._dispose;
+ }
+
+ constructor(
+ @Optional()
+ private tempRef: TemplateRef,
+ @Optional()
+ private vcRef: ViewContainerRef,
+ @Optional()
+ @SkipSelf()
+ private provider: ContextProviderComponent,
+ ) {}
+
+ private init(): void {
+ const disposed: string[] = parseKeys(this.dispose);
+
+ this.provider.reset$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.ngOnChanges());
+
+ if (this.provider.provide.length)
+ this.provider.change$
+ .pipe(
+ takeUntil(this.destroy$),
+ filter(key => !!key),
+ )
+ .subscribe(providerKey => this.syncProperties(disposed, providerKey));
+ }
+
+ private reset(): void {
+ this.view = this.vcRef.createEmbeddedView(this.tempRef, new Context());
+ }
+
+ private syncProperties(disposed: string[], providerKey: string): void {
+ const key = this.provider.contextMap[providerKey] || providerKey;
+
+ if (disposed.length && disposed.indexOf(key) < 0) return;
+
+ const value = this.provider.component[providerKey];
+
+ this.view.context.$implicit[key] = value;
+ this.view.context[key] = value;
+ this.view.markForCheck();
+ }
+
+ ngOnChanges() {
+ this.ngOnDestroy();
+ this.reset();
+
+ if (this.provider && this.tempRef && this.vcRef) this.init();
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+
+ if (this.view) this.vcRef.clear();
+ }
+}
+
+export class Context {
+ $implicit: { [key: string]: any } = {};
+}
diff --git a/src/package.json b/src/package.json
index 6f8f4a8..7dccc33 100644
--- a/src/package.json
+++ b/src/package.json
@@ -1,6 +1,6 @@
{
"name": "ngx-context",
- "version": "1.0.0",
+ "version": "1.1.0",
"repository": {
"type": "git",
"url": "git+https://github.com/ng-turkey/ngx-context.git"
@@ -18,9 +18,11 @@
"prop-drilling",
"data-binding",
"property binding",
+ "event binding",
"utility",
"provider",
"consumer",
+ "disposer",
"router-outlet"
],
"peerDependencies": {
diff --git a/src/public_api.ts b/src/public_api.ts
index 655e445..9a686ff 100644
--- a/src/public_api.ts
+++ b/src/public_api.ts
@@ -1,5 +1,6 @@
export { NgxContextModule } from './lib/context.module';
export { ContextConsumerComponent } from './lib/consumer.component';
export { ContextConsumerDirective } from './lib/consumer.directive';
+export { ContextDisposerDirective } from './lib/disposer.directive';
export { ContextProviderComponent } from './lib/provider.component';
export { ContextMap } from './lib/symbols';
diff --git a/src/tests/disposer.directive.spec.ts b/src/tests/disposer.directive.spec.ts
new file mode 100644
index 0000000..9e81499
--- /dev/null
+++ b/src/tests/disposer.directive.spec.ts
@@ -0,0 +1,147 @@
+import { ChangeDetectorRef } from '@angular/core';
+import {
+ ComponentFixture,
+ fakeAsync,
+ inject,
+ TestBed,
+ tick,
+} from '@angular/core/testing';
+import { ContextDisposerDirective } from '../lib/disposer.directive';
+import { ContextProviderComponent } from '../lib/provider.component';
+import { TestDisposerComponent } from './test-disposer.component';
+import { TestProviderComponent } from './test-provider.component';
+
+export interface UDisposerDirective {
+ disposer: ContextDisposerDirective;
+ element: HTMLElement;
+ fixture: ComponentFixture;
+ provider: ContextProviderComponent;
+}
+
+describe('ContextDisposerDirective', function(this: UDisposerDirective) {
+ describe('implicitly', () => {
+ beforeEach(() => {
+ this.provider = new ContextProviderComponent(({
+ _view: { component: new TestProviderComponent() },
+ } as any) as ChangeDetectorRef);
+
+ TestBed.overrideComponent(TestDisposerComponent, {
+ set: {
+ template: `
+
+ {{ context.target }}
+
+ `,
+ },
+ });
+
+ TestBed.configureTestingModule({
+ declarations: [ContextDisposerDirective, TestDisposerComponent],
+ }).compileComponents();
+
+ this.fixture = TestBed.createComponent(TestDisposerComponent);
+ this.element = this.fixture.nativeElement;
+ this.disposer = this.fixture.componentInstance.disposer;
+ this.disposer['provider'] = this.provider as any;
+ });
+
+ it('should be created', () => {
+ expect(this.disposer).not.toBeUndefined();
+ });
+
+ it('should have empty string as dispose', () => {
+ expect(this.disposer.dispose).toBe('');
+ });
+
+ it('should call ngOnChanges on init', () => {
+ spyOn(this.disposer, 'ngOnChanges');
+ this.fixture.detectChanges();
+
+ expect(this.disposer.ngOnChanges).toHaveBeenCalledTimes(1); // because, let-context
+ });
+
+ it('should call ngOnChanges on reset$', () => {
+ this.fixture.detectChanges();
+
+ spyOn(this.disposer, 'ngOnChanges');
+ this.provider.reset$.next();
+
+ expect(this.disposer.ngOnChanges).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call ngOnChanges on reset$ if provider not found', () => {
+ this.disposer['provider'] = null;
+ this.fixture.detectChanges();
+
+ spyOn(this.disposer, 'ngOnChanges');
+ this.provider.reset$.next();
+
+ expect(this.disposer.ngOnChanges).toHaveBeenCalledTimes(0);
+ });
+
+ it('should dispose property when provided', () => {
+ const prop: keyof TestProviderComponent = 'target';
+
+ this.provider.provide = prop;
+ this.provider.reset$.next();
+ this.provider.change$.next(prop);
+
+ this.fixture.detectChanges();
+
+ expect(this.element.innerText).toBe(this.provider.component[prop]);
+ });
+ });
+
+ describe('explicitly', () => {
+ beforeEach(() => {
+ this.provider = new ContextProviderComponent(({
+ _view: { component: new TestProviderComponent() },
+ } as any) as ChangeDetectorRef);
+
+ TestBed.overrideComponent(TestDisposerComponent, {
+ set: {
+ template: `
+
+ {{ target }}
+
+ `,
+ },
+ });
+
+ TestBed.configureTestingModule({
+ declarations: [ContextDisposerDirective, TestDisposerComponent],
+ }).compileComponents();
+
+ this.fixture = TestBed.createComponent(TestDisposerComponent);
+ this.element = this.fixture.nativeElement;
+ this.disposer = this.fixture.componentInstance.disposer;
+ this.disposer['provider'] = this.provider as any;
+ });
+
+ it('should dispose property when provided', () => {
+ const prop: keyof TestProviderComponent = 'target';
+
+ this.provider.provide = prop;
+ this.provider.reset$.next();
+ this.provider.change$.next(prop);
+
+ this.fixture.detectChanges();
+
+ expect(this.element.innerText).toBe(this.provider.component[prop]);
+ });
+
+ it('should not dispose when provided property is not disposed', () => {
+ this.fixture.componentInstance.provided = 'test';
+
+ const prop: keyof TestProviderComponent = 'target';
+
+ this.provider.provide = prop;
+ this.provider.reset$.next();
+ this.provider.change$.next(prop);
+
+ this.fixture.detectChanges();
+
+ expect(this.element.innerText).toBe('');
+ });
+ });
+});
diff --git a/src/tests/integration.spec.ts b/src/tests/integration.spec.ts
index 91a1fe8..42da800 100644
--- a/src/tests/integration.spec.ts
+++ b/src/tests/integration.spec.ts
@@ -1,27 +1,72 @@
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { FormControlDirective, ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { ContextConsumerDirective } from '../lib/consumer.directive';
import { NgxContextModule } from '../lib/context.module';
-import { ContextProviderComponent } from '../lib/provider.component';
+import { ContextDisposerDirective } from '../lib/disposer.directive';
import { TestConsumerComponent } from './test-consumer.component';
+import { TestDisposerComponent } from './test-disposer.component';
+import { TestFormComponent } from './test-form.component';
import { TestMiddleComponent } from './test-middle.component';
import { TestProviderComponent } from './test-provider.component';
-type TConsumer = ContextConsumerDirective;
-type TProvider = ContextProviderComponent;
+interface IContextDisposer {
+ fixture: ComponentFixture;
+ parent: TestFormComponent;
+ disposer: ContextDisposerDirective;
+ child: HTMLInputElement;
+}
+
+describe('Context Provider & Disposer', function(this: IContextDisposer) {
+ it('should work with reactive forms', fakeAsync(() => {
+ TestBed.overrideComponent(TestDisposerComponent, {
+ set: {
+ template: `
+
+
+
+ `,
+ },
+ });
+
+ TestBed.configureTestingModule({
+ imports: [NgxContextModule, ReactiveFormsModule],
+ declarations: [TestFormComponent, TestDisposerComponent],
+ }).compileComponents();
+
+ this.fixture = TestBed.createComponent(TestFormComponent);
+ this.fixture.detectChanges();
+
+ this.parent = this.fixture.debugElement.componentInstance;
+ const disposer = this.fixture.debugElement.query(By.directive(TestDisposerComponent));
+ this.disposer = disposer.componentInstance.disposer;
+
+ tick();
+ this.fixture.detectChanges();
+
+ this.child = disposer.query(By.directive(FormControlDirective)).nativeElement;
+
+ expect(this.child.checked).toBe(false);
+ expect(this.parent.checkControl.value).toBe(false);
+
+ this.child.click();
+ this.fixture.detectChanges();
+
+ expect(this.child.checked).toBe(true);
+ expect(this.parent.checkControl.value).toBe(true);
+ }));
+});
-interface IContext {
+interface IContextConsumer {
fixture: ComponentFixture;
- provider: TProvider;
- consumer: TConsumer;
parent: TestProviderComponent;
middle: TestMiddleComponent;
child: TestConsumerComponent;
}
-describe('Context Provider & Consumer', function(this: IContext) {
+describe('Context Provider & Consumer', function(this: IContextConsumer) {
it('should work through nested components', fakeAsync(() => {
TestBed.overrideComponent(TestProviderComponent, {
set: {
@@ -82,7 +127,7 @@ describe('Context Provider & Consumer', function(this: IContext) {
type Excluded = 'provided' | 'contextMap' | 'consume';
function shouldSyncProvidedProperty(
- this: IContext,
+ this: IContextConsumer,
prop: Exclude,
): void {
// Query component instances
diff --git a/src/tests/test-disposer.component.ts b/src/tests/test-disposer.component.ts
new file mode 100644
index 0000000..e89b42b
--- /dev/null
+++ b/src/tests/test-disposer.component.ts
@@ -0,0 +1,20 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ViewChild,
+ ViewEncapsulation,
+} from '@angular/core';
+import { ContextDisposerDirective } from '../lib/disposer.directive';
+
+@Component({
+ selector: 'context-test-disposer',
+ template: ``,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+})
+export class TestDisposerComponent {
+ @ViewChild(ContextDisposerDirective)
+ disposer: ContextDisposerDirective;
+
+ provided: string | string[] = '';
+}
diff --git a/src/tests/test-form.component.ts b/src/tests/test-form.component.ts
new file mode 100644
index 0000000..97581a2
--- /dev/null
+++ b/src/tests/test-form.component.ts
@@ -0,0 +1,28 @@
+import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
+import { AbstractControl, FormBuilder, FormGroup } from '@angular/forms';
+
+@Component({
+ selector: 'context-test-form',
+ template: `
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+})
+export class TestFormComponent {
+ form: FormGroup;
+
+ get checkControl(): AbstractControl {
+ return this.form.controls.check;
+ }
+
+ constructor(private fb: FormBuilder) {
+ this.form = this.fb.group({
+ check: [false],
+ });
+ }
+}