Forms, Fields and Validation

Every form in the application is a reactive form. There is no template-driven form and no third-party form library. Three pieces do the work:

DynamicFieldComponent

One input component that renders as text, number, password, textarea, select, email, date or masked input.

FormHelpersService

Builds FormGroup s from a list of field names, and resolves validation errors to translated strings.

PybValidators / PybAsyncValidators

The two custom validators the app adds on top of Angular’s.

DynamicFieldComponent

views/partials/components/fields/dynamic-field/. Selector pyb-dynamic-field.

It is a full custom form control — it implements both ControlValueAccessor and Material’s MatFormFieldControl, and registers itself four times:

providers: [
    { provide: MatFormFieldControl, useExisting: DynamicFieldComponent, multi: true },
    { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => DynamicFieldComponent), multi: true },
    { provide: NG_VALIDATORS, useExisting: DynamicFieldComponent, multi: true },
    { provide: MAT_DATE_FORMATS, useValue: DATE_FORMATS },
]

So it can be dropped inside a mat-form-field, bound with formControlName, and used with a datepicker that renders MM/dd/yyyy — without the host form knowing which input type will appear.

Field types

export enum DynamicFieldType {
    Text     = 'text',
    Number   = 'number',
    Password = 'password',
    TextArea = 'textarea',
    Select   = 'select',
    Email    = 'email',
    Date     = 'date',
    Masked   = 'masked',
}

Configuration

export interface DynamicFieldConfig {
    type?: DynamicFieldType;
    options?: SelectOption[];      // { value, label, divider? }
    textMask?: MaskConfig;         // { mask, placeholderChar }
}

type and options can be passed either as their own @Input() or inside config. ngOnInit merges them with lodash.defaultsDeep, so a config value wins over the individual input, which wins over the static default:

DynamicFieldComponent.defaultOptions = {
    type: DynamicFieldType.Text,
    options: [],
    textMask: { mask: [], placeholderChar: '\u2000' },
};

The default placeholder character is U+2000 (en quad), not a space or an underscore — a mask position renders as invisible fixed-width whitespace.

Changing type at runtime clears the value
ngOnChanges(changes: SimpleChanges): void {
    if (changes.type && !changes.type.isFirstChange()) {
        this.value = null;
        this.onChange(this.value);
        this.changed.next(this.value);
    }
    // …
}

This is deliberate — switching a field from, say, Text to Date would otherwise leave an unparseable value in the control. It also means a type bound to an expression that re-evaluates will silently wipe user input.

Internal form group

Each instance builds its own single-control FormGroup keyed by the formControlName it was given, and proxies value through it:

get value(): any {
    return this.formGroup.value[this.formControlName];
}

formControlName is therefore required even when the component is used outside a parent form — it is the key of the internal group. errorState is read from the outer NgControl, which the component captures in its validate() hook.

A sandbox/ sub-folder holds DynamicFieldSandboxComponent, a development harness showing every type; it is not routed in production.

Config-driven form construction

FormHelpersService.formCreator() is a curried builder: the first call fixes the default value and default validators, the second builds the group.

formCreator(defaultValue = '', defaultValidator = []) {
    return (formNames, formValues = {}, validators = {}, asyncValidators?) => {
        const controlsConfig = {};
        formNames.forEach(formName => {
            controlsConfig[formName] = [
                get(formValues, formName, defaultValue),
                get(validators, formName, defaultValidator),
                get(asyncValidators, formName),
            ];
        });
        return this.fb.group(controlsConfig);
    };
}

The typical use — from TransferMethodDetailsModal — makes every field required by default and then opts individual fields out:

const formCreator = this.formHelper.formCreator(null, [Validators.required]);   (1)
const fieldNames  = [
    ...this.bankInformationFieldsConfig.map(this.extractFieldName),
    ...this.addressFieldsConfig.map(this.extractFieldName),
];
const validators      = { [AvailableFields.AddressLine2]: [] };                (2)
const asyncValidators = { [AvailableFields.RoutingNumberBankID]: [this.pybAsyncValidators.swift(US_CODE)] };

return formCreator(fieldNames, values, validators, asyncValidators);
1 Default value null, default validator required.
2 Address line 2 is the only optional field, expressed as an empty override array rather than by omission.

Field names come from the AvailableFields enum (views/pages/settings/form-fields.enum.ts), which is the canonical list of control names shared by the settings pages, the transfer-information modals and the onboarding forms:

Business

companyName, website, legalEntityType, startDate, ein, stateOfIncorporation

Person

firstName, lastName, dateOfBirth, taxId, ssn, phone, email

Address

addressLine1, addressLine2, country, city, state, zip, postalCode

Banking

accountNumber, routingNumber, routingNumberBankId, beneficiaryName, bankName

AvailableFields lives under views/pages/settings/, but FormHelpersService in core/ imports it with a relative path (../../../views/pages/settings/…) — one of the upward dependencies described in Coding Conventions. Treat the enum as shared vocabulary rather than settings-page detail, and do not add page-specific fields to it.

Validators

PybValidators

A single synchronous validator, exposed as a static:

export const patternValidator = (pattern: string | RegExp): ValidatorFn => {
    return (control: AbstractControl): ValidationErrors => {
        const newPattern = new RegExp(pattern);
        const value = control?.value;
        if (!isNull(value) && !isUndefined(value) && !isEmpty(value)) {
            return newPattern.test(value) ? null : { pattern: true };
        }
        return null;
    };
};

Unlike Validators.pattern, it accepts a string and does not anchor it, and it treats empty values as valid — so it composes with Validators.required instead of duplicating it. Used by the marketplace invite-step for the NewEgg credential and password rules.

