Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(forms): support for dynamic control change #1074

Merged
merged 8 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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>
dtsanevmw marked this conversation as resolved.
Show resolved Hide resolved
`,
})
class WithDynamicFormControl {
form = new FormGroup<any>({
alternative: new FormControl(),
});

addControl() {
this.form.addControl('control', new FormControl('TEST'));
}
}
@Component({
dtsanevmw marked this conversation as resolved.
Show resolved Hide resolved
dtsanevmw marked this conversation as resolved.
Show resolved Hide resolved
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>
dtsanevmw marked this conversation as resolved.
Show resolved Hide resolved
`,
})
class WithDynamicNgControl {
object = {};
dtsanevmw marked this conversation as resolved.
Show resolved Hide resolved
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) {
dtsanevmw marked this conversation as resolved.
Show resolved Hide resolved
dtsanevmw marked this conversation as resolved.
Show resolved Hide resolved
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() {
kevinbuhmann marked this conversation as resolved.
Show resolved Hide resolved
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 => {
dtsanevmw marked this conversation as resolved.
Show resolved Hide resolved
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 => {
dtsanevmw marked this conversation as resolved.
Show resolved Hide resolved
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();
}
});
});
}
}