Skip to content

feat(radio-value-accessor): adds radio-value-accessor directive #22

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

Merged
merged 1 commit into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/www/src/app/library-components/directives/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './radio-control-accessor';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './radio-value-accessor.directive';
export * from './radio-control-registry.service';
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {RadioControlRegistry} from "./radio-control-registry.service";
import {TestBed} from "@angular/core/testing";
import {ControlValueAccessor} from "@angular/forms";
import {noop} from "rxjs";

class ControlValueAccessSpec implements ControlValueAccessor {

onChange = noop;
onTouched = noop;

registerOnChange = jest.fn();

registerOnTouched = jest.fn();

writeValue = jest.fn();

setDisabledState = jest.fn();
}

const name = 'radio-control';

describe('Radio Control Registry', () => {

let registry: RadioControlRegistry;
let control1: ControlValueAccessSpec;
let control2: ControlValueAccessSpec;
let control3: ControlValueAccessSpec;

beforeEach(() => {
registry = TestBed.get(RadioControlRegistry);
control1 = new ControlValueAccessSpec();
control2 = new ControlValueAccessSpec();
control3 = new ControlValueAccessSpec();
});

it('should register "controls"', () => {
registry.add(name, control1);
registry.add(name, control2);
registry.add(name, control3);
expect(registry['accessors'].length).toBe(3);
});

it('should remove "controls"', () => {
registry.add(name, control1);
registry.add(name, control2);
registry.remove(control1);
expect(registry['accessors'].length).toBe(1);
});

it("should set the value of all controls matching `name`", () => {
const value = 'Hello World';
registry.add(name, control1);
registry.add(name, control2);
registry.add(`${name}-2`, control3);

registry.setValue(name, value)
expect(control1.writeValue).toHaveBeenCalledWith(value)
expect(control2.writeValue).toHaveBeenCalledWith(value)
expect(control3.writeValue).not.toHaveBeenCalled();
});

})
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {Injectable} from "@angular/core";
import type {ControlValueAccessor} from "@angular/forms";

