UNPKG

@v4fire/client

Version:

V4Fire client core library

1,076 lines (888 loc) • 24.6 kB
/*! * 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 */ @component({ model: { prop: 'valueProp', event: 'onChange' }, deprecatedProps: { dataType: 'formValueConverter' } }) 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]] */ @prop(Boolean) readonly hideIfOffline: boolean = false; /** * Initial component value * @see [[iInput.value]] */ @prop({required: false}) 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]] */ @prop({required: false}) 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 * ``` */ @prop({type: String, required: false}) 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 * ``` */ @prop({type: String, required: false}) 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 * ``` */ @prop({type: String, required: false}) readonly form?: string; /** @see [[iAccess.autofocus]] */ @prop({type: Boolean, required: false}) readonly autofocus?: boolean; /** @see [[iAccess.tabIndex]] */ @prop({type: Number, required: false}) readonly tabIndex?: number; /** * Additional attributes are provided to an "internal" (native) input tag * @see [[iInput.$refs.input]] */ @prop({type: Object, required: false}) 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*$/ * ``` */ @prop({required: false}) 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]] */ @prop({type: Function, required: false}) 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()] * ``` */ @prop({type: [Function, Array], required: false}) 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. */ @prop(Boolean) readonly cache: boolean = true; /** * List of component validators to check * * @example * ``` * < b-input :name = 'name' | :validators = ['required', ['pattern', {pattern: /^\d+$/}]] * ``` */ @prop(Array) 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' * ``` */ @prop({type: String, required: false}) 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' * ``` */ @prop({type: String, required: false}) readonly errorProp?: string; /** * If true, then is generated the default markup within a component template to show info/error messages */ @prop({type: Boolean, required: false}) readonly messageHelpers?: boolean; /** * Previous component value */ @system({replace: false}) prevValue?: this['Value']; override get unsafe(): UnsafeGetter<UnsafeIInput<this>> { return Object.cast(this); } /** * Link to a map of available component validators */ @p({replace: false}) get validatorsMap(): typeof iInput['validators'] { return (<typeof iInput>this.instance.constructor).validators; } /** * Link to a form that is associated with the component */ @p({replace: false}) 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]] */ @p({replace: false}) 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]] */ @p({replace: false}) 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. */ @p({replace: false}) 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]] */ @p({replace: false}) 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 */ @p({replace: false}) 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. */ @p({replace: false}) 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. */ @p({replace: false}) 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]] */ @system((o) => o.sync.link()) protected attrs?: Dictionary; /** @see [[iInput.info]] */ @system({ replace: false, init: (o) => o.sync.link() }) protected infoStore?: string; /** @see [[iInput.error]] */ @system({ replace: false, init: (o) => o.sync.link() }) protected errorStore?: string; protected override readonly $refs!: {input?: HTMLInputElement}; /** @see [[iInput.value]] */ @field<iInput>({ replace: false, init: (o) => o.sync.link((val) => o.resolveValue(val)) }) protected valueStore!: unknown; /** * Internal validation error message */ @system() private validationMsg?: string; /** @see [[iAccess.enable]] */ @p({replace: false}) enable(): Promise<boolean> { return iAccess.enable(this); } /** @see [[iAccess.disable]] */ @p({replace: false}) disable(): Promise<boolean> { return iAccess.disable(this); } /** @see [[iAccess.focus]] */ @p({replace: false}) @wait('ready', {label: $$.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]] */ @p({replace: false}) @wait('ready', {label: $$.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'])` */ @p({replace: false}) @wait('ready', {label: $$.clear}) 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'])` */ @p({replace: false}) @wait('ready', {label: $$.reset}) 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']>)` */ @p({replace: false}) @wait('ready', {defer: true, label: $$.validate}) 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 */ @p({replace: false}) 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 */ @p({hook: 'created', replace: false}) 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 */ @p({replace: false}) protected onFocus(): void { void this.setMod('focused', true); } /** * Handler: the component lost the focus */ @p({replace: false}) protected onBlur(): void { void this.setMod('focused', false); } /** * Handler: changing of a component value * @emits `change(value: this['Value'])` */ @p({replace: false}) 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); } } }