Skip to content

Commit

Permalink
feat(forms): support for dynamic control change
Browse files Browse the repository at this point in the history
  • Loading branch information
dtsanevmw committed Nov 24, 2023
1 parent 350110e commit d707ddf
Show file tree
Hide file tree
Showing 8 changed files with 292 additions and 5 deletions.
12 changes: 11 additions & 1 deletion projects/angular/clarity.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ export class CdsIconCustomTag {
static ɵfac: i0.ɵɵFactoryDeclaration<CdsIconCustomTag, never>;
}

// @public (undocumented)
export enum CHANGES_KEYS {
// (undocumented)
FORM = "form",
// (undocumented)
MODEL = "model"
}

// @public (undocumented)
export class ClarityModule {
// (undocumented)
Expand Down Expand Up @@ -4826,7 +4834,7 @@ export const TOGGLE_SERVICE_PROVIDER: {
export function ToggleServiceFactory(): BehaviorSubject<boolean>;

// @public (undocumented)
export class WrappedFormControl<W extends DynamicWrapper> implements OnInit, OnDestroy {
export class WrappedFormControl<W extends DynamicWrapper> implements OnInit, OnDestroy, DoCheck {
constructor(vcr: ViewContainerRef, wrapperType: Type<W>, injector: Injector, ngControl: NgControl, renderer: Renderer2, el: ElementRef);
// (undocumented)
protected controlIdService: ControlIdService;
Expand All @@ -4844,6 +4852,8 @@ export class WrappedFormControl<W extends DynamicWrapper> implements OnInit, OnD
// (undocumented)
protected ngControlService: NgControlService;
// (undocumented)
ngDoCheck(): void;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
Expand Down
69 changes: 66 additions & 3 deletions projects/angular/src/forms/common/wrapped-control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { Component, Directive, ElementRef, Injector, NgModule, Renderer2, Type, ViewContainerRef } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { FormsModule, NgControl } from '@angular/forms';
import { FormControl, FormGroup, FormsModule, NgControl, ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';

import { DynamicWrapper } from '../../utils/host-wrapping/dynamic-wrapper';
Expand All @@ -15,7 +15,7 @@ import { ClrHostWrappingModule } from '../../utils/host-wrapping/host-wrapping.m
import { ClrAbstractContainer } from './abstract-container';
import { ClrControlError } from './error';
import { ClrControlHelper } from './helper';
import { IfControlStateService } from './if-control-state/if-control-state.service';
import { CONTROL_STATE, IfControlStateService } from './if-control-state/if-control-state.service';
import { ControlClassService } from './providers/control-class.service';
import { ControlIdService } from './providers/control-id.service';
import { LayoutService } from './providers/layout.service';
Expand Down Expand Up @@ -193,6 +193,46 @@ class WithControlAndSuccess {
model = '';
}

@Component({
template: `
<form-wrapper>
<test-wrapper3>
<label> Label </label>
<textarea testControl3 [formControl]="form.get('control') || form.get('alternative')"></textarea>
<clr-control-success>Successful!</clr-control-success>
</test-wrapper3>
<form-wrapper> </form-wrapper
></form-wrapper>
`,
})
class WithDynamicFormControl {
form = new FormGroup<any>({
alternative: new FormControl(),
});

addControl() {
this.form.addControl('control', new FormControl('TEST'));
}
}
@Component({
template: `
<form-wrapper>
<test-wrapper3>
<label> Label </label>
<textarea testControl3 [(ngModel)]="object['control']"></textarea>
<clr-control-success>Successful!</clr-control-success>
</test-wrapper3>
<form-wrapper> </form-wrapper
></form-wrapper>
`,
})
class WithDynamicNgControl {
object = {};
addControl() {
this.object['control'] = 'TEST';
}
}

interface TestContext {
fixture: ComponentFixture<any>;
wrapper: TestWrapper;
Expand All @@ -210,7 +250,7 @@ export default function (): void {
describe('WrappedFormControl', () => {
function setupTest<T>(testContext: TestContext, testComponent: Type<T>, testControl: any) {
TestBed.configureTestingModule({
imports: [WrappedFormControlTestModule, FormsModule],
imports: [WrappedFormControlTestModule, FormsModule, ReactiveFormsModule],
declarations: [testComponent, ClrControlError, ClrControlHelper, ClrControlSuccess],
});
testContext.fixture = TestBed.createComponent(testComponent);
Expand Down Expand Up @@ -339,6 +379,29 @@ export default function (): void {
});
});

describe('with dynamic controls', function () {
it('with form-control directive', function (this: TestContext) {
setupTest(this, WithDynamicFormControl, TestControl3);
const cb = jasmine.createSpy('cb');
const sub = this.ifControlStateService.statusChanges.subscribe((control: CONTROL_STATE) => cb(control));
expect(cb).toHaveBeenCalledWith(CONTROL_STATE.NONE);
this.fixture.componentInstance.addControl();
this.fixture.detectChanges();
expect(cb).toHaveBeenCalledWith(CONTROL_STATE.VALID);
sub.unsubscribe();
});
it('with ng-control directive', function (this: TestContext) {
setupTest(this, WithDynamicNgControl, TestControl3);
const cb = jasmine.createSpy('cb');
const sub = this.ifControlStateService.statusChanges.subscribe((control: CONTROL_STATE) => cb(control));
expect(cb).toHaveBeenCalledWith(CONTROL_STATE.NONE);
this.fixture.componentInstance.addControl();
this.fixture.detectChanges();
expect(cb).toHaveBeenCalledWith(CONTROL_STATE.VALID);
sub.unsubscribe();
});
});

describe('aria roles', function () {
it('adds the aria-describedby for helper', function () {
setupTest(this, WithControlAndHelper, TestControl3);
Expand Down
33 changes: 32 additions & 1 deletion projects/angular/src/forms/common/wrapped-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

import {
Directive,
DoCheck,
ElementRef,
HostBinding,
HostListener,
InjectionToken,
Injector,
Input,
KeyValueDiffer,
KeyValueDiffers,
OnDestroy,
OnInit,
Renderer2,
Expand All @@ -31,8 +34,13 @@ import { ControlIdService } from './providers/control-id.service';
import { MarkControlService } from './providers/mark-control.service';
import { Helpers, NgControlService } from './providers/ng-control.service';

export enum CHANGES_KEYS {
FORM = 'form',
MODEL = 'model',
}

@Directive()
export class WrappedFormControl<W extends DynamicWrapper> implements OnInit, OnDestroy {
export class WrappedFormControl<W extends DynamicWrapper> implements OnInit, OnDestroy, DoCheck {
_id: string;

protected renderer: Renderer2;
Expand All @@ -47,6 +55,8 @@ export class WrappedFormControl<W extends DynamicWrapper> implements OnInit, OnD
private markControlService: MarkControlService;
private containerIdService: ContainerIdService;
private _containerInjector: Injector;
private differs: KeyValueDiffers;
private differ: KeyValueDiffer<any, any>;

// I lost way too much time trying to make this work without injecting the ViewContainerRef and the Injector,
// I'm giving up. So we have to inject these two manually for now.
Expand All @@ -66,6 +76,7 @@ export class WrappedFormControl<W extends DynamicWrapper> implements OnInit, OnD
this.ifControlStateService = injector.get(IfControlStateService, null);
this.controlClassService = injector.get(ControlClassService, null);
this.markControlService = injector.get(MarkControlService, null);
this.differs = injector.get(KeyValueDiffers, null);
}

if (this.controlClassService) {
Expand All @@ -86,6 +97,10 @@ export class WrappedFormControl<W extends DynamicWrapper> implements OnInit, OnD
})
);
}

if (ngControl) {
this.differ = this.differs.find(ngControl).create();
}
}

@Input()
Expand All @@ -100,6 +115,22 @@ export class WrappedFormControl<W extends DynamicWrapper> implements OnInit, OnD
}
}

ngDoCheck() {
if (this.differ) {
const changes = this.differ.diff(this.ngControl);
if (changes) {
changes.forEachChangedItem(change => {
if (
(change.key === CHANGES_KEYS.FORM || change.key === CHANGES_KEYS.MODEL) &&
change.currentValue !== change.previousValue
) {
this.triggerValidation();
}
});
}
}
}

ngOnInit() {
this._containerInjector = new HostWrapper(this.wrapperType, this.vcr, this.index);
this.controlIdService = this._containerInjector.get(ControlIdService);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<h1>Reproduce Step</h1>
<ol>
<li>Select ECS, Public signed radio is automatically selected. Click Signature (empty textarea).</li>
<li style="color: red">
When switching platform dropdown to Cloudian, signature textarea has value, but its successful state is not updated.
clr-success not show
</li>
</ol>

<form clrForm [formGroup]="form">
<clr-select-container>
<label>{{ 'CHOOSE_PLATFORM' }}</label>
<select class="w-60" clrSelect formControlName="platform">
<option value="CLOUDIAN">CLOUDIAN</option>
<option value="ECS">ECS</option>
</select>
<clr-control-error> {{ 'REQUIRED' }} </clr-control-error>
</clr-select-container>

<clr-input-container>
<label>{{ 'S3_URL' }}</label>
<input clrInput type="text" [formControl]="$any(form)?.get('s3')?.get('url')" />
<clr-control-error>{{ 'INVALID_HTTPS_DESC' }}</clr-control-error>
</clr-input-container>

<clr-radio-container clrInline>
<label>{{ 'CERTIFICATE_VALIDATION' }}</label>
<clr-radio-wrapper>
<input type="radio" clrRadio [name]="'s3-cert-validate'" value="tlsSignature" [formControl]="certValidation" />
<label>{{ 'SIGNATURE' }}</label>
</clr-radio-wrapper>
<clr-radio-wrapper>
<input type="radio" clrRadio [name]="'s3-cert-validate'" value="publicSigned" [formControl]="certValidation" />
<label>{{ 'PUBLIC_SIGNED' }}</label>
</clr-radio-wrapper>
<clr-control-error> {{ 'REQUIRED' }} </clr-control-error>
</clr-radio-container>

<clr-textarea-container *ngIf="certValidation.value === 'tlsSignature'">
<label>{{ 'SIGNATURE' }}</label>
<textarea clrTextarea [formControl]="$any(form).get('s3').get('tlsSignature')"></textarea>
<clr-control-helper>{{ 'SIGNATURE_DESC' }}</clr-control-helper>
<clr-control-success>I should show when successful!!!</clr-control-success>
<clr-control-error> {{ 'REQUIRED' }} </clr-control-error>
</clr-textarea-container>
</form>
131 changes: 131 additions & 0 deletions projects/demo/src/app/forms/dynamic-controls/dynamic-controls.demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2016-2023 VMware, Inc. All Rights Reserved.
* This software is released under MIT license.
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { Component, inject } from '@angular/core';
import { FormControl, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';

@Component({
selector: 'dynamic-controls-demo',
templateUrl: './dynamic-controls.demo.html',
})
export class DynamicControlsDemo {
// radio control
readonly certValidation = new FormControl<string | null>(null, Validators.required);

fb = inject(UntypedFormBuilder);

form: UntypedFormGroup = this.fb.group({
platform: new FormControl<string | null>(null, [Validators.required]),
});

ngOnInit() {
this.registerPlatformChange();

this.registerCertValidateChange();
}

private registerPlatformChange() {
this.form.controls.platform.valueChanges.subscribe(value => {
this.form.removeControl('s3');
this.form.removeControl('iam');
this.form.removeControl('console');

switch (value) {
case 'CLOUDIAN':
this.addControlsForCloudian();
break;
case 'ECS':
this.addControlsForECS();
break;
default:
break;
}

this.setCertValidationValue();
});
}

private addControlsForCloudian() {
this.addS3Control('cloudian signature');
this.addIAMControl();
}

private addControlsForECS() {
this.addS3Control();
this.addConsoleControl();
}

private addS3Control(signature?: string) {
this.form.addControl(
's3',
this.fb.group({
url: this.fb.control('', [Validators.required]),
tlsSignature: this.fb.control(
{
value: signature,
disabled: true,
},
Validators.required
),
})
);
}

private addIAMControl() {
this.form.addControl(
'iam',
this.fb.group({
url: this.fb.control('', [Validators.required]),
tlsSignature: this.fb.control(
{
value: '',
disabled: true,
},
Validators.required
),
})
);
}

private addConsoleControl() {
this.form.addControl(
'console',
this.fb.group({
url: ['', [Validators.required]],
secretKey: ['', [Validators.required]],
})
);
}

// set radio based on form value
private setCertValidationValue() {
const signatureControl = this.form.controls['s3'].get('tlsSignature') as FormControl;

const value = signatureControl.value ? 'tlsSignature' : 'publicSigned';

this.certValidation.setValue(value);
}

private registerCertValidateChange() {
this.certValidation.valueChanges.subscribe(value => {
const tlsSignature = this.form.controls['s3'].get('tlsSignature') as FormControl;

if (value === 'tlsSignature') {
tlsSignature.enable({ emitEvent: false });
} else {
// public signed
tlsSignature.disable({ emitEvent: false });
}

setTimeout(() => {
if (tlsSignature.value) {
tlsSignature.markAsTouched();
tlsSignature.updateValueAndValidity();
}
});
});
}
}

0 comments on commit d707ddf

Please sign in to comment.