This commit is contained in:
2023-07-11 09:53:08 +03:00
commit 7c7077a44a
16 changed files with 862 additions and 0 deletions

139
src/form/ctrl.ts Normal file
View File

@@ -0,0 +1,139 @@
import type { z } from 'zod'
export type TFormSchemaCtrlArgs = {
key?: string
type?: string
label?: string
component?: string
hidden?: boolean
description?: string
readonly?: boolean
multiple?: boolean
disabled?: boolean
cssClass?: string
}
export class FormSchemaCtrl {
key: string
type: string
label: string
component: string
hidden?: boolean
description?: string
readonly?: boolean
multiple?: boolean
disabled?: boolean
cssClass?: string
static toString(ctrl: TFormSchemaCtrlArgs, description?: string) {
let result = ''
try {
if (description) {
const descObj = JSON.parse(description)
ctrl = Object.assign(descObj, ctrl)
}
result = JSON.stringify(ctrl)
} catch (ex) {}
return result
}
constructor(args: TFormSchemaCtrlArgs, shape: z.ZodTypeAny) {
let desc: any = {}
try {
if (shape.description) {
desc = JSON.parse(shape.description)
} else {
desc = JSON.parse(shape?._def?.innerType?._def?.description || '{}')
}
} catch (ex: any) {
desc = { label: shape.description || 'Unknown' }
}
this.key = desc.key || args.key || 'unknown'
this.label = desc.label || args.label || 'Label'
// Тип.
this.type = desc.type || args.type
if (!this.type && shape) {
if (
shape?._def?.innerType?._def?.innerType?._def?.schema?._def?.typeName
) {
this.type =
shape?._def?.innerType?._def?.innerType?._def?.schema?._def?.typeName
}
if (shape._def?.innerType?._def?.innerType?._def?.typeName) {
this.type = shape?._def?.innerType?._def?.innerType._def.typeName
}
if (shape._def.innerType?._def?.schema?._def?.typeName) {
this.type = shape._def.innerType?._def?.schema?._def?.typeName
}
if (shape._def.innerType?._def?.typeName) {
this.type = shape._def.innerType._def.typeName
}
if (shape._def.schema?._def?.typeName) {
this.type = shape._def.schema._def.typeName
}
if (!this.type) {
this.type = shape._def.typeName
}
}
// Переименовываем тип.
switch (this.type) {
case 'ZodString':
this.type = 'string'
break
case 'ZodNumber':
this.type = 'number'
break
case 'ZodBoolean':
this.type = 'boolean'
break
case 'ZodArray':
this.type = 'array'
break
case 'ZodDate':
this.type = 'date'
break
}
// Компонент.
this.component = desc.component || args.component
if (!this.component) {
switch (this.type) {
case 'string':
this.component = 'ui-field-text'
break
case 'number':
this.component = 'ui-field-number'
break
case 'boolean':
this.component = 'ui-field-checkbox'
break
case 'date':
this.component = 'ui-field-date'
break
default:
this.component = 'ui-field'
}
}
// Не обязательные.
this.hidden = desc.hidden || args.hidden
this.description = desc.description || args.description
this.readonly = desc.readonly || args.readonly
this.multiple = desc.multiple || args.multiple
this.disabled = desc.disabled || args.disabled
this.cssClass = desc.cssClass || args.cssClass
}
}

191
src/form/fields.ts Normal file
View File

