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:
|
One input component that renders as text, number, password, textarea, select, email, date or masked input. |
|
Builds |
|
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
This is deliberate — switching a field from, say, |
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 |
|
Person |
|
Address |
|
Banking |
|
|
|
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:
|
All countries as |
|
Administrative areas for a country, or |
|
Display name, falling back to the code itself. |
|
Display name for a state. |
|
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
AvailableFieldsIts controls are |
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.
|
Bank ACH. |
|
Bank Wire. |
|
Instant Transfer. Percentage plus an optional minimum; has a separate |
|
Prepaid card. Name is the literal |
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 withnew FormGroup()literals in a template. -
Use
hasError()+getErrorMessages()rather than readingcontrol.errorsin the template, so error copy stays inen.ts. -
Add a validator to
PybValidatorsonly if it is generic; anything that reads sibling controls belongs onFormHelpersService. -
Every label, placeholder and error string is a translation key. Internationalization