@v4fire/client
Version:
V4Fire client core library
1,076 lines (888 loc) • 24.6 kB
text/typescript
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
/**
* [[include:super/i-input/README.md]]
* @packageDocumentation
*/
import symbolGenerator from 'core/symbol';
import SyncPromise from 'core/promise/sync';
import { Option } from 'core/prelude/structures';
import iAccess from 'traits/i-access/i-access';
import iVisible from 'traits/i-visible/i-visible';
import iData, {
component,
prop,
field,
system,
wait,
p,
ModsDecl,
ModEvent,
UnsafeGetter,
ComponentConverter
} from 'super/i-data/i-data';
import type {
Value,
FormValue,
UnsafeIInput,
Validators,
ValidatorMsg,
ValidatorParams,
ValidatorResult,
ValidationResult,
ValidatorsDecl,
CustomValidatorParams
} from 'super/i-input/interface';
import { unpackIf } from 'super/i-input/modules/helpers';
export * from 'super/i-data/i-data';
export * from 'super/i-input/modules/helpers';
export * from 'super/i-input/interface';
export const
$$ = symbolGenerator();
/**
* Superclass for all form components
*/
export default abstract class iInput extends iData implements iVisible, iAccess {
/**
* Type: component value
*/
readonly Value!: Value;
/**
* Type: component form value
*/
readonly FormValue!: FormValue;
/** @see [[iVisible.prototype.hideIfOffline]] */
readonly hideIfOffline: boolean = false;
/**
* Initial component value
* @see [[iInput.value]]
*/
readonly valueProp?: this['Value'];
/**
* An initial component default value.
* This value will be used if the value prop is not specified or after invoking of `reset`.
*
* @see [[iInput.default]]
*/
readonly defaultProp?: this['Value'];
/**
* An input DOM identifier.
* You free to use this prop to connect the component with a label tag or other stuff.
*
* @example
* ```
* < b-input :id = 'my-input'
* < label for = my-input
* The input label
* ```
*/
readonly id?: string;
/**
* A string specifying a name for the form control.
* This name is submitted along with the control's value when the form data is submitted.
* If you don't provide the name, your component will be ignored by the form.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname
*
* @example
* ```
* < form
* < b-input :name = 'fname' | :value = 'Andrey'
* /// After pressing, the form generates an object to submit with values {fname: 'Andrey'}
* < button type = submit
* Submit
* ```
*/
readonly name?: string;
/**
* A string specifying the `<form>` element with which the component is associated (that is, its form owner).
* This string's value, if present, must match the id of a `<form>` element in the same document.
* If this attribute isn't specified, the component is associated with the nearest containing form, if any.
*
* The form prop lets you place a component anywhere in the document but have it included with a form elsewhere
* in the document.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefform
*
* @example
* ```
* < b-input :name = 'fname' | :form = 'my-form'
*
* < form id = my-form
* < button type = submit
* Submit
* ```
*/
readonly form?: string;
/** @see [[iAccess.autofocus]] */
readonly autofocus?: boolean;
/** @see [[iAccess.tabIndex]] */
readonly tabIndex?: number;
/**
* Additional attributes are provided to an "internal" (native) input tag
* @see [[iInput.$refs.input]]
*/
readonly attrsProp?: Dictionary;
/**
* Component values that are not allowed to send via the tied form.
* If a component value matches with one of the denied conditions, the form value will be equal to undefined.
*
* The parameter can take a value or list of values to ban.
* Also, the parameter can be passed as a function or regular expression.
*
* @see [[iInput.formValue]]
* @example
* ```
* /// Disallow values that contain only whitespaces
* < b-input :name = 'name' | :disallow = /^\s*$/
* ```
*/
readonly disallow?: CanArray<this['Value']> | Function | RegExp;
/**
* Converter/s of the original component value to a form value.
*
* You can provide one or more functions to convert the original value to a new form value.
* For instance, you have an input component. The input's original value is string, but you provide a function
* to parse this string into a data object.
*
* ```
* < b-input :formValueConverter = toDate
* ```
*
* To provide more than one function, use the array form. Functions from the array are invoked from
* the "left-to-right".
*
* ```
* < b-input :formValueConverter = [toDate, toUTC]
* ```
*
* Any converter can return a promise. In the case of a list of converters,
* they are waiting to resolve the previous invoking.
*
* Also, any converter can return the `Maybe` monad.
* It helps to combine validators and converters.
*
* ```
* < b-input :validators = ['required'] | :formValueConverter = [toDate.option(), toUTC.toUTC()]
* ```
*
* @see [[iInput.formValue]]
*/
readonly formValueConverter?: CanArray<ComponentConverter>;
/**
* Converter/s that is/are used by the associated form.
* The form applies these converters to the group form value of the component.
*
* To provide more than one function, use the array form. Functions from the array are invoked from
* the "left-to-right".
*
* ```
* < b-input :formConverter = [toProtobuf, zip]
* ```
*
* Any converter can return a promise. In the case of a list of converters,
* they are waiting to resolve the previous invoking.
*
* Also, any converter can return the `Maybe` monad (all errors transform to undefined).
* It helps to combine validators and converters.
*
* ```
* < b-input :validators = ['required'] | :formConverter = [toProtobuf.option(), zip.toUTC()]
* ```
*/
readonly formConverter?: CanArray<ComponentConverter> = unpackIf;
/**
* If false, then a component value isn't cached by the associated form.
* The caching is mean that if the component value does not change since the last sending of the form,
* it won't be sent again.
*/
readonly cache: boolean = true;
/**
* List of component validators to check
*
* @example
* ```
* < b-input :name = 'name' | :validators = ['required', ['pattern', {pattern: /^\d+$/}]]
* ```
*/
readonly validators: Validators = [];
/**
* An initial information message that the component needs to show.
* This parameter logically is pretty similar to STDIN output from Unix.
*
* @example
* ```
* < b-input :info = 'This is required parameter'
* ```
*/
readonly infoProp?: string;
/**
* An initial error message that the component needs to show.
* This parameter logically is pretty similar to STDERR output from Unix.
*
* @example
* ```
* < b-input :error = 'This is required parameter'
* ```
*/
readonly errorProp?: string;
/**
* If true, then is generated the default markup within a component template to show info/error messages
*/
readonly messageHelpers?: boolean;
/**
* Previous component value
*/
prevValue?: this['Value'];
override get unsafe(): UnsafeGetter<UnsafeIInput<this>> {
return Object.cast(this);
}
/**
* Link to a map of available component validators
*/
get validatorsMap(): typeof iInput['validators'] {
return (<typeof iInput>this.instance.constructor).validators;
}
/**
* Link to a form that is associated with the component
*/
get connectedForm(): CanPromise<CanUndef<HTMLFormElement>> {
return this.waitStatus('ready', () => {
let
form;
// tslint:disable-next-line:prefer-conditional-expression
if (this.form != null) {
form = document.querySelector<HTMLFormElement>(`#${this.form}`);
} else {
form = this.$el?.closest('form');
}
return form ?? undefined;
});
}
/**
* Component value
* @see [[iInput.valueStore]]
*/
get value(): this['Value'] {
return this.field.get('valueStore');
}
/**
* Sets a new component value
* @param value
*/
set value(value: this['Value']) {
this.field.set('valueStore', value);
}
/**
* Component default value
* @see [[iInput.defaultProp]]
*/
get default(): this['Value'] {
return this.defaultProp;
}
/**
* A component form value.
*
* By design, all `iInput` components have their "own" values and "form" values.
* The form value is based on the own component value, but they are equal in a simple case.
* The form associated with this component will use the form value but not the original.
*
* Parameters from `disallow` test this value. If the value does not match allowing parameters,
* it will be skipped (the getter returns undefined). The value that passed the validation is converted
* via `formValueConverter` (if it's specified).
*
* The getter always returns a promise.
*/
get formValue(): Promise<this['FormValue']> {
return (async () => {
await this.nextTick();
const
test = Array.concat([], this.disallow),
value = await this.value;
const match = (el): boolean => {
if (Object.isFunction(el)) {
return el.call(this, value);
}
if (Object.isRegExp(el)) {
return el.test(String(value));
}
return el === value;
};
let
allow = true;
for (let i = 0; i < test.length; i++) {
if (match(test[i])) {
allow = false;
break;
}
}
if (allow) {
if (this.formValueConverter != null) {
const
converters = Array.concat([], this.formValueConverter);
let
res: CanUndef<typeof value> = value;
for (let i = 0; i < converters.length; i++) {
const
validation = converters[i].call(this, res, this);
if (validation instanceof Option) {
res = await validation.catch(() => undefined);
} else {
res = await validation;
}
}
return res;
}
return value;
}
return undefined;
})();
}
/**
* A list of form values. The values are taken from components with the same `name` prop and
* which are associated with the same form.
*
* The getter always returns a promise.
*
* @see [[iInput.formValue]]
*/
get groupFormValue(): Promise<Array<this['FormValue']>> {
return (async () => {
const
list = await this.groupElements;
const
values = <Array<this['FormValue']>>[],
tasks = <Array<Promise<void>>>[];
for (let i = 0; i < list.length; i++) {
tasks.push((async () => {
const
v = await list[i].formValue;
if (v !== undefined) {
values.push(v);
}
})());
}
await Promise.all(tasks);
return values;
})();
}
/**
* A list of components with the same `name` prop and associated with the same form
*/
get groupElements(): CanPromise<readonly iInput[]> {
const
nm = this.name;
if (nm != null) {
return this.waitStatus('ready', () => {
const
form = this.connectedForm,
list = document.getElementsByName(nm);
const
els = <iInput[]>[];
for (let i = 0; i < list.length; i++) {
const
component = this.dom.getComponent<iInput>(list[i], '[class*="_form_true"]');
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (component != null && form === component.connectedForm) {
els.push(component);
}
}
return Object.freeze(els);
});
}
return Object.freeze([this]);
}
/**
* An information message that the component needs to show.
* This parameter logically is pretty similar to STD output from Unix.
*/
get info(): CanUndef<string> {
return this.infoStore;
}
/**
* Sets a new information message
* @param value
*/
set info(value: CanUndef<string>) {
this.infoStore = value;
if (this.messageHelpers) {
void this.waitStatus('ready', () => {
const
box = this.block?.element('info-box');
if (box?.children[0]) {
box.children[0].innerHTML = this.infoStore ?? '';
}
});
}
}
/**
* An error message that the component needs to show.
* This parameter logically is pretty similar to STDERR output from Unix.
*/
get error(): CanUndef<string> {
return this.errorStore;
}
/**
* Sets a new error message
* @param value
*/
set error(value: CanUndef<string>) {
this.errorStore = value;
if (this.messageHelpers) {
void this.waitStatus('ready', () => {
const
box = this.block?.element('error-box');
if (box?.children[0]) {
box.children[0].innerHTML = this.errorStore ?? '';
}
});
}
}
/** @see [[iAccess.isFocused]] */
get isFocused(): boolean {
const
{input} = this.$refs;
if (input != null) {
return document.activeElement === input;
}
return iAccess.isFocused(this);
}
static override readonly mods: ModsDecl = {
...iAccess.mods,
...iVisible.mods,
form: [
['true'],
'false'
],
valid: [
'true',
'false'
],
showInfo: [
'true',
'false'
],
showError: [
'true',
'false'
]
};
/**
* Map of available component validators
*/
static validators: ValidatorsDecl = {
//#if runtime has iInput/validators
/**
* Checks that a component value must be filled
*
* @param msg
* @param showMsg
*/
async required({msg, showMsg = true}: ValidatorParams): Promise<ValidatorResult<boolean>> {
if (await this.formValue === undefined) {
this.setValidationMsg(this.getValidatorMsg(false, msg, this.t`Required field`), showMsg);
return false;
}
return true;
},
/**
* Invokes the specified custom validator function with additional provided parameters
*
* @param params - an object containing the validator function
* and other validation parameters
*
* @param params.validator - the custom validation function that will be invoked
* with the rest of the parameters
*
* @throws {Error} if the validator function is not provided
*/
async custom(params: CustomValidatorParams): Promise<ValidatorResult> {
const {validator, ...rest} = params;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (validator == null) {
throw new Error('The `custom` validator must accept the validator function, but it was not provided');
}
const
result = await validator(rest);
if (Object.isBoolean(result) || Object.isNull(result)) {
return result;
}
return {
name: 'custom',
value: result
};
}
//#endif
};
/**
* Additional attributes that are provided to an "internal" (native) input tag
* @see [[iInput.attrsProp]]
*/
protected attrs?: Dictionary;
/** @see [[iInput.info]] */
protected infoStore?: string;
/** @see [[iInput.error]] */
protected errorStore?: string;
protected override readonly $refs!: {input?: HTMLInputElement};
/** @see [[iInput.value]] */
<iInput>({
replace: false,
init: (o) => o.sync.link((val) => o.resolveValue(val))
})
protected valueStore!: unknown;
/**
* Internal validation error message
*/
private validationMsg?: string;
/** @see [[iAccess.enable]] */
enable(): Promise<boolean> {
return iAccess.enable(this);
}
/** @see [[iAccess.disable]] */
disable(): Promise<boolean> {
return iAccess.disable(this);
}
/** @see [[iAccess.focus]] */
focus(): Promise<boolean> {
const
{input} = this.$refs;
if (input != null && !this.isFocused) {
input.focus();
return SyncPromise.resolve(true);
}
return SyncPromise.resolve(false);
}
/** @see [[iAccess.blur]] */
blur(): Promise<boolean> {
const
{input} = this.$refs;
if (input != null && this.isFocused) {
input.blur();
return SyncPromise.resolve(true);
}
return SyncPromise.resolve(false);
}
/**
* Clears the component value to undefined
* @emits `clear(value: this['Value'])`
*/
clear(): Promise<boolean> {
if (this.value !== undefined) {
this.value = undefined;
this.async.clearAll({group: 'validation'});
const emit = () => {
void this.removeMod('valid');
this.emit('clear', this.value);
return true;
};
if (this.meta.systemFields.value != null) {
return SyncPromise.resolve(emit());
}
return this.nextTick().then(emit);
}
return SyncPromise.resolve(false);
}
/**
* Resets the component value to default
* @emits `reset(value: this['Value'])`
*/
async reset(): Promise<boolean> {
if (this.value !== this.default) {
this.value = this.default;
this.async.clearAll({group: 'validation'});
const emit = () => {
void this.removeMod('valid');
this.emit('reset', this.value);
return true;
};
if (this.meta.systemFields.value != null) {
return SyncPromise.resolve(emit());
}
return this.nextTick().then(emit);
}
return SyncPromise.resolve(false);
}
/**
* Returns a validator error message from the specified arguments
*
* @param err - error details
* @param msg - error message / error table / error function
* @param defMsg - default error message
*/
getValidatorMsg(err: ValidatorResult, msg: ValidatorMsg, defMsg: string): string {
if (Object.isFunction(msg)) {
const m = msg(err);
return Object.isTruly(m) ? m : defMsg;
}
if (Object.isPlainObject(msg)) {
return Object.isPlainObject(err) && msg[err.name] || defMsg;
}
return Object.isTruly(msg) ? String(msg) : defMsg;
}
/**
* Sets a validation error message to the component
*
* @param msg
* @param [showMsg] - if true, then the message will be provided to .error
*/
setValidationMsg(msg: string, showMsg: boolean = false): void {
this.validationMsg = msg;
if (showMsg) {
this.error = msg;
}
}
/**
* Validates a component value
* (returns true or `ValidationError` if the validation is failed)
*
* @param params - additional parameters
* @emits `validationStart()`
* @emits `validationSuccess()`
* @emits `validationFail(failedValidation: ValidationError<this['FormValue']>)`
* @emits `validationEnd(result: boolean, failedValidation?: ValidationError<this['FormValue']>)`
*/
async validate(params?: ValidatorParams): Promise<ValidationResult<this['FormValue']>> {
//#if runtime has iInput/validators
if (this.validators.length === 0) {
void this.removeMod('valid');
return true;
}
this.emit('validationStart');
let
valid,
failedValidation;
for (const decl of this.validators) {
const
isArray = Object.isArray(decl),
isPlainObject = !isArray && Object.isPlainObject(decl);
let
key;
if (isPlainObject) {
key = Object.keys(decl)[0];
} else if (isArray) {
key = decl[0];
} else {
key = decl;
}
const
validator = this.validatorsMap[key];
if (validator == null) {
throw new Error(`The "${key}" validator is not defined`);
}
const validation = validator.call(
this,
Object.assign((isPlainObject ? decl[key] : (isArray && decl[1])) ?? {}, params)
);
if (Object.isPromise(validation)) {
void this.removeMod('valid');
void this.setMod('progress', true);
}
try {
valid = await validation;
} catch (err) {
valid = err;
}
if (valid !== true) {
failedValidation = {
validator: key,
error: valid,
msg: this.validationMsg
};
break;
}
}
void this.setMod('progress', false);
if (valid != null) {
void this.setMod('valid', valid === true);
} else {
void this.removeMod('valid');
}
if (valid === true) {
this.emit('validationSuccess');
} else if (valid != null) {
this.emit('validationFail', failedValidation);
}
this.validationMsg = undefined;
this.emit('validationEnd', valid === true, failedValidation);
return valid === true ? valid : failedValidation;
//#endif
// eslint-disable-next-line no-unreachable
return true;
}
/**
* Resolves the specified component value and returns it.
* If the value argument is `undefined`, the method can return the default value.
*
* @param value
*/
protected resolveValue(value?: this['Value']): this['Value'] {
const
i = this.instance;
if (value === undefined && this.lfc.isBeforeCreate()) {
return i['defaultGetter'].call(this);
}
return value;
}
/**
* Normalizes the specified additional attributes and returns it
*
* @see [[iInput.attrs]]
* @param [attrs]
*/
protected normalizeAttrs(attrs: Dictionary = {}): Dictionary {
return attrs;
}
/**
* Initializes default event listeners of the component value
*/
protected initValueListeners(): void {
this.watch('value', this.onValueChange.bind(this));
this.on('actionChange', () => this.validate());
}
protected override initBaseAPI(): void {
super.initBaseAPI();
this.resolveValue = this.instance.resolveValue.bind(this);
}
protected override initRemoteData(): CanUndef<CanPromise<unknown | Dictionary>> {
if (!this.db) {
return;
}
const
val = this.convertDBToComponent(this.db);
if (Object.isDictionary(val)) {
return Promise.all(this.state.set(val)).then(() => val);
}
this.value = val;
return val;
}
protected override initModEvents(): void {
super.initModEvents();
iAccess.initModEvents(this);
iVisible.initModEvents(this);
this.localEmitter.on('block.mod.*.valid.*', ({type, value}: ModEvent) => {
if (type === 'remove' && value === 'false' || type === 'set' && value === 'true') {
this.error = undefined;
}
});
this.localEmitter.on('block.mod.*.disabled.*', (e: ModEvent) => this.waitStatus('ready', () => {
const
{input} = this.$refs;
if (input != null) {
input.disabled = e.value !== 'false' && e.type !== 'remove';
}
}));
this.localEmitter.on('block.mod.*.focused.*', (e: ModEvent) => this.waitStatus('ready', () => {
const
{input} = this.$refs;
if (input == null) {
return;
}
if (e.value !== 'false' && e.type !== 'remove') {
input.focus();
} else {
input.blur();
}
}));
const
msgInit = Object.createDict();
const createMsgHandler = (type) => (val) => {
if (msgInit[type] == null && this.modsProp != null && String(this.modsProp[type]) === 'false') {
return false;
}
msgInit[type] = true;
return Boolean(val);
};
this.sync.mod('showInfo', 'infoStore', createMsgHandler('showInfo'));
this.sync.mod('showError', 'errorStore', createMsgHandler('showError'));
}
/**
* Handler: the component in focus
*/
protected onFocus(): void {
void this.setMod('focused', true);
}
/**
* Handler: the component lost the focus
*/
protected onBlur(): void {
void this.setMod('focused', false);
}
/**
* Handler: changing of a component value
* @emits `change(value: this['Value'])`
*/
protected onValueChange(value: this['Value'], oldValue: CanUndef<this['Value']>): void {
this.prevValue = oldValue;
if (value !== oldValue || value != null && typeof value === 'object') {
this.emit('change', this.value);
}
}
}