@@ -0,0 +1,191 @@
import type { ZodTypeAny } from 'zod'
import type { TFormSchemaCtrlArgs } from './ctrl'
import { z } from 'zod'
import { GenderEnum } from '../entities'
import {
getPhoneNumberValue,
regexFIO,
validPhoneNumber,
regexSearch,
regexId
} from './helpers'
import { FormSchemaCtrl } from './ctrl'
/**
* Create field schema.
*/
export const fieldSchema = <T extends ZodTypeAny>(
base: T,
args?: TFormSchemaCtrlArgs
) => base.describe(FormSchemaCtrl.toString(args, base.description))
/**
* Base fields schema.
*/
export const bFieldsSchema = {
number: z
.preprocess(value => Number(value), z.number())
.describe(
FormSchemaCtrl.toString({
label: 'Номер',
type: 'number',
component: 'ui-field-number'
})
),
string: z
.string()
.trim()
.min(2)
.max(64)
.describe(
FormSchemaCtrl.toString({
label: 'Строка',
type: 'string',
component: 'ui-field-text'
})
),
date: z
.preprocess(value => new Date(String(value)), z.date())
.describe(
FormSchemaCtrl.toString({
label: 'Дата',
type: 'date',
component: 'ui-field-date'
})
),
boolean: z
.preprocess(value => String(value) === 'true', z.boolean())
.describe(
FormSchemaCtrl.toString({
label: 'Логическое значение',
type: 'boolean',
component: 'ui-field-checkbox'
})
)
}
/**
* Common fields schema.
*/
export const cFieldsSchema = z.object({
...bFieldsSchema,
_id: fieldSchema(bFieldsSchema.string.regex(regexId, 'Не валидный ID'), {
label: 'ID'
}),
dateCreate: fieldSchema(bFieldsSchema.date, {
label: 'Дата создания'
}),
dateUpdate: fieldSchema(bFieldsSchema.date, {
label: 'Дата изменения'
}),
q: fieldSchema(
z.preprocess(
val => String(val).replace(regexSearch, ''),
bFieldsSchema.string
),
{
label: 'Поиск',
component: 'ui-field-search'
}
),
name: fieldSchema(bFieldsSchema.string, {
label: 'Название'
}),
title: fieldSchema(bFieldsSchema.string, {
label: 'Заголовок'
}),
comment: fieldSchema(z.string().trim().min(2).max(1000), {
label: 'Комментарий',
component: 'ui-field-text-area'
}),
description: fieldSchema(z.string().trim().min(2).max(1000), {
label: 'Описание',
component: 'ui-field-text-area'
}),
text: fieldSchema(z.string().trim().min(2).max(3000), {
label: 'Текст',
component: 'ui-field-text-area'
}),
login: fieldSchema(bFieldsSchema.string, {
label: 'Логин'
}),
email: fieldSchema(bFieldsSchema.string.email(), {
label: 'Email'
}),
password: fieldSchema(bFieldsSchema.string.min(6), {
label: 'Пароль',
component: 'ui-field-password'
}),
price: fieldSchema(
z.preprocess(val => Number(val), z.number().nonnegative()),
{ label: 'Стоимость' }
),
alias: fieldSchema(
bFieldsSchema.string
.toLowerCase()
.regex(/^[a-z-]+$/, 'Только латиница и тире "-"'),
{ label: 'Псевдоним' }
),
published: fieldSchema(bFieldsSchema.boolean, {
label: 'Опубликован(а)'
}),
active: fieldSchema(bFieldsSchema.boolean, {
label: 'Активный(ная)'
}),
enabled: fieldSchema(bFieldsSchema.boolean, {
label: 'Включен(а)'
}),
disabled: fieldSchema(bFieldsSchema.boolean, {
label: 'Отключен(а)'
}),
open: fieldSchema(bFieldsSchema.boolean, {
label: 'Открыт(а)'
}),
close: fieldSchema(bFieldsSchema.boolean, {
label: 'Закрыто'
}),
closed: fieldSchema(bFieldsSchema.boolean, {
label: 'Закрыт(а)'
}),
online: fieldSchema(bFieldsSchema.boolean, {
label: 'Онлайн'
}),
firstName: fieldSchema(
bFieldsSchema.string.regex(regexFIO, 'Только кириллица'),
{ label: 'Имя' }
),
middleName: fieldSchema(
bFieldsSchema.string.regex(regexFIO, 'Только кириллица'),
{ label: 'Отчество' }
),
lastName: fieldSchema(
bFieldsSchema.string.regex(regexFIO, 'Только кириллица'),
{ label: 'Фамилия' }
),
birthday: fieldSchema(bFieldsSchema.date, {
label: 'Дата рождения'
}),
phone: fieldSchema(
z
.preprocess(val => getPhoneNumberValue(val) || 0, bFieldsSchema.number)
.refine(val => validPhoneNumber(val), {
message: 'Не вервый формат номера телефона'
}),
{
label: 'Телефон',
component: 'ui-field-phone'
}
),
gender: fieldSchema(z.enum([GenderEnum.man, GenderEnum.woman]), {
label: 'Пол',
component: 'ui-field-select-gender'
}),
year: fieldSchema(bFieldsSchema.number, {
label: 'Год'
}),
days: fieldSchema(bFieldsSchema.number.array(), {
label: 'Дни недели',
component: 'ui-picker-days'
})
})

