commit 7c7077a44a36f29eb999861750b0b84be453983c Author: AntoXa PRO Date: Tue Jul 11 09:53:08 2023 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16acd49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..cdda34f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": true, + "tabWidth": 2, + "singleQuote": true, + "semi": false, + "trailingComma": "none", + "arrowParens": "avoid", + "printWidth": 79 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9150c59 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# axp-ts + +My helper library, TypeScript diff --git a/package.json b/package.json new file mode 100644 index 0000000..21cfe15 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "axp-ts", + "version": "1.9.5", + "description": "TypeScript helper library", + "author": "AntoXa PRO ", + "main": "dist/index.js", + "files": [ + "dist", + "tsconfig.ts" + ], + "scripts": { + "build": "tsc", + "prepare": "npm run build" + }, + "dependencies": { + "zod": "^3.21.4" + }, + "devDependencies": { + "prettier": "^2.8.7", + "typescript": "^4.9.5" + } +} diff --git a/src/data-result.ts b/src/data-result.ts new file mode 100644 index 0000000..14ffb73 --- /dev/null +++ b/src/data-result.ts @@ -0,0 +1,67 @@ +import type { TPagination } from './pagination' +import type { TNotificationItem } from './notification' + +import { Pagination } from './pagination' + +export type TInfoDataResult = { + type: 'object' | 'array' | 'undefined' + length: number + pagination?: TPagination +} + +export interface IDataResult { + status: number + message: string + data: T + info: TInfoDataResult + errors: TNotificationItem[] + setData(data: T, pagination?: TPagination): void +} + +export class DataResultEntity implements IDataResult { + status: number = 200 + message: string = 'Ok' + data: T + info: TInfoDataResult = { type: 'undefined', length: 0 } + errors: TNotificationItem[] = [] + + constructor(data: any = {}) { + this.data = data + this.setData() + } + + setData(data: T = null, pagination?: TPagination): void { + if (data !== null) { + this.data = data + } + + // Если данные есть. + if (this.data) { + // Если данные это массив. + if (Array.isArray(this.data)) { + this.info.type = 'array' + this.info.length = [...this.data].length + + if (pagination) { + this.info.pagination = pagination + } else { + const pagination = new Pagination( + { page: 1, limit: this.info.length, total: this.info.length }, + this.info.length + ) + this.info.pagination = pagination.toObject() + } + + // Если общее кол-во меньше чем размер массива. + // Это обычно значение по умолчанию "0" при инициализации объекта пагинации. + if (this.info.pagination.total < this.info.length) { + this.info.pagination.total = this.info.length + } + } else { + // Данные это объект. + this.info.type = 'object' + this.info.length = 1 + } + } + } +} diff --git a/src/entities/gender.ts b/src/entities/gender.ts new file mode 100644 index 0000000..6756a38 --- /dev/null +++ b/src/entities/gender.ts @@ -0,0 +1,28 @@ +/** + * Перечисление гендеров. + */ +export enum GenderEnum { + man = 'man', + woman = 'woman' +} + +/** + * Тип гендера. + */ +export type TGender = keyof typeof GenderEnum + +/** + * Список значений типов гендера. + */ +export const GENDERS = Object.keys(GenderEnum) + +/** + * Проверка гендера. + */ +export const isGender = (val?: any | null) => { + try { + return GENDERS.includes(String(val)) + } catch (e) { + return false + } +} diff --git a/src/entities/index.ts b/src/entities/index.ts new file mode 100644 index 0000000..2b7b237 --- /dev/null +++ b/src/entities/index.ts @@ -0,0 +1 @@ +export * from './gender' diff --git a/src/form/ctrl.ts b/src/form/ctrl.ts new file mode 100644 index 0000000..ce8bd92 --- /dev/null +++ b/src/form/ctrl.ts @@ -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 + } +} diff --git a/src/form/fields.ts b/src/form/fields.ts new file mode 100644 index 0000000..25ebbce --- /dev/null +++ b/src/form/fields.ts @@ -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 = ( + 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' + }) +}) diff --git a/src/form/helpers.ts b/src/form/helpers.ts new file mode 100644 index 0000000..42341aa --- /dev/null +++ b/src/form/helpers.ts @@ -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 '' +} diff --git a/src/form/index.ts b/src/form/index.ts new file mode 100644 index 0000000..7439b8d --- /dev/null +++ b/src/form/index.ts @@ -0,0 +1,4 @@ +export * from './helpers' +export * from './ctrl' +export * from './fields' +export * from './model' diff --git a/src/form/model.ts b/src/form/model.ts new file mode 100644 index 0000000..9e13620 --- /dev/null +++ b/src/form/model.ts @@ -0,0 +1,100 @@ +import type { z } from 'zod' +import type { TNotificationItem } from '../notification' + +import { FormSchemaCtrl } from './ctrl' + + +/** + * Интерфейс базовой формы для сущностей в БД. + */ +export interface IFormModel { + _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 implements IFormModel { + _id: string + + dateCreate?: Date + dateUpdate?: Date + title?: string + + obj: T + schema: z.ZodObject + ctrls: FormSchemaCtrl[] = [] + + _errors: {[PropKey in keyof T]?: string} = {} + + constructor(obj: any = {}, schema: z.ZodObject) { + 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 + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8789f68 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export * from './entities' +export * from './form' +export * from './notification' +export * from './pagination' +export * from './data-result' diff --git a/src/notification.ts b/src/notification.ts new file mode 100644 index 0000000..1a9ed09 --- /dev/null +++ b/src/notification.ts @@ -0,0 +1,14 @@ +export type TNotificationItem = { + code: string + text: string +} + +export class NotificationItem implements TNotificationItem { + code: string + text: string + + constructor(args: {code: string, text: string }) { + this.code = args.code + this.text = args.text + } +} diff --git a/src/pagination.ts b/src/pagination.ts new file mode 100644 index 0000000..89f0fbc --- /dev/null +++ b/src/pagination.ts @@ -0,0 +1,151 @@ +import { z } from 'zod' +import { cFieldsSchema, fieldSchema } from './form' + +export const paginationSchema = z.object({ + page: fieldSchema(cFieldsSchema.shape.number, { + label: 'Номер страницы' + }), + limit: fieldSchema(cFieldsSchema.shape.number, { + label: 'Лимит на странице' + }), + total: fieldSchema(cFieldsSchema.shape.number, { + label: 'Общее кол-во' + }), + skip: fieldSchema(cFieldsSchema.shape.number, { + label: 'Пропустить' + }), + pages: fieldSchema(cFieldsSchema.shape.number, { + label: 'Кол-во всех страниц' + }) +}) + +export type TPagination = z.infer + +// Константы. +const DEFAULTS = { page: 1, limit: 10, maxLimit: 100 } + +export type TPaginationParseArg = number | string | undefined + +export type TPaginationArguments = { + page?: TPaginationParseArg + limit?: TPaginationParseArg + total?: number +} + +export interface IPagination extends TPagination { + /** + * Максимальный лимит элементов. + */ + get maxLimit(): number + + /** + * Парсинг аргументов. + * @param arg - Значение аргумента для парсинга. + * @param defaultReturnValue - Возвращаемое значение по умолчанию. + * @returns Возвращает абсолютное значение числа аргумента. + */ + parseArg( + arg: number | string | undefined, + defaultReturnValue: number + ): number + + /** + * Присваивает значения для основных свойств класса и считает кол-во + * пропускаемых элементов в зависимости от полученных аргументов. + * @param args - Аргументы пагинации. + * @returns Возвращает текущий экземпляр класса. + */ + set(args: TPaginationArguments): this + + /** + * Возвращает простой объект пагинации. + * @returns Объект пагинации. + */ + toObject(): TPagination +} + +export class Pagination implements IPagination { + /** + * Максимальный лимит элементов. + */ + private _maxLimit: number + + page: number = DEFAULTS.page + limit: number = DEFAULTS.limit + + skip: number = 0 + total: number = 0 + pages: number = 0 + + static parseArg( + arg: TPaginationParseArg, + defaultReturnValue: number + ): number { + return Math.abs( + typeof arg === 'string' + ? Number.parseInt(arg) || defaultReturnValue + : arg || defaultReturnValue + ) + } + + constructor(args: TPaginationArguments = {}, maxLimit?: number) { + this._maxLimit = this.parseArg(maxLimit, DEFAULTS.maxLimit) + this.set(args) + } + + get maxLimit() { + return this._maxLimit + } + + parseArg(arg: TPaginationParseArg, defaultReturnValue: number): number { + return Pagination.parseArg(arg, defaultReturnValue) + } + + set(args: TPaginationArguments = {}): this { + let isCalcSkip: boolean = false + + // Страница. + if (args.page && args.page !== this.page) { + // Инициализипуем страницу. + this.page = this.parseArg(args.page, DEFAULTS.page) + isCalcSkip = true + } + + // Лимит. + if (args.limit && args.limit !== this.limit) { + this.limit = this.parseArg(args.limit, this.limit) + isCalcSkip = true + } + + // Проверка лимита. + if (this.limit > this.maxLimit) this.limit = this.maxLimit + + // Общее кол-во. + if (args.total && args.total !== this.total) { + this.total = Math.abs(args.total) + this.pages = Math.ceil(this.total / this.limit) + } + + // Перерасчёт пропускаемых элементов. + if (isCalcSkip) { + let skip = 0 + try { + skip = (this.page - 1) * this.limit + } catch (ex: any) {} + this.skip = skip > 0 ? skip : 0 + } + + return this + } + + toObject(): TPagination { + return { + page: this.page, + limit: this.limit, + total: this.total, + + skip: this.skip, + pages: this.pages + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8bf7fb5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "target": "ES6", + "module": "CommonJS", + "moduleResolution": "node", + "lib": ["es2017"], + "declaration": true, + "sourceMap": true + }, + "exclude": ["node_modules", "dist"] +}