PybAsyncValidators

swift(countryCode) verifies a routing/SWIFT code against the backend and surfaces the server’s own message as the error text:

swift(countryCode: string): AsyncValidatorFn {
    return (control: AbstractControl): Observable<ValidationErrors | null> => {
        if (!countryCode || !control.value) {
            return of(null);
        }
        return this.swiftService.verifyRouting(countryCode, control.value).pipe(
            map(() => null),
            catchError(({ error }) => of({ swift: error.status.user_message })),
        );
    };
}

The error payload is a human-readable string from the API, not a key, so it is displayed verbatim and is not translated.

phoneValidator

On FormHelpersService rather than the validator classes, because it needs to reach across the form group. It combines a phone control with a country control and validates with google-libphonenumber:

phoneValidator(phoneNumberControlName: string, countryControlName: string): ValidatorFn {
    return (control: AbstractControl): ValidationErrors | null => {
        if (!control.parent) {
            return null;
        }
        const phoneNumber = control.parent?.get(phoneNumberControlName)?.value;
        const country     = control.parent?.get(countryControlName)?.value;

        let validNumber = false;
        try {
            const number = this.phoneNumberUtil.parse(`+${country.phoneCode}${phoneNumber}`);
            validNumber = this.phoneNumberUtil.isValidNumber(number);
        } catch (e) {}

        return validNumber ? null : { wrongNumber: true };
    };
}

The catch is empty by design: any parse failure means invalid, reported as { wrongNumber: true }. The country control must hold an object with a phoneCode, not a plain ISO string.

Error messages

hasError() is the standard "should I show an error" check — invalid and touched, so errors do not appear before the user has interacted:

hasError(formGroup: FormGroup, formControlName: string): boolean {
    const formControl = formGroup.controls[formControlName];
    return formControl.invalid && formControl.touched;
}

getErrorMessages() maps Angular’s error keys to translation keys. Only one default is built in:

private readonly defaultErrorMessages: Dictionary<string> = {
    required: 'COMMON.VALIDATION.REQUIRED',
};

A caller passes errorsMap for the rest. When a key has no mapping and controlDefaultError is true, the raw error value is passed to translateService.instant() — which is how the swift validator’s server message reaches the screen: instant() returns an unknown key unchanged.

Address data

Country and state options do not come from the API. They are compiled into the bundle at src/assets/js/global/address-field/, and read through AddressFieldService:

getCountriesDictionary(order?)

All countries as SelectOption[]. With order, those ISO codes are hoisted to the top and a divider: true entry is inserted before the remainder.

getStatesDictionary(iso)

Administrative areas for a country, or null if it has none.

getCountryNameByCountryCode(iso)

Display name, falling back to the code itself.

getStateNameByCountryCodeAndStateCode()

Display name for a state.

getStateCodeByCountryCodeAndName()

Reverse lookup, by label.

getStatesDictionary() walks a nested locality → administrativearea → options structure and returns null from a bare catch if the shape does not match. A country with no states is therefore indistinguishable from a malformed entry — both yield null, and callers render an empty select.

The countries NgRX entity collection is a separate concern: it holds the API’s country list used for tax and compliance. See Entity Slices.

simple-address-form

views/templates/simple-address-form/ is the reusable address block. It emits its own FormGroup upward rather than accepting one:

@Output() onFormReady = new EventEmitter<{ formGroup: FormGroup }>();
@Output() changed     = new EventEmitter();

The host stores the group from onFormReady and calls checkIfFormIsValid() before submitting, which marks everything touched so errors become visible.

Validation is country-dependent — city and state are required only for the three countries that have structured data for them:

updateValidity(countryCode: string) {
    const { localityName, administrativeArea } = this.addressForm.controls;
    if (['US', 'GB', 'CA'].includes(countryCode)) {
        localityName.setValidators([Validators.required]);
        administrativeArea.setValidators([Validators.required]);
    } else {
        localityName.clearValidators();
        administrativeArea.clearValidators();
    }
    localityName.updateValueAndValidity();
    administrativeArea.updateValueAndValidity();
    this.addressForm.updateValueAndValidity();
}

['US', 'GB', 'CA'] appears twice — once here and once in fetchCountries() as the hoist order for the country select. Both are inline literals.

This template does not use AvailableFields

Its controls are addressLine1, addressLine2, country, administrativeArea, localityName, postalCode — Google-style address names, whereas AvailableFields uses state, city and zip. Anything consuming both has to map between the two vocabularies.

changed is debounced by one second, so a host that persists on change writes at most once per second per address.

Dictionaries

core/pyb/dictionaries/payment-method.dict.ts maps PaymentMethodType to display data — name, long name, sort priority, and a feeText(feeTier) function that formats the fee from the fee schedule.

ACH

Bank ACH. {feePercent}% Fee, or $0 Fee when absent.

WIR

Bank Wire. {feePercent}% Fee, or $8 Fee when absent.

PPM

Instant Transfer. Percentage plus an optional minimum; has a separate trailDescription for the 7-day free trial.

PCARD

Prepaid card. Name is the literal 'Pcard name to remove' — never displayed, because card names are resolved by the card components.

The hardcoded $8 and $0 fallbacks are display defaults for a missing fee tier, not authoritative pricing. See Instant Transfer.

Conventions

  • Build groups with FormBuilder, never with new FormGroup() literals in a template.

  • Use hasError() + getErrorMessages() rather than reading control.errors in the template, so error copy stays in en.ts.

  • Add a validator to PybValidators only if it is generic; anything that reads sibling controls belongs on FormHelpersService.

  • Every label, placeholder and error string is a translation key. Internationalization