112
src/form/helpers.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Проверка ФИО.
*/
export const regexFIO = /^[А-яЁё]+$/
/**
* Значения номера.
*/
export const regexPhone = /^[9]\d{9}$/
/**
* Регулярка для поиска.
*/
export const regexSearch = /[\'\"\+\(\)]/g
/**
* Регулярка ID.
*/
export const regexId = /^[a-f\d]{24}$/i
/**
* Возвращает значение номера телефона.
*/
export const getPhoneNumberValue = (phone?: any): number | undefined => {
if (phone) {
if (typeof phone === 'number') {
phone = phone.toString()
}
if (typeof phone === 'string') {
try {
phone = phone.replace(/\D/g, '').replace(/^[78]/, '').substring(0, 10)
return Number(phone) || undefined
} catch (e) {}
}
}
return undefined
}
/**
* Валидация мобильного номера телефона.
*/
export const validPhoneNumber = (value?: number | string) => {
if (!value) return false
const str: string = value.toString()
if (str.length !== 10) return false
if (str.match(regexPhone) === null) return false
return true
}
/**
* Формат номера телефона.
*/
export const getPhoneNumberFormat = (
phone?: number | string,
prefix: string = '+7 '
): string => {
let result = prefix
const strValue = getPhoneNumberValue(phone)?.toString().substring(0, 10)
if (strValue) {
for (let i = 0; i < strValue.length; i++) {
switch (i) {
case 0:
result += '('
break
case 3:
result += ') '
break
case 6:
result += '-'
break
case 8:
result += '-'
break
}
result += strValue[i]
}
}
return result
}
/**
* Функция для проеобрадования загоавных букв в верхний регистр.
*/
export const capitalize = (str: string = '') => {
return str[0] ? str[0].toUpperCase() + str.substring(1) : ''
}
/**
* Проверка ID.
*/
export const isId = (val: any) =>
typeof val === 'string' && val.match(/^[a-f\d]{24}$/i)
type TDate = Date | number | string
/**
* Преобразование даты в общий формат (YYYY-MM-DD).
*/
export const getDateCommonFormat = (val?: TDate | null): string => {
try {
if (val) {
const date = new Date(val)
const day = date.toLocaleDateString('ru-RU', { day: '2-digit' })
const month = date.toLocaleDateString('ru-RU', { month: '2-digit' })
const year = date.toLocaleDateString('ru-RU', { year: 'numeric' })
const format = `${year}-${month}-${day}`
return format
}
} catch (ex) {}
return ''
}

4
src/form/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './helpers'
export * from './ctrl'
export * from './fields'
export * from './model'

100
src/form/model.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { z } from 'zod'
import type { TNotificationItem } from '../notification'
import { FormSchemaCtrl } from './ctrl'
/**
* Интерфейс базовой формы для сущностей в БД.
*/
export interface IFormModel<T> {
_id: string
dateCreate?: Date
dateUpdate?: Date
title?: string
obj: T
ctrls: FormSchemaCtrl[]
_errors: {[PropKey in keyof T]?: string}
errors: TNotificationItem[]
isValid(): boolean
setValidError(code: string, text: string): void
}
/**
* Базовая модель для валидирования форм.
*/
export class BaseFormModel<T extends Object = {}> implements IFormModel<T> {
_id: string
dateCreate?: Date
dateUpdate?: Date
title?: string
obj: T
schema: z.ZodObject<z.ZodRawShape>
ctrls: FormSchemaCtrl[] = []
_errors: {[PropKey in keyof T]?: string} = {}
constructor(obj: any = {}, schema: z.ZodObject<z.ZodRawShape>) {
this._id = obj._id || 'create'
delete obj._id
if (obj.dateCreate) {
try {
this.dateCreate = new Date(obj.dateCreate)
delete obj.dateCreate
} catch (_) {}
}
if (obj.dateUpdate) {
try {
this.dateUpdate = new Date(obj.dateUpdate)
delete obj.dateUpdate
} catch (_) {}
}
this.obj = obj
this.schema = schema
// Создаём контролы.
for (const key in this.schema.shape) {
this.ctrls.push(new FormSchemaCtrl({ key }, this.schema.shape[key]))
}
// Заголовок.
if (this.schema.description) this.title = this.schema.description
}
get errors() {
let items: TNotificationItem[] = []
for (const code in this._errors) {
const text = this._errors[code]
items.push({ code, text })
}
return items
}
isValid() {
this._errors = {}
try {
this.obj = this.schema.parse(this.obj) as T
return true
} catch (ex) {
const error = ex as z.ZodError
for (const issues of error.issues) {
this.setValidError(issues.path.toString(), issues.message)
}
return false
}
}
setValidError(code: string, text: string) {
this._errors[code] = code + ' - ' + text
}
}