@Injectable({providedIn: 'root'})
export class RadioControlRegistry {
private accessors: Array<[string, ControlValueAccessor]> = [];

add(name: string, accessor: ControlValueAccessor) {
this.accessors.push([name, accessor]);
}

remove(accessor: ControlValueAccessor) {
const index = this.accessors.findIndex(([, _accessor]) => _accessor === accessor);
if (index > -1) {
this.accessors.splice(index, 1);
}
}

setValue(name: string, value: unknown) {
this.accessors
.filter(([_name]) => _name === name)
.forEach(([, _accessor]) => _accessor.writeValue(value));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {Component, DebugElement, HostBinding, HostListener, Input} from "@angular/core";
import {RadioValueAccessorDirective} from "./radio-value-accessor.directive";
import {CheckboxControlValueAccessor, FormControl, ReactiveFormsModule} from "@angular/forms";
import {fireEvent, render, RenderResult} from "@testing-library/angular";

const selectors = {
one: 'control-one',
two: 'control-two',
three: 'control-three',
}

@Component({
selector: 'app-rva',
template: '',
standalone: true,
hostDirectives: [{
directive: RadioValueAccessorDirective,
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['name', 'value'],
}],
})
export class RadioValueAccessorSpecComponent {

@Input() name!: string;
@Input() value!: string;

@HostBinding('attr.data-testid')
get hostAttrTestId() {
return `${this.name}-${this.value}`;
}

@HostBinding('attr.data-selected')
get hostAttrSelected() {
return this.rva.selected;
}


@HostBinding('attr.data-disabled')
get hostAttrDisabled() {
return this.rva.disabled;
}

@HostBinding('attr.data-value')
get hostAttrValue() {
return this.value;
}

@HostListener('click')
onClick() {
this.rva.select();
}

constructor(
private rva: RadioValueAccessorDirective,
) {
}

}


describe('Radio Value Accessor Directive', () => {

it('should throw an error if name is not provided', async () => {
const instance = await render(`<app-rva value="one"/>`, {
imports: [RadioValueAccessorSpecComponent],
detectChangesOnRender: false
});
expect(instance.detectChanges).toThrow()
});

it('should throw an error if value is not provided', async () => {
const instance = await render(`<app-rva name="one"/>`, {
imports: [RadioValueAccessorSpecComponent],
detectChangesOnRender: false
});
expect(instance.detectChanges).toThrow()
});

describe('Control Value Accessor', () => {

let result: RenderResult<{ control: FormControl }>;
let control1: HTMLElement;
let control2: HTMLElement;

beforeEach(async () => {
result = await render(`
<app-rva name="control" value="one" [formControl]="control"/>
<app-rva name="control" value="two" [formControl]="control"/>
<app-rva name="control" value="three" [formControl]="control"/>
`,
{
imports: [ReactiveFormsModule, RadioValueAccessorSpecComponent],
componentProperties: {control: new FormControl<string>('')}
});

control1 = result.getByTestId(selectors.one);
control2 = result.getByTestId(selectors.two);
});

it('should not select a control without a matching value', () => {
expect(control1).toHaveAttribute('data-selected', 'false');
expect(control2).toHaveAttribute('data-selected', 'false');
});

it('should only select the interacted control', () => {
fireEvent.click(control1);
expect(control1).toHaveAttribute('data-selected', 'true');
expect(control2).toHaveAttribute('data-selected', 'false');
});

it('should only allow a single control to be selected', () => {
fireEvent.click(control1);
fireEvent.click(control2);
expect(control1).toHaveAttribute('data-selected', 'false');
expect(control2).toHaveAttribute('data-selected', 'true');
});

it('should set the NG_CONTROL value to the selected value', () => {
fireEvent.click(control2);
const value = control2.getAttribute('data-value');
expect(result.fixture.componentInstance.control.value).toBe(value);
});

it('should disable all controls that share the NG_CONTROL', () => {
result.fixture.componentInstance.control.disable();
result.detectChanges();
expect(control1).toHaveAttribute('data-disabled', 'true');
expect(control2).toHaveAttribute('data-disabled', 'true');
});

});

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {Directive, Input, OnDestroy, OnInit} from "@angular/core";
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from "@angular/forms";
import {noop} from "rxjs";
import {RadioControlRegistry} from "./index";


@Directive({
selector: '[appRadioValueAccessor]',
standalone: true,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: RadioValueAccessorDirective,
multi: true
}]
})
export class RadioValueAccessorDirective implements OnInit, OnDestroy, ControlValueAccessor {

@Input() name!: string;
@Input() value: unknown;

selected = false;
disabled = false

constructor(
private radioControlRegistry: RadioControlRegistry,
) {
}

onChange: (value: unknown) => void = noop;
onTouched: () => void = noop;

ngOnInit() {
if (!this.name) {
throw new Error('RadioValueAccessorDirective // Name is a required Input')
}

if (!this.value) {
throw new Error('RadioValueAccessorDirective // Value is a required Input')
}

this.radioControlRegistry.add(this.name, this);
}

ngOnDestroy() {
this.radioControlRegistry.remove(this);
}

registerOnTouched(fn: never) {
this.onTouched = fn;
}

registerOnChange(fn: never) {
this.onChange = fn;
}

setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled;
}

writeValue(value: unknown) {
this.selected = value === this.value;
}

select() {
this.radioControlRegistry.setValue(this.name, this.value);
this.writeValue(this.value);
this.onChange(this.value);
this.onTouched();
}

}

2 changes: 2 additions & 0 deletions apps/www/src/test-setup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
import 'jest-preset-angular/setup-jest';
import '@testing-library/angular';
import '@testing-library/jest-dom';
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@
"@nrwl/linter": "15.8.3",
"@nrwl/workspace": "15.8.3",
"@schematics/angular": "~15.2.0",
"@testing-library/angular": "^13.3.0",
"@testing-library/dom": "^9.0.0",
"@testing-library/jest-dom": "^5.16.5",
"@types/jest": "^29.4.0",
"@types/node": "16.11.7",
"@typescript-eslint/eslint-plugin": "^5.36.1",
Expand Down
Loading