diff --git a/docs/components/widget-example-form-disable.vue b/docs/components/widget-example-form-disable.vue index 96f4df5..9ce72cc 100644 --- a/docs/components/widget-example-form-disable.vue +++ b/docs/components/widget-example-form-disable.vue @@ -26,7 +26,7 @@ + + diff --git a/src/pages/test/input-coord.vue b/project/pages/test/input-coord.vue similarity index 76% rename from src/pages/test/input-coord.vue rename to project/pages/test/input-coord.vue index 5d2dbbc..89666f5 100644 --- a/src/pages/test/input-coord.vue +++ b/project/pages/test/input-coord.vue @@ -8,12 +8,16 @@ + + \ No newline at end of file diff --git a/src/pages/test/widget-daughter.vue b/project/pages/test/widget-daughter.vue similarity index 100% rename from src/pages/test/widget-daughter.vue rename to project/pages/test/widget-daughter.vue diff --git a/src/pages/test/widget-double-child.vue b/project/pages/test/widget-double-child.vue similarity index 100% rename from src/pages/test/widget-double-child.vue rename to project/pages/test/widget-double-child.vue diff --git a/src/pages/test/widget-field.vue b/project/pages/test/widget-field.vue similarity index 100% rename from src/pages/test/widget-field.vue rename to project/pages/test/widget-field.vue diff --git a/src/pages/test/widget-grandmother.vue b/project/pages/test/widget-grandmother.vue similarity index 100% rename from src/pages/test/widget-grandmother.vue rename to project/pages/test/widget-grandmother.vue diff --git a/src/pages/test/widget-hello.vue b/project/pages/test/widget-hello.vue similarity index 100% rename from src/pages/test/widget-hello.vue rename to project/pages/test/widget-hello.vue diff --git a/src/pages/test/widget-input-account-type.vue b/project/pages/test/widget-input-account-type.vue similarity index 100% rename from src/pages/test/widget-input-account-type.vue rename to project/pages/test/widget-input-account-type.vue diff --git a/src/pages/test/widget-input-country.vue b/project/pages/test/widget-input-country.vue similarity index 100% rename from src/pages/test/widget-input-country.vue rename to project/pages/test/widget-input-country.vue diff --git a/src/pages/test/widget-mother.vue b/project/pages/test/widget-mother.vue similarity index 100% rename from src/pages/test/widget-mother.vue rename to project/pages/test/widget-mother.vue diff --git a/src/pages/test/widget-parent.vue b/project/pages/test/widget-parent.vue similarity index 100% rename from src/pages/test/widget-parent.vue rename to project/pages/test/widget-parent.vue diff --git a/src/classes/Form.ts b/src/classes/Form.ts new file mode 100644 index 0000000..3ee5fca --- /dev/null +++ b/src/classes/Form.ts @@ -0,0 +1,116 @@ +import grandObject from "../utils/grand-object"; +import mergeObjects from "../utils/merge-objects"; +import EventEmitter from "jenesius-event-emitter"; +import FormEvent from "./FormEvent"; +import {getCurrentInstance, inject as injectVue} from "vue"; +import getPropFromObject from "../../plugin/utils/get-prop-from-object"; +import {provide as provideVue} from "@vue/runtime-core"; +import checkNameInObject from "../utils/check-name-in-object"; +/** + * Main principe : GMN + * G - Grand + * M - Merge + * N - Notify + * */ +export default class Form extends EventEmitter implements FormDependence { + static EVENT_NAME = 'form-event' + static PROVIDE_NAME = 'form-controller'; + + static getParentForm() { + return injectVue
(Form.PROVIDE_NAME, undefined); + } + /** + * @description Name of Entity. + * */ + name?: string + + #values = {} + get values(): any { + if (this.parent) { + return this.parent.getValueByName(this.name as string); + } + return this.#values; + }; + + dependencies: any[] = [] + + #parent: Form | undefined; + get parent() { return this.#parent }; + set parent(parent: Form | undefined) { + this.#parent = parent; + + if (!this.parent) return; + + this.parent.subscribe(this); + + this.parent.on(Form.EVENT_NAME, (event: FormEvent) => { + console.group('%cnew-event', 'color: red'); + console.log(event, this.name); + + if (checkNameInObject(event.payload, this.name as string)) + this.emit(Form.EVENT_NAME, getPropFromObject(event.payload, this.name as string)) + + console.groupEnd() + }) + } + + constructor(params: Partial) { + super(); + + this.name = params.name; + const currentInstance = !!getCurrentInstance(); + + console.log(this.name, Form.getParentForm()); + if (currentInstance) this.parent = Form.getParentForm(); + if (currentInstance) provideVue(Form.PROVIDE_NAME, this); // Default providing current form for children. + } + + private mergeValues(data: any) { + mergeObjects(this.values, data); + } + private notify(event: FormEvent['type'], model: any ) { + // - Генерация эвента для всей модели + console.log('Generation new event', model); + + this.emit(Form.EVENT_NAME, FormEvent.newValue(model)); + } + setValues(data: any):void { + if (this.parent) { + return void this.parent.setValues({ + [this.name as string]: data + }); + } + + const grandData = grandObject(data); + this.mergeValues(grandData); + this.notify('value', grandData); + } + getValueByName(name: string) { + return getPropFromObject(this.values, name); + } + + change(data: any) { + + // Mark changes + this.setValues(data); + } + + subscribe(element: T) { + this.dependencies.push(element); + } + + oninput(name: string, callback: (newValue: any, oldValue: any) => void) { + return this.on(Form.EVENT_NAME, data => { + callback(0, 0); + }) + } +} + +interface FormParams { + name: string +} +interface FormDependence { + change(data: any): void, + setValues(data: any): void, +} + diff --git a/src/classes/FormEvent.ts b/src/classes/FormEvent.ts new file mode 100644 index 0000000..8118e12 --- /dev/null +++ b/src/classes/FormEvent.ts @@ -0,0 +1,17 @@ +export default class FormEvent { + type: FormEventType + payload: any + + constructor(type: FormEventType, payload: any) { + this.type = type; + this.payload = payload; + } + + static newValue(values: any) { + return new FormEvent('value', values); + } +} + + + +type FormEventType = 'value' | 'change' | 'available' diff --git a/src/config/config.ts b/src/config/config.ts new file mode 100644 index 0000000..6756f33 --- /dev/null +++ b/src/config/config.ts @@ -0,0 +1,38 @@ +import mergeObjects from "./../utils/merge-objects"; +import STORE, {IStore} from "./store"; +import Store from "./store"; +import debug from "./../debug/debug"; + +export default function config(params: ConfigParams) { + + /** + * In case if params includes inputTypes, merge provided component with default widgets. + */ + if ("typeNotCaseSensitive" in params && typeof params.typeNotCaseSensitive === "boolean") Store.typeNotCaseSensitive = params.typeNotCaseSensitive; + + + if ("debug" in params && typeof params.debug === "boolean") { + STORE.debug = params.debug; + debug.msg('Debugging turn on'); + } + + try { + if (params.inputTypes) { + let parsedInputTypes = params.inputTypes; + + if (STORE.typeNotCaseSensitive) + parsedInputTypes = Object.entries(params.inputTypes).reduce>((acc, [type, component]) => { + acc[type.toLowerCase()] = component + return acc; + }, {}) + + mergeObjects(STORE.inputTypes, parsedInputTypes) + } + } catch (e) { + console.error(e) + } +} + +type ConfigParams = Partial & { + inputTypes?: IStore['inputTypes'] +} \ No newline at end of file diff --git a/src/config/store.ts b/src/config/store.ts new file mode 100644 index 0000000..fe59535 --- /dev/null +++ b/src/config/store.ts @@ -0,0 +1,32 @@ +import InputText from "./../widgets/input-text/input-text.vue"; + + +const STORE: IStore = { + requiredMessage: 'Please fill in this field', + inputTypes: { + text : InputText, + }, + typeNotCaseSensitive: true, + debug: false, + defaultType: 'text' +} + +type defineInputTypes = 'text' | 'select' | 'radio' | 'checkbox' | 'switch' | 'password' | 'tel' | 'number' | 'range' | 'textarea'; +export interface IStore { + inputTypes: { + [name: defineInputTypes | string]: any + }, + requiredMessage: string, + typeNotCaseSensitive: boolean + debug: boolean, + defaultType: string +} +export default STORE; + + +export function getFieldType(type: any) { + if (typeof type !== 'string') return STORE.inputTypes[STORE.defaultType]; + + type = STORE.typeNotCaseSensitive ? type?.toLowerCase() : type; + return STORE.inputTypes[type] || STORE.inputTypes[STORE.defaultType]; +} diff --git a/src/debug/debug.ts b/src/debug/debug.ts new file mode 100644 index 0000000..564cf33 --- /dev/null +++ b/src/debug/debug.ts @@ -0,0 +1,24 @@ +import STORE from "../config/store"; +import Form from "../classes/Form"; + +class debug{ + private static getName(element: T) { + return element.name ? `(${element.name})` : ''; + } + static msg(...params: string[]) { + if (!STORE.debug) return; + + console.log(`%c[form]%c`, 'color: #42b883', 'color: black', ...params) + } + static newForm(form: Form) { + debug.msg(`form${debug.getName(form)}: created`); + } + static newSubscription(parent: T, child: T) { + debug.msg(`subscription${debug.getName(child)}: add to ${debug.getName(parent)}`) + } +} +export default debug; + +interface NamedElement { + name?: string +} \ No newline at end of file diff --git a/src/debug/warn.ts b/src/debug/warn.ts new file mode 100644 index 0000000..3f0cebf --- /dev/null +++ b/src/debug/warn.ts @@ -0,0 +1,5 @@ +import debug from "./debug"; + +export default function warn(subject: string, text: string, error?: any) { + console.log(`%c[${subject}] %c${text}`, 'color: #dac400', 'color: black',error); +} diff --git a/src/local-hooks/only-number.ts b/src/local-hooks/only-number.ts new file mode 100644 index 0000000..592d0b4 --- /dev/null +++ b/src/local-hooks/only-number.ts @@ -0,0 +1,4 @@ +export default function onlyNumber(a: unknown) { + if (typeof a !== "string") return ''; + return a.replace(/[^\d,.+-]/,'') +} \ No newline at end of file diff --git a/src/local-hooks/use-modify.ts b/src/local-hooks/use-modify.ts new file mode 100644 index 0000000..fdefae8 --- /dev/null +++ b/src/local-hooks/use-modify.ts @@ -0,0 +1,24 @@ +import {StringModify} from "../types"; +import warn from "../debug/warn"; + +type ModifyParam = undefined | StringModify | Array +export default function useModify(callbackModifyProps: () => ModifyParam) { + function parse(param: ModifyParam) { + if (!param) return []; + if (!Array.isArray(param)) return [param]; + return param.filter(a => a !== undefined) as StringModify[] + } + + return function execute(v: unknown): string { + const arr = parse(callbackModifyProps()); // Getting array of handlers + + arr.forEach(callback => v = callback(v)); + try { + arr.forEach(callback => v = callback(v)); + } catch (e) { + warn('[modify]', `Error in modify callback with value ${v}`, e); + } + + return (v !== undefined && v !== null) ? String(v) : ''; + } +} \ No newline at end of file diff --git a/src/pages/test/App.vue b/src/pages/test/App.vue deleted file mode 100644 index 62b3a72..0000000 --- a/src/pages/test/App.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000..0790f97 --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "esnext", + "strict": true, + "importHelpers": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationDir": "../dist/types" + }, + "exclude": [ + + ] +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..d380a4d --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,61 @@ +export interface Values { + [name: string]: Value +} +export type Value = Values | any; + +export type ValidationRule = (value: any) => boolean | string; + +export type FunctionHandleData = () => Promise | any | void; + +export type ValidationGuard = () => void + +export type OptionRow = IOptionRowWithLabel | IOptionRowWithTitle + +interface IOptionRowWithLabel { + label: string, + value: any +} +interface IOptionRowWithTitle { + title: string, + value: any +} + + +export interface FormDependence { + name?: string, + changed?: boolean + disable?: (name?: string | string[]) => void, + enable? : (name?: string | string[]) => void, + change ?: (v: any) => void, + setValues?: (v: any) => void, + validate?: () => boolean | string | string[], + cleanChanges?: (values?: any) => void +} +export interface NamedFormDependence extends FormDependence{ + name: string +} + +export interface InputProps { + +} + +/** + * @description Current interface using for special widget-inputs, not for InputField + * */ +export type IPropsInput = { + label?: string, + errors: string[], + modelValue: any, + disabled: boolean, + autofocus: boolean +} + + +export type StringModify = (v: unknown) => string + + +export interface SimpleFormParams { + name?: string +} + + diff --git a/src/utils/bypass-object.ts b/src/utils/bypass-object.ts new file mode 100644 index 0000000..85ed452 --- /dev/null +++ b/src/utils/bypass-object.ts @@ -0,0 +1,58 @@ +import isEndPointValue from "./is-end-point-value"; + +function step(array: BypassItem[], value: any, path: string[] = []): void { + if (isEndPointValue(value)) return; + + Object.keys(value) + .forEach(key => { + + const parsedKey = key.split('.'); + + const p = [...path, ...parsedKey]; // Step path + const v = value[key]; // Step value + + if (isEndPointValue(v)) { + array.push({ + path: p, + value: v + }) + return; + } + + step(array, v, p) + }) +} +/** + * @description Функция проходит по всем полям объекта. + * @return Array of {path: string[], value: any} + * @example + * { person: { profile: { head: { mouth: 1, eyes: 2 } } } } + * Result: + * [ + * { + * path: ['person', 'profile', 'head', 'mouth'], + * value: 1 + * }, + * { + * path: ['person', 'profile', 'head', 'eyes'], + * value: 2 + * } + * ] + */ +export default function bypassObject(object: any): BypassItem[] { + const array:BypassItem[] = []; + + step(array, object); + + return array +} + +interface BypassItem { + value: any, + path: string[] +} + + + + + diff --git a/src/utils/check-composite-name.ts b/src/utils/check-composite-name.ts new file mode 100644 index 0000000..398f78e --- /dev/null +++ b/src/utils/check-composite-name.ts @@ -0,0 +1,18 @@ +export default function checkCompositeName(parentName: string, childrenName: string): boolean { + + // Parent name can't be less for size; + if (parentName.length > childrenName.length) return false; + // Equal + if (parentName === childrenName) return true; + + let index = 0; + const parentArray = parentName.split('.'); + const childrenArray = childrenName.split('.'); + + while(index < parentArray.length) { + if (parentArray[index] !== childrenArray[index]) return false; + index++; + } + + return true; +} diff --git a/src/utils/check-name-in-object.ts b/src/utils/check-name-in-object.ts new file mode 100644 index 0000000..545cf3a --- /dev/null +++ b/src/utils/check-name-in-object.ts @@ -0,0 +1,26 @@ +import splitName from "./split-name"; + +/** + * @description Return true if object include name, otherwise - false. Object should be granted! + * @example + * { user: { id: 2, address: {city: "Mars"} } } + * user.id -> true + * user -> true + * user.name -> false + * user.name.label -> false + * */ +export default function checkNameInObject>(object: T, searchName: string ) { + const names = splitName(searchName); + + for(let i = 0; i < names.length; i++) { + const name = names[i]; + try { + if ((name in object) === false || !object.hasOwnProperty(name)) return false; + } catch (e) { + return false; + } + object = object[name]; + } + + return true; +} \ No newline at end of file diff --git a/src/utils/check-primitive-type.ts b/src/utils/check-primitive-type.ts new file mode 100644 index 0000000..8d8e8e1 --- /dev/null +++ b/src/utils/check-primitive-type.ts @@ -0,0 +1 @@ +export default (v: any) => v === null || v === undefined || Array.isArray(v) || typeof v !== 'object'; diff --git a/src/utils/click-outside.ts b/src/utils/click-outside.ts new file mode 100644 index 0000000..65a8d6c --- /dev/null +++ b/src/utils/click-outside.ts @@ -0,0 +1,17 @@ + +export default function clickOutside(el: HTMLElement, callback: any) { + + function handleClickOutside(e: MouseEvent) { + // Clicked outside + if (!el.contains(e.target as Node)) { + callback(); + document.removeEventListener('click', handleClickOutside); + } + } + + document.addEventListener('click', handleClickOutside) + // Return off hook + return () => { + document.removeEventListener('click', handleClickOutside) + } +} diff --git a/src/utils/concat-name.ts b/src/utils/concat-name.ts new file mode 100644 index 0000000..565f177 --- /dev/null +++ b/src/utils/concat-name.ts @@ -0,0 +1,6 @@ +/** + * @description Concat names. Method using for check first name. The correct name is xxx.xxx. Wrong value is .xxx or xxx. + * */ +export default function concatName(sub: string, name: string) { + return (sub.length) ? `${sub}.${name}` : name; +} \ No newline at end of file diff --git a/src/utils/convert-options-object.ts b/src/utils/convert-options-object.ts new file mode 100644 index 0000000..bb35acf --- /dev/null +++ b/src/utils/convert-options-object.ts @@ -0,0 +1,27 @@ +import {OptionRow} from "../types"; + +/** + * @description Converting Options Object to OptionsRow[] + * */ +export default function convertOptionsObject(object: Record): OptionRow[] +export default function convertOptionsObject(object: Record, type: 'reverse'): OptionRow[] +export default function convertOptionsObject(object:OptionsObject, type?: 'reverse'): OptionRow[] { + function parse(obj: [any, any]) { + return { + value: obj[0], + label: String(obj[1]) + } + } + function reverseParse(obj: [any, any]) { + return { + value: obj[1], + label: String(obj[0]) + } + } + + const handle = type === 'reverse' ? reverseParse : parse; + + return Object.entries(object).map(handle) +} + +type OptionsObject = Record \ No newline at end of file diff --git a/src/utils/copy-object.ts b/src/utils/copy-object.ts new file mode 100644 index 0000000..ead7887 --- /dev/null +++ b/src/utils/copy-object.ts @@ -0,0 +1,13 @@ +import checkPrimitiveType from "./check-primitive-type"; + +export default function copyObject(object: T): T { + const outputObject:any = {}; + + if (checkPrimitiveType(object)) return object; + + Object.entries(object).forEach(([key, value]) => { + outputObject[key] = copyObject(value); + }) + + return outputObject as T; +} \ No newline at end of file diff --git a/src/utils/delete-prop-by-name.ts b/src/utils/delete-prop-by-name.ts new file mode 100644 index 0000000..7babdb8 --- /dev/null +++ b/src/utils/delete-prop-by-name.ts @@ -0,0 +1,29 @@ +/** + * @param {Object} object. Deepen object! + * @param {String} name of removed item. + * @return {Boolean} removed. True if property was found and removed. + * */ +export default function deletePropByName(object: any, name: string) { + // 'address.city.code' + const splitName = name.split('.'); // ['address', 'city', 'code'] + + let index = 0; + + // Until last item. Last item don't touched. We need to delete it. + while (index < splitName.length) { + const currentName = splitName[index]; + + // Name was not founded. Operation rejected. + if (!object.hasOwnProperty(currentName)) return false; + + // last item + if (index === splitName.length - 1) { + delete object[currentName]; + return true; + } + object = object[currentName]; + index++; + } + + return false; +} diff --git a/src/utils/find-nearest-name-from-array.ts b/src/utils/find-nearest-name-from-array.ts new file mode 100644 index 0000000..ccf1a2d --- /dev/null +++ b/src/utils/find-nearest-name-from-array.ts @@ -0,0 +1,13 @@ +import checkCompositeName from "./check-composite-name"; + +export default function findNearestNameFromArray(name: string, array: string[]): string | undefined { + let answer = ""; + + array.forEach(n => { + + if (!checkCompositeName(n, name)) return; + if (n.length > answer.length) answer = n; + }) + + return answer.length === 0 ? undefined: answer +} diff --git a/src/utils/generate-field-by-path.ts b/src/utils/generate-field-by-path.ts new file mode 100644 index 0000000..5fc2cc3 --- /dev/null +++ b/src/utils/generate-field-by-path.ts @@ -0,0 +1,28 @@ +import isEndPointValue from "./is-end-point-value"; +import FormErrors from "../classes/FormErrors"; +/** + * @description By provided path and value create props of provided object + * @example {}, ["address", "city"], Berlin -> { address: city: "Berlin" } + * */ +export default function generateFieldByPath(object: any, path: string[], value: any) { + const refObject = object; + path.forEach((key,index) => { + + if (index >= path.length - 1) return Object.defineProperty(object, key, { + value, + enumerable: true, + configurable: true + }); + if (!Object.prototype.hasOwnProperty.call(object, key)) { + object[key] = {} + object = object[key]; + return; + } + if (isEndPointValue(object[key])) throw FormErrors.UnableExtendPrimitive(key); + + object = object[key]; + }) + + return refObject; + +} \ No newline at end of file diff --git a/src/utils/get-cast-object.ts b/src/utils/get-cast-object.ts new file mode 100644 index 0000000..ad21335 --- /dev/null +++ b/src/utils/get-cast-object.ts @@ -0,0 +1,17 @@ +import isEndPointValue from "./is-end-point-value"; + +export default function getCastObject(values: any, cast: any) { + const output:any = {}; + + Object.keys(cast) + .forEach(name => { + // В слепке дошли до true + if (isEndPointValue(cast[name])) { + output[name] = values[name]; + return; + } + output[name] = getCastObject(values[name], cast[name]) + }); + + return output; +} diff --git a/src/utils/get-label-from-option-row.ts b/src/utils/get-label-from-option-row.ts new file mode 100644 index 0000000..8e5315d --- /dev/null +++ b/src/utils/get-label-from-option-row.ts @@ -0,0 +1,10 @@ +import {OptionRow} from "../types"; + +/** + * @description Пока нет надобности убирать title, и полноценно его заменять на label, по этому в текущей системе + * поддерживается эти два варианта. В будущем будем придерживаться политики сужения, чтобы был единственный ВЕРНЫЙ вариант + * описания данных + * */ +export default function getLabelFromOptionRow(optionData: OptionRow) { + return (("title" in optionData) ? optionData.title : optionData.label) || ''; +} \ No newline at end of file diff --git a/src/utils/get-prop-from-object.ts b/src/utils/get-prop-from-object.ts new file mode 100644 index 0000000..8b63ae4 --- /dev/null +++ b/src/utils/get-prop-from-object.ts @@ -0,0 +1,63 @@ + +import {Value, Values} from "../types"; + + +/** + * @description Функция вернёт значения поля из объекта. Prop может быть вложеным, указывается через точку + * @description Функция возвращает значение по имени. Имя может быть составным. + * Если имя не было найдёно - вернёт undefined + * */ + + + +export default function getPropFromObject(obj: Values, name: string) : Value | undefined{ + + /** + * Если переданный объект, явялется примитивным типом, то дальнейший спуск + * по объекты - не возможет. Возвращается undefined + * */ + if (typeof obj !== 'object' || obj === null) return undefined; + + + /** + * Т.к. свойство объета - может быть мульти полем + * { + * country.code: AU + * } + * Необходимо на каждом шаге проверять наличие + * */ + if (name in obj) return obj[name]; + + // Поиск точки. Мульти имени + const _index = name.indexOf('.'); + + // Если точка найдена. обрубаем первую часть и ищем рекурсивно далее + if(_index > -1){ + /** + * В ДАННОМ СЛУЧАЕ NAME СОСТАВНОЕ: address.city.street + * МЫ ОБРЕЗАЕМ ДО ПЕРВОЙ ТОЧКИ И ИСПОЛЬЗУЕМ ТОЛЬКО address + * ДАЛЕЕ РЕКУРСИВНО ОБРАБАТЫВАЕМ ДАЛЬШЕ + * */ + const subName:string = name.substring(0, _index); // address (first name) + + if (subName in obj) + return getPropFromObject(obj[subName], name.substring(_index + 1)); + + return undefined; + } + + + return obj[name]; +} + + +/** + * @description Разбивает имя с сервера(точки рассоединяет) + * + * @param {String} name + * @return {Array} arr + * + * */ +export function parseName(name:string){ + return name.split("."); +} diff --git a/src/utils/grand-object.ts b/src/utils/grand-object.ts new file mode 100644 index 0000000..cabd14d --- /dev/null +++ b/src/utils/grand-object.ts @@ -0,0 +1,47 @@ +import bypassObject from "./bypass-object"; + + +/** + * @description На вход получает объект данных, возвращает максимально упрощённо разложенный объект. (Предыдущее название: + * DeepenObject) + * { { + * address.city.name: 'Berlin' ---> address: { city: { name: 'Berlin' } } + * } } + * */ + +export default function grandObject(object: any) { + + return bypassObject(object) + .reduce((acc: any, {path, value}) => { + + + let link = acc; + let index = 0; + + while (index !== path.length) { + const name = path[index]; + + // last item + if (index === path.length - 1) { + + // value = JSON.parse(JSON.stringify(value)) + link[name] = value; + } + else { + link = link[name] || (() => { + link[name] = {}; + return link[name]; + })() + } + index++; + } + return acc; + }, {}) + +} +export function grandValue(name: string, value: any) { + return grandObject({ + [name]: value + }) +} + diff --git a/src/utils/is-end-point-value.ts b/src/utils/is-end-point-value.ts new file mode 100644 index 0000000..efc5b59 --- /dev/null +++ b/src/utils/is-end-point-value.ts @@ -0,0 +1,19 @@ +import checkPrimitiveType from "./check-primitive-type"; + +/** + * @description Method using for check for value is endpoint? + * @return {Boolean} If provided object is primitive or frozen return true, otherwise return false. + */ +export default function isEndPointValue(v: any) { + + if (checkPrimitiveType(v)) return true; + + /** + * If value of frozen, For example, in case passing File or some Class Data. + */ + if ( + Object.isFrozen(v) + ) return true; + + return false; +} \ No newline at end of file diff --git a/src/utils/iterate-endpoint.ts b/src/utils/iterate-endpoint.ts new file mode 100644 index 0000000..4e394e7 --- /dev/null +++ b/src/utils/iterate-endpoint.ts @@ -0,0 +1,43 @@ +import isEndPointValue from "./is-end-point-value"; + +interface Endpoint { + set(v: any): void, // For set new value + path: string[], + value: any +} +/** + * @description Method for iterate for each end point instance. + * @return {Endpoint} EndpointController to manipulate with point. + * @example { address: {city: "Berlin" } } -> [ { path: ["address", "city"], value: "Berlin", set(){} } ] + */ +export default function iterateEndpoint(value: any) { + function next(endpointsArray: Endpoint[], value: any, path: string[] = []) { + if (isEndPointValue(value)) return endpointsArray; + + Object.keys(value).forEach(key => { + const parsedKey = key.split('.'); + const currentValue = value[key]; + const newPath = [...path, ...parsedKey]; + if (isEndPointValue(currentValue)) { + endpointsArray.push({ + set(v: any) { + value[key] = v; + }, + value: currentValue, + path: newPath + }) + return; + } + next(endpointsArray, currentValue, newPath); + }) + return endpointsArray; + } + + /** + * @description Array of output's controller + */ + const outputArray:Endpoint[] = []; + + return next(outputArray, value); +} + diff --git a/src/utils/iterate-points.ts b/src/utils/iterate-points.ts new file mode 100644 index 0000000..1e6314a --- /dev/null +++ b/src/utils/iterate-points.ts @@ -0,0 +1,24 @@ +import checkPrimitiveType from "./check-primitive-type"; +import concatName from "./concat-name"; + +interface IPoint { + name: string, + value: any +} +/** + * @description Method return array witch includes all points of provided object. + * @example {a: 1, name: {code: 1}} => + * [ {name: 'a', value: 1}, { name: 'city', value: {code: 1} }, { name: 'city.code', value: 1} ] + */ +export default function iteratePoints(object: unknown, startWith = '', array: IPoint[] = []) { + if (typeof object !== 'object' || object === null) return array; + + Object.entries(object).forEach(([key, value]) => { + const name = concatName(startWith, key); + array.push({ + name, value + }) + if (!checkPrimitiveType(value)) iteratePoints(value, name, array) + }) + return array; +} \ No newline at end of file diff --git a/src/utils/merge-objects.ts b/src/utils/merge-objects.ts new file mode 100644 index 0000000..9828fbf --- /dev/null +++ b/src/utils/merge-objects.ts @@ -0,0 +1,35 @@ +import {Values} from "../types"; +import isEndPointValue from "./is-end-point-value"; +/** + * @description Сливает второй объект в первый. + * {a: {b: 1}}, {a: {c: 1}} => {a: {b: 1 , c: 1}} + * */ +export default function mergeObjects(originalValues: Values, newValues: Values){ + function set(o: any, k: string, v: any) { + o[k] = v; + } + + for( const key in newValues ) { + const value = newValues[key]; + if (isEndPointValue(value)) set(originalValues, key, value); + else { + if (!originalValues.hasOwnProperty(key)) originalValues[key] = {}; + + // If current value is primitive we need to change it to object. + if (isEndPointValue(originalValues[key])) originalValues[key] = {}; + + mergeObjects(originalValues[key], value); + } + } + return originalValues; +} + +/** + * Принцип работы: + * + * merge: 1. Идём по ключам второго объекта. + * 2. Значение простое? Да -> Установить значение (исходныеОбъект, Значение, Текущий ключ) + * Нет -> + * 3. Данного ключа нет в исходному Объекте -> Установить ключ, как {} + * 4. merge(исходныеОбъект[key], Значение) + * */ diff --git a/src/utils/plain-object.ts b/src/utils/plain-object.ts new file mode 100644 index 0000000..ebfc623 --- /dev/null +++ b/src/utils/plain-object.ts @@ -0,0 +1,8 @@ +import bypassObject from "./bypass-object"; + +export default function plainObject(object: any) { + return bypassObject(object).reduce((acc: any, {path, value}) => { + acc[path.join('.')] = value; + return acc; + }, {}); +} diff --git a/src/utils/replace-values.ts b/src/utils/replace-values.ts new file mode 100644 index 0000000..b5de9a7 --- /dev/null +++ b/src/utils/replace-values.ts @@ -0,0 +1,35 @@ +import {Values} from "../types"; +import isEndPointValue from "./is-end-point-value"; +import iterateEndpoint from "./iterate-endpoint"; +import generateFieldByPath from "./generate-field-by-path"; + +/** + * @description Метод вернёт новый объект, заменив все примитивные значения + * на переданный аргумент. + * */ + +function replace(o: Values, value: any): {} { + Object.keys(o) + .forEach(key => { + if (isEndPointValue(o[key])) return o[key] = value; + + replace(o[key], value); + }) + + return o; +} + +export default function replaceValues(object: Values, value: any = true) { + + return iterateEndpoint(object) + .reduce((acc: any, item) => { + generateFieldByPath(acc, item.path, value) + return acc; + }, {}) + + /* + const copyObject = JSON.parse(JSON.stringify(object)); + + return replace(copyObject, value);*/ + +} diff --git a/src/utils/run-promise-queue.ts b/src/utils/run-promise-queue.ts new file mode 100644 index 0000000..c6e8643 --- /dev/null +++ b/src/utils/run-promise-queue.ts @@ -0,0 +1,3 @@ +export default function runPromiseQueue(guards:Array): Promise { + return guards.reduce((promiseAccumulator, fn) => promiseAccumulator.then((x:any) => fn?.(x)), Promise.resolve()); +} diff --git a/src/utils/search-changes-by-comparison.ts b/src/utils/search-changes-by-comparison.ts new file mode 100644 index 0000000..8b018bb --- /dev/null +++ b/src/utils/search-changes-by-comparison.ts @@ -0,0 +1,112 @@ +/** + * @description Function using for search changes in mainObject. Will return Array<{name: string, newValue: string}> + */ +import getPropFromObject from "./get-prop-from-object"; +import checkPrimitiveType from "./check-primitive-type"; +import concatName from "./concat-name"; +import iteratePoints from "./iterate-points"; +import copyObject from "./copy-object"; +import mergeObjects from "./merge-objects"; + +/** + * @description Pushing ComparisonResult to array. + * */ +function add(array: IComparisonResult[], name: string, oldValue: any, newValue: any,) { + array.push({ + name, newValue, oldValue + }) +} + +export interface IComparisonResult { + name: string, + newValue: any, + oldValue: any +} +/** + * @description Check all point and set new value as undefined + */ +function resetEachValue(object: Record, array: IComparisonResult[] = [], subName: string = '') { + iteratePoints(object, subName).forEach(state => { + array.push({ + name: state.name, + oldValue: state.value, + newValue: undefined + }) + }) + return array; +} + +/** + * @description Compares all points of oldValue/newValue + * */ +export function searchByComparison(oldValues: any, newValues: any, array: IComparisonResult[] = [], subName = '') { + function addOld() { + add(array, concatName(subName, oldPoints[oldIndex].name), oldPoints[oldIndex].value, undefined); + oldIndex++; + } + function addNew() { + add(array, concatName(subName, newPoints[newIndex].name), undefined, newPoints[newIndex].value); + newIndex++; + } + + const oldPoints = iteratePoints(oldValues), newPoints = iteratePoints(newValues); + let oldIndex = 0, newIndex = 0; + + while(oldIndex < oldPoints.length && newIndex < newPoints.length) { + const oldKey = oldPoints[oldIndex].name, newKey = newPoints[newIndex].name; + if (oldKey === newKey) { + add(array, concatName(subName, oldKey), oldPoints[oldIndex].value, newPoints[newIndex].value); + oldIndex++; newIndex++; + } + else if (oldKey < newKey) { + addOld() + } else { // newKey < oldKey + addNew() + } + } + // If some points was not checked + while(oldIndex < oldPoints.length) addOld() + while(newIndex < newPoints.length) addNew() + return array; +} + +/** + * @description Function the same with searchByComparison, but check only changes + * @param {Object} changes Only changes. Not an output object + * */ +export function searchChangesByComparison(mainObject: any, changes: unknown, array: IComparisonResult[] = [], subName = '') { + // if (typeof mainObject !== "object" || mainObject === null) throw FormErrors.ProvidedValueNotObject(mainObject); + if (typeof changes !== "object" || changes === null) return []; + + + Object.entries(changes).forEach(([key, newValue]) => { + + + let oldValue = checkPrimitiveType(mainObject) ? undefined : mainObject[key]; + const compositeName = concatName(subName, key); + + /** + * Добавить работу с array + * */ + const copyOldValue = copyObject(oldValue); // Copy old object. We don't need to changed it + const mergedValue = !checkPrimitiveType(copyOldValue) && !checkPrimitiveType(newValue)? mergeObjects(copyOldValue, newValue) : newValue; // Merging copy with values + + add(array, compositeName, getPropFromObject(mainObject, key), mergedValue); + + + if (checkPrimitiveType(newValue) && !checkPrimitiveType(oldValue)) { + /** + * Собрать по oldValue все ключи и значение undefined + * combineKeys(oldValues, undefined); + * */ + resetEachValue(oldValue, array, compositeName); + } + if (!checkPrimitiveType(newValue)) { + searchChangesByComparison(oldValue, newValue, array, compositeName); + } + }) + + return array; +} + + diff --git a/src/utils/split-name.ts b/src/utils/split-name.ts new file mode 100644 index 0000000..1c55a4d --- /dev/null +++ b/src/utils/split-name.ts @@ -0,0 +1,3 @@ +export default function splitName(name: string) { + return name.split('.'); +} \ No newline at end of file diff --git a/src/utils/update-input-position.ts b/src/utils/update-input-position.ts new file mode 100644 index 0000000..0906df3 --- /dev/null +++ b/src/utils/update-input-position.ts @@ -0,0 +1,20 @@ +import {OptionRow} from "../types"; + +/** + * @description Method using for to move bottom/up options + * @param params.value Current value (modelValue) + * @param {Number} params.duration Duration to step (1, -1 or other) + * + * */ +export default function updateInputPosition(params: {options: OptionRow[], value: any, onInput: any, duration: number}) { + + const values = params.options.map(v => v.value); + + let currentIndex = values.indexOf(params.value) + params.duration; + + // Limits + if (currentIndex >= values.length) currentIndex = 0; + if (currentIndex < 0) currentIndex = values.length - 1; + + params.onInput(values[currentIndex]); +} \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..412a1e4 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,38 @@ +import updateInputPosition from "./update-input-position"; +import replaceValues from "./replace-values"; +import plainObject from "./plain-object"; +import iterateEndpoint from "./iterate-endpoint"; +import isEndPointValue from "./is-end-point-value"; +import grandObject from "./grand-object"; +import getPropFromObject from "./get-prop-from-object"; +import getCastObject from "./get-cast-object"; +import generateFieldByPath from "./generate-field-by-path"; +import findNearestNameFromArray from "./find-nearest-name-from-array"; +import deletePropByName from "./delete-prop-by-name"; +import clickOutside from "./click-outside"; +import checkPrimitiveType from "./check-primitive-type"; +import checkCompositeName from "./check-composite-name"; +import bypassObject from "./bypass-object"; +import convertOptionsObject from "./convert-options-object"; +import copyObject from "./copy-object"; + +const utils = { + updateInputPosition, + replaceValues, + plainObject, + iterateEndpoint, + isEndPointValue, + grandObject, + getPropFromObject, + getCastObject, + generateFieldByPath, + findNearestNameFromArray, + deletePropByName, + clickOutside, + checkPrimitiveType, + checkCompositeName, + bypassObject, + convertOptionsObject, + copyObject, +} +export default utils; diff --git a/src/widgets/field-wrap.vue b/src/widgets/field-wrap.vue new file mode 100644 index 0000000..cf1bdda --- /dev/null +++ b/src/widgets/field-wrap.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/widgets/form-field.vue b/src/widgets/form-field.vue new file mode 100644 index 0000000..836e21d --- /dev/null +++ b/src/widgets/form-field.vue @@ -0,0 +1,68 @@ + + + + diff --git a/src/widgets/input-text/input-text.vue b/src/widgets/input-text/input-text.vue new file mode 100644 index 0000000..ba8160c --- /dev/null +++ b/src/widgets/input-text/input-text.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/tests/integrations/App.vue b/tests/integrations/App.vue new file mode 100644 index 0000000..577d499 --- /dev/null +++ b/tests/integrations/App.vue @@ -0,0 +1,29 @@ + + + + + \ No newline at end of file diff --git a/tests/integrations/form-values.spec.ts b/tests/integrations/form-values.spec.ts new file mode 100644 index 0000000..20e83e8 --- /dev/null +++ b/tests/integrations/form-values.spec.ts @@ -0,0 +1,94 @@ +import {mount} from "@vue/test-utils"; +import App from "./App.vue"; +import Form from "./../../src/classes/Form" + +function wait(n = 10) { + return new Promise(resolve => setTimeout(resolve, n)) +} + +describe("Dynamic form values.", () => { + + test("The form should update the values, after entering data in input.", async () => { + const app = mount(App) as any; + const form = app.vm.form; + await app.get('input').setValue('Jenesius'); + + expect(form.values).toEqual({ + username: "Jenesius" + }) + }) + + test("Input should get value from FormModel after mounted.", async () => { + const app = mount(App) as any; + const form = app.vm.form as Form; + const show = app.vm.showFieldAge; + + expect(form.values).toEqual({}); + + form.setValues({ + age: "1998" + }) + + expect(form.values).toEqual({ + age: "1998" + }) + + show(); + await wait(50); + + const inputAge = app.find('input[name=age]').element as HTMLInputElement; + const value = inputAge.value; + + expect(value).toEqual("1998") + }) + + test("The form should update the composite dependency after handle input.", async () => { + const app = mount(App) as any; + const form = app.vm.form; + + await app.get('input[name=x]').setValue('123'); + + expect(form.values).toEqual({ + coordinate: { + x: 123 + } + }) + }) + test("The form must fire the event for each name has been updated.", async () => { + const app = mount(App) as any; + const form = app.vm.form; + + await app.get('input[name=x]').setValue('123'); + + const mockCoordinate = jest.fn((...args) => args); + const mockCoordinateX = jest.fn((...args) => args); + + form.oninput('coordinate', mockCoordinate); + form.oninput('coordinate.x', mockCoordinateX); + + const NEW_X_VALUE = "123" + form.setValues({ + coordinate: { + x: NEW_X_VALUE + } + }) + + expect(mockCoordinate.mock.calls).toHaveLength(1); + expect(mockCoordinateX.mock.calls).toHaveLength(1); + + expect(mockCoordinateX.mock.calls[0][0]).toBe(undefined); + expect(mockCoordinateX.mock.calls[0][1]).toBe(NEW_X_VALUE); + + expect(mockCoordinate.mock.calls[0][1]).toBe(undefined); + expect(mockCoordinate.mock.calls[0][1]).toEqual({ + x: NEW_X_VALUE + }); + }) + + test("The form should update the composite dependency after coordinate-form execute setValues", async () => { + + }) + test("The form should update the composite dependency after parent-form execute setValues", async () => { + + }) +}) \ No newline at end of file diff --git a/tests/integrations/widget-composite.vue b/tests/integrations/widget-composite.vue new file mode 100644 index 0000000..a17b43d --- /dev/null +++ b/tests/integrations/widget-composite.vue @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index baa2823..ed19de6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,9 +33,9 @@ "examples/**/*.ts", "examples/**/*.tsx", "examples/**/*.vue", - "src/**/*.ts", - "src/**/*.tsx", - "src/**/*.vue", + "project/**/*.ts", + "project/**/*.tsx", + "project/**/*.vue", "tests/**/*.ts", "tests/**/*.tsx" ], diff --git a/vue.config.js b/vue.config.js index fab7e62..9128141 100644 --- a/vue.config.js +++ b/vue.config.js @@ -1,9 +1,10 @@ const { defineConfig } = require('@vue/cli-service') +const path = require("path"); module.exports = defineConfig({ transpileDependencies: true, pages: { index: { - entry: './src/pages/index/main.ts' + entry: './project/pages/index/main.ts' }, "simple-form": { entry: './examples/simple-form/main.ts' @@ -24,10 +25,10 @@ module.exports = defineConfig({ entry: "./examples/input-otp/main.ts" }, "test": { - entry: "./src/pages/test/main.ts" + entry: "./project/pages/test/main.ts" }, "inputs": { - entry: "./src/pages/inputs/main.ts" + entry: "./project/pages/inputs/main.ts" }, "input-select": { entry: "./examples/input-select/main.ts" @@ -35,5 +36,12 @@ module.exports = defineConfig({ "all-inputs": { entry: "./examples/all-inputs/main.ts" } + }, + configureWebpack: { + resolve: { + alias: { + "@": path.resolve(__dirname, 'project') + } + } } })