feat(stage-c): implement full field types meta, dynamic renderer, and integration tests
This commit is contained in:
parent
1930e41530
commit
368ed1ed9d
9 changed files with 2651 additions and 174 deletions
|
|
@ -25,43 +25,65 @@ export interface BaseEntity {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FieldDataType =
|
export interface FileMetadata {
|
||||||
// Text group
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
url: string;
|
||||||
|
dataUrl?: string;
|
||||||
|
uploadedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StructuredAddress {
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
formatted?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoLocation {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CanonicalFieldType =
|
||||||
|
// 1. Text
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'long_text'
|
| 'long_text'
|
||||||
| 'rich_text'
|
| 'rich_text'
|
||||||
// Numeric group
|
// 2. Number
|
||||||
| 'number'
|
|
||||||
| 'integer'
|
| 'integer'
|
||||||
| 'decimal'
|
| 'decimal'
|
||||||
| 'money'
|
| 'money'
|
||||||
| 'percentage'
|
| 'percentage'
|
||||||
// Logic group
|
// 3. Boolean
|
||||||
| 'boolean'
|
| 'boolean'
|
||||||
// Date/Time group
|
// 4. Choice
|
||||||
| 'date'
|
|
||||||
| 'datetime'
|
|
||||||
| 'time'
|
|
||||||
// Selection group
|
|
||||||
| 'select'
|
| 'select'
|
||||||
| 'multiselect'
|
|
||||||
| 'multi_select'
|
| 'multi_select'
|
||||||
| 'radio'
|
| 'radio'
|
||||||
| 'checkbox_group'
|
| 'checkbox_group'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'rating'
|
| 'rating'
|
||||||
// Communication & Web group
|
// 5. DateTime
|
||||||
|
| 'date'
|
||||||
|
| 'datetime'
|
||||||
|
| 'time'
|
||||||
|
// 6. Communications
|
||||||
| 'phone'
|
| 'phone'
|
||||||
| 'email'
|
| 'email'
|
||||||
| 'url'
|
| 'url'
|
||||||
// Geographic group
|
// 7. Geo
|
||||||
| 'address'
|
| 'address'
|
||||||
| 'location'
|
| 'location'
|
||||||
// Media & Assets group
|
// 8. Media
|
||||||
| 'file'
|
| 'file'
|
||||||
| 'image'
|
| 'image'
|
||||||
| 'color'
|
| 'color'
|
||||||
// Relational & People group
|
// 9. Relational / Entity references
|
||||||
| 'user'
|
| 'user'
|
||||||
| 'employee'
|
| 'employee'
|
||||||
| 'team'
|
| 'team'
|
||||||
|
|
@ -69,12 +91,147 @@ export type FieldDataType =
|
||||||
| 'contact'
|
| 'contact'
|
||||||
| 'product'
|
| 'product'
|
||||||
| 'entity_relation'
|
| 'entity_relation'
|
||||||
| 'entity_ref'
|
// 10. Advanced / Computed
|
||||||
// Computed & Advanced group
|
|
||||||
| 'formula'
|
| 'formula'
|
||||||
| 'autonumber'
|
| 'autonumber'
|
||||||
| 'json';
|
| 'json';
|
||||||
|
|
||||||
|
export type LegacyFieldTypeAlias =
|
||||||
|
| 'number'
|
||||||
|
| 'single_select'
|
||||||
|
| 'multiselect'
|
||||||
|
| 'entity_ref'
|
||||||
|
| 'lookup';
|
||||||
|
|
||||||
|
export type FieldDataType = CanonicalFieldType | LegacyFieldTypeAlias;
|
||||||
|
|
||||||
|
export const CANONICAL_FIELD_TYPES: readonly CanonicalFieldType[] = [
|
||||||
|
'text',
|
||||||
|
'long_text',
|
||||||
|
'rich_text',
|
||||||
|
'integer',
|
||||||
|
'decimal',
|
||||||
|
'money',
|
||||||
|
'percentage',
|
||||||
|
'boolean',
|
||||||
|
'select',
|
||||||
|
'multi_select',
|
||||||
|
'radio',
|
||||||
|
'checkbox_group',
|
||||||
|
'status',
|
||||||
|
'rating',
|
||||||
|
'date',
|
||||||
|
'datetime',
|
||||||
|
'time',
|
||||||
|
'phone',
|
||||||
|
'email',
|
||||||
|
'url',
|
||||||
|
'address',
|
||||||
|
'location',
|
||||||
|
'file',
|
||||||
|
'image',
|
||||||
|
'color',
|
||||||
|
'user',
|
||||||
|
'employee',
|
||||||
|
'team',
|
||||||
|
'company',
|
||||||
|
'contact',
|
||||||
|
'product',
|
||||||
|
'entity_relation',
|
||||||
|
'formula',
|
||||||
|
'autonumber',
|
||||||
|
'json',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ALL_39_FIELD_TYPES: readonly (CanonicalFieldType | LegacyFieldTypeAlias)[] = [
|
||||||
|
// 1. Text (3)
|
||||||
|
'text',
|
||||||
|
'long_text',
|
||||||
|
'rich_text',
|
||||||
|
// 2. Number (4 canonical + 1 alias)
|
||||||
|
'integer',
|
||||||
|
'decimal',
|
||||||
|
'money',
|
||||||
|
'percentage',
|
||||||
|
'number',
|
||||||
|
// 3. Boolean (1)
|
||||||
|
'boolean',
|
||||||
|
// 4. Choice (6 canonical + 2 aliases)
|
||||||
|
'select',
|
||||||
|
'single_select',
|
||||||
|
'multi_select',
|
||||||
|
'multiselect',
|
||||||
|
'radio',
|
||||||
|
'checkbox_group',
|
||||||
|
'status',
|
||||||
|
'rating',
|
||||||
|
// 5. DateTime (3)
|
||||||
|
'date',
|
||||||
|
'datetime',
|
||||||
|
'time',
|
||||||
|
// 6. Communications (3)
|
||||||
|
'phone',
|
||||||
|
'email',
|
||||||
|
'url',
|
||||||
|
// 7. Geo (2)
|
||||||
|
'address',
|
||||||
|
'location',
|
||||||
|
// 8. Media (3)
|
||||||
|
'file',
|
||||||
|
'image',
|
||||||
|
'color',
|
||||||
|
// 9. Relational (7 canonical + 2 aliases)
|
||||||
|
'user',
|
||||||
|
'employee',
|
||||||
|
'team',
|
||||||
|
'company',
|
||||||
|
'contact',
|
||||||
|
'product',
|
||||||
|
'entity_relation',
|
||||||
|
'entity_ref',
|
||||||
|
'lookup',
|
||||||
|
// 10. Advanced (3)
|
||||||
|
'formula',
|
||||||
|
'autonumber',
|
||||||
|
'json',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function normalizeFieldType(type: string): CanonicalFieldType {
|
||||||
|
const t = (type || '').trim().toLowerCase();
|
||||||
|
switch (t) {
|
||||||
|
case 'number':
|
||||||
|
case 'int':
|
||||||
|
return 'integer';
|
||||||
|
case 'float':
|
||||||
|
case 'double':
|
||||||
|
return 'decimal';
|
||||||
|
case 'currency':
|
||||||
|
return 'money';
|
||||||
|
case 'percent':
|
||||||
|
return 'percentage';
|
||||||
|
case 'single_select':
|
||||||
|
return 'select';
|
||||||
|
case 'multiselect':
|
||||||
|
case 'multi-select':
|
||||||
|
case 'tags':
|
||||||
|
return 'multi_select';
|
||||||
|
case 'entity_ref':
|
||||||
|
case 'lookup':
|
||||||
|
case 'relation':
|
||||||
|
case 'reference':
|
||||||
|
return 'entity_relation';
|
||||||
|
case 'bool':
|
||||||
|
return 'boolean';
|
||||||
|
case 'link':
|
||||||
|
return 'url';
|
||||||
|
default:
|
||||||
|
if ((CANONICAL_FIELD_TYPES as readonly string[]).includes(t)) {
|
||||||
|
return t as CanonicalFieldType;
|
||||||
|
}
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface FieldOption {
|
export interface FieldOption {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
|
|
||||||
297
src/features/__tests__/fieldTypesContract.test.ts
Normal file
297
src/features/__tests__/fieldTypesContract.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
ALL_39_FIELD_TYPES,
|
||||||
|
CANONICAL_FIELD_TYPES,
|
||||||
|
normalizeFieldType,
|
||||||
|
FieldDataType,
|
||||||
|
} from '../../entities/types';
|
||||||
|
import {
|
||||||
|
getDefaultFieldValue,
|
||||||
|
validateFieldValue,
|
||||||
|
serializeFieldValue,
|
||||||
|
deserializeFieldValue,
|
||||||
|
formatFieldValue,
|
||||||
|
evaluateSafeFormula,
|
||||||
|
} from '../../shared/lib/fieldEngine';
|
||||||
|
import { getFieldTypeInfo } from '../settings/entities/fieldTypesMeta';
|
||||||
|
|
||||||
|
describe('Stage C: Field Types Contract Suite across All 39 Types', () => {
|
||||||
|
it('contains exactly 35 canonical field types and all 39 types in catalog registry', () => {
|
||||||
|
expect(CANONICAL_FIELD_TYPES).toHaveLength(35);
|
||||||
|
expect(ALL_39_FIELD_TYPES.length).toBeGreaterThanOrEqual(39);
|
||||||
|
|
||||||
|
// Verify all canonical types are unique
|
||||||
|
const uniqueCanonical = new Set(CANONICAL_FIELD_TYPES);
|
||||||
|
expect(uniqueCanonical.size).toBe(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each(ALL_39_FIELD_TYPES)('Field Contract for type "%s"', (fieldType) => {
|
||||||
|
it('normalizes to a canonical field type', () => {
|
||||||
|
const canonical = normalizeFieldType(fieldType);
|
||||||
|
expect(CANONICAL_FIELD_TYPES).toContain(canonical);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a valid default value', () => {
|
||||||
|
const defaultVal = getDefaultFieldValue(fieldType);
|
||||||
|
expect(defaultVal).toBeDefined();
|
||||||
|
|
||||||
|
const canonical = normalizeFieldType(fieldType);
|
||||||
|
if (canonical === 'boolean') {
|
||||||
|
expect(typeof defaultVal).toBe('boolean');
|
||||||
|
} else if (canonical === 'integer' || canonical === 'decimal' || canonical === 'money' || canonical === 'percentage') {
|
||||||
|
expect(typeof defaultVal).toBe('number');
|
||||||
|
} else if (canonical === 'rating') {
|
||||||
|
expect(defaultVal).toBe(5);
|
||||||
|
} else if (canonical === 'multi_select' || canonical === 'checkbox_group') {
|
||||||
|
expect(Array.isArray(defaultVal)).toBe(true);
|
||||||
|
} else if (canonical === 'file' || canonical === 'image') {
|
||||||
|
expect(defaultVal).toBeNull();
|
||||||
|
} else if (canonical === 'address') {
|
||||||
|
expect(typeof defaultVal).toBe('object');
|
||||||
|
} else if (canonical === 'location') {
|
||||||
|
expect(typeof defaultVal).toBe('object');
|
||||||
|
} else {
|
||||||
|
expect(typeof defaultVal).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes validation for valid default or sample values', () => {
|
||||||
|
let sampleVal: unknown = getDefaultFieldValue(fieldType);
|
||||||
|
const canonical = normalizeFieldType(fieldType);
|
||||||
|
|
||||||
|
if (canonical === 'email') sampleVal = 'user@company.ru';
|
||||||
|
if (canonical === 'phone') sampleVal = '+7 (999) 123-45-67';
|
||||||
|
if (canonical === 'url') sampleVal = 'https://krona.io';
|
||||||
|
if (canonical === 'time') sampleVal = '14:30';
|
||||||
|
if (canonical === 'color') sampleVal = '#2F684C';
|
||||||
|
if (canonical === 'location') sampleVal = { lat: 55.7558, lng: 37.6173 };
|
||||||
|
if (canonical === 'json') sampleVal = '{"test": 123}';
|
||||||
|
if (canonical === 'multi_select' || canonical === 'checkbox_group') sampleVal = ['opt_1', 'opt_2'];
|
||||||
|
if (canonical === 'rating') sampleVal = 4;
|
||||||
|
|
||||||
|
const result = validateFieldValue(fieldType, sampleVal);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required constraint when value is empty', () => {
|
||||||
|
const result = validateFieldValue(fieldType, '', undefined, { required: true });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('Обязательное поле для заполнения');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through serialize and deserialize', () => {
|
||||||
|
let originalVal: unknown;
|
||||||
|
const canonical = normalizeFieldType(fieldType);
|
||||||
|
|
||||||
|
switch (canonical) {
|
||||||
|
case 'integer':
|
||||||
|
originalVal = 42;
|
||||||
|
break;
|
||||||
|
case 'decimal':
|
||||||
|
case 'percentage':
|
||||||
|
originalVal = 19.99;
|
||||||
|
break;
|
||||||
|
case 'money':
|
||||||
|
originalVal = 150000; // 1500.00 RUB in minor units
|
||||||
|
break;
|
||||||
|
case 'boolean':
|
||||||
|
originalVal = true;
|
||||||
|
break;
|
||||||
|
case 'multi_select':
|
||||||
|
case 'checkbox_group':
|
||||||
|
originalVal = ['alpha', 'beta'];
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
originalVal = '{"key":"value"}';
|
||||||
|
break;
|
||||||
|
case 'location':
|
||||||
|
originalVal = { lat: 55.75, lng: 37.61 };
|
||||||
|
break;
|
||||||
|
case 'address':
|
||||||
|
originalVal = { street: 'Main St 1', city: 'Moscow', postalCode: '101000', country: 'RU' };
|
||||||
|
break;
|
||||||
|
case 'color':
|
||||||
|
originalVal = '#2F684C';
|
||||||
|
break;
|
||||||
|
case 'rating':
|
||||||
|
originalVal = 5;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
originalVal = 'тестовое значение';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = serializeFieldValue(fieldType, originalVal);
|
||||||
|
const deserialized = deserializeFieldValue(fieldType, serialized);
|
||||||
|
|
||||||
|
if (canonical === 'json') {
|
||||||
|
expect(typeof deserialized).toBe('string');
|
||||||
|
expect(JSON.parse(deserialized as string)).toEqual({ key: 'value' });
|
||||||
|
} else if (typeof originalVal === 'object' && originalVal !== null) {
|
||||||
|
expect(deserialized).toMatchObject(originalVal as object);
|
||||||
|
} else {
|
||||||
|
expect(deserialized).toEqual(originalVal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats values into non-empty display strings', () => {
|
||||||
|
let val: unknown = 'тест';
|
||||||
|
const canonical = normalizeFieldType(fieldType);
|
||||||
|
|
||||||
|
if (canonical === 'money') val = 125000;
|
||||||
|
if (canonical === 'rating') val = 4;
|
||||||
|
if (canonical === 'boolean') val = true;
|
||||||
|
if (canonical === 'date') val = '2026-09-11';
|
||||||
|
if (canonical === 'datetime') val = '2026-09-11T12:00:00Z';
|
||||||
|
if (canonical === 'multi_select' || canonical === 'checkbox_group') val = ['A', 'B'];
|
||||||
|
|
||||||
|
const formatted = formatFieldValue(fieldType as FieldDataType, val);
|
||||||
|
expect(formatted).toBeDefined();
|
||||||
|
expect(formatted.length).toBeGreaterThan(0);
|
||||||
|
expect(formatted).not.toBe('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves metadata info from catalog', () => {
|
||||||
|
const info = getFieldTypeInfo(fieldType);
|
||||||
|
expect(info).toBeDefined();
|
||||||
|
expect(info.label).toBeDefined();
|
||||||
|
expect(info.category).toBeDefined();
|
||||||
|
expect(info.icon).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Legacy Alias Normalization', () => {
|
||||||
|
it('normalizes legacy "number" alias to "integer"', () => {
|
||||||
|
expect(normalizeFieldType('number')).toBe('integer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes legacy "single_select" alias to "select"', () => {
|
||||||
|
expect(normalizeFieldType('single_select')).toBe('select');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes legacy "multiselect" and "multi-select" aliases to "multi_select"', () => {
|
||||||
|
expect(normalizeFieldType('multiselect')).toBe('multi_select');
|
||||||
|
expect(normalizeFieldType('multi-select')).toBe('multi_select');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes legacy "entity_ref" and "lookup" aliases to "entity_relation"', () => {
|
||||||
|
expect(normalizeFieldType('entity_ref')).toBe('entity_relation');
|
||||||
|
expect(normalizeFieldType('lookup')).toBe('entity_relation');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AST Safe Formula Engine (NO eval, NO Function)', () => {
|
||||||
|
it('evaluates basic arithmetic expressions accurately', () => {
|
||||||
|
expect(evaluateSafeFormula('10 + 20 * 2', {})).toBe(50);
|
||||||
|
expect(evaluateSafeFormula('(10 + 20) * 2', {})).toBe(60);
|
||||||
|
expect(evaluateSafeFormula('100 / 4 - 5', {})).toBe(20);
|
||||||
|
expect(evaluateSafeFormula('17 % 5', {})).toBe(2);
|
||||||
|
expect(evaluateSafeFormula('-15 + 25', {})).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaluates expressions with field references', () => {
|
||||||
|
const context = {
|
||||||
|
qty: 10,
|
||||||
|
price: 250,
|
||||||
|
discount: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSafeFormula('{qty} * {price} * (1 - {discount})', context);
|
||||||
|
expect(result).toBe(2250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaluates supported math functions (round, floor, ceil, abs, min, max, sqrt)', () => {
|
||||||
|
expect(evaluateSafeFormula('round(10.556, 2)', {})).toBe(10.56);
|
||||||
|
expect(evaluateSafeFormula('floor(9.99)', {})).toBe(9);
|
||||||
|
expect(evaluateSafeFormula('ceil(9.01)', {})).toBe(10);
|
||||||
|
expect(evaluateSafeFormula('abs(-42)', {})).toBe(42);
|
||||||
|
expect(evaluateSafeFormula('min(10, 5, 20)', {})).toBe(5);
|
||||||
|
expect(evaluateSafeFormula('max(10, 5, 20)', {})).toBe(20);
|
||||||
|
expect(evaluateSafeFormula('sqrt(144)', {})).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('safely returns null on division by zero without crashing', () => {
|
||||||
|
expect(evaluateSafeFormula('10 / 0', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('10 % 0', {})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strictly blocks code injection and forbidden words (eval, Function, window, process, etc.)', () => {
|
||||||
|
// Must return null and never execute
|
||||||
|
expect(evaluateSafeFormula('eval("1+1")', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('Function("return 1")()', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('window.location = "http://evil.com"', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('document.cookie', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('process.exit(1)', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('globalThis.evil = true', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('constructor.constructor("alert(1)")()', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('import("fs")', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('while(true){}', {})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty or invalid formulas', () => {
|
||||||
|
expect(evaluateSafeFormula('', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula(' ', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('++--', {})).toBeNull();
|
||||||
|
expect(evaluateSafeFormula('{unclosed_bracket', {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Detailed Type-Specific Validation Rules', () => {
|
||||||
|
it('validates integer constraints', () => {
|
||||||
|
expect(validateFieldValue('integer', 15).valid).toBe(true);
|
||||||
|
expect(validateFieldValue('integer', 15.5).valid).toBe(false);
|
||||||
|
expect(validateFieldValue('integer', 'not-a-number').valid).toBe(false);
|
||||||
|
expect(validateFieldValue('integer', 3, { min: 5 }).valid).toBe(false);
|
||||||
|
expect(validateFieldValue('integer', 12, { max: 10 }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates email format', () => {
|
||||||
|
expect(validateFieldValue('email', 'admin@krona.ru').valid).toBe(true);
|
||||||
|
expect(validateFieldValue('email', 'invalid-email').valid).toBe(false);
|
||||||
|
expect(validateFieldValue('email', 'user@').valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates url format', () => {
|
||||||
|
expect(validateFieldValue('url', 'https://krona.io').valid).toBe(true);
|
||||||
|
expect(validateFieldValue('url', 'http://localhost:3000').valid).toBe(true);
|
||||||
|
expect(validateFieldValue('url', 'javascript:alert(1)').valid).toBe(false);
|
||||||
|
expect(validateFieldValue('url', 'not-a-url').valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates color HEX format', () => {
|
||||||
|
expect(validateFieldValue('color', '#2F684C').valid).toBe(true);
|
||||||
|
expect(validateFieldValue('color', '#fff').valid).toBe(true);
|
||||||
|
expect(validateFieldValue('color', 'red').valid).toBe(false);
|
||||||
|
expect(validateFieldValue('color', '#12345678').valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates rating range (1-5)', () => {
|
||||||
|
expect(validateFieldValue('rating', 1).valid).toBe(true);
|
||||||
|
expect(validateFieldValue('rating', 5).valid).toBe(true);
|
||||||
|
expect(validateFieldValue('rating', 0).valid).toBe(false);
|
||||||
|
expect(validateFieldValue('rating', 6).valid).toBe(false);
|
||||||
|
expect(validateFieldValue('rating', 3.5).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates json syntax', () => {
|
||||||
|
expect(validateFieldValue('json', '{"valid": true}').valid).toBe(true);
|
||||||
|
expect(validateFieldValue('json', '{invalid json').valid).toBe(false);
|
||||||
|
expect(validateFieldValue('json', { already: 'object' }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates location bounds (lat -90..90, lng -180..180)', () => {
|
||||||
|
expect(validateFieldValue('location', { lat: 55.75, lng: 37.61 }).valid).toBe(true);
|
||||||
|
expect(validateFieldValue('location', { lat: 95, lng: 37.61 }).valid).toBe(false);
|
||||||
|
expect(validateFieldValue('location', { lat: 55.75, lng: 200 }).valid).toBe(false);
|
||||||
|
expect(validateFieldValue('location', '55.75, 37.61').valid).toBe(true);
|
||||||
|
expect(validateFieldValue('location', 'invalid').valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates multi_select and checkbox_group array structure', () => {
|
||||||
|
expect(validateFieldValue('multi_select', ['item1', 'item2']).valid).toBe(true);
|
||||||
|
expect(validateFieldValue('multi_select', 'not-an-array').valid).toBe(false);
|
||||||
|
expect(validateFieldValue('checkbox_group', ['item1']).valid).toBe(true);
|
||||||
|
expect(validateFieldValue('checkbox_group', 123).valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
308
src/features/__tests__/fieldTypesIntegration.test.tsx
Normal file
308
src/features/__tests__/fieldTypesIntegration.test.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { DynamicFormRenderer } from '../metadata/DynamicFormRenderer';
|
||||||
|
import { DynamicCardRenderer } from '../metadata/DynamicCardRenderer';
|
||||||
|
import { EntityMetadata } from '../../entities/types';
|
||||||
|
import { putRecord, getRecord, resetDatabase, RecordEntity } from '../../services/storage/indexedDb';
|
||||||
|
import { ulid } from '../../shared/lib/ulid';
|
||||||
|
|
||||||
|
describe('Stage C: Field Types Integration (Form -> Save -> IndexedDB -> Card)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
const testEntity: EntityMetadata = {
|
||||||
|
id: 'ent_product_spec',
|
||||||
|
code: 'product_spec',
|
||||||
|
name: 'Спецификация продукта',
|
||||||
|
pluralName: 'Спецификации',
|
||||||
|
category: 'custom',
|
||||||
|
isSystem: false,
|
||||||
|
maturity: 'target_prototype',
|
||||||
|
primaryFieldKey: 'title',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'f_title',
|
||||||
|
key: 'title',
|
||||||
|
label: 'Название продукта',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_budget',
|
||||||
|
key: 'budget',
|
||||||
|
label: 'Бюджет поставки',
|
||||||
|
type: 'money',
|
||||||
|
placeholder: '0.00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_rating',
|
||||||
|
key: 'rating',
|
||||||
|
label: 'Приоритет качества',
|
||||||
|
type: 'rating',
|
||||||
|
defaultValue: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_tags',
|
||||||
|
key: 'tags',
|
||||||
|
label: 'Категории продукта',
|
||||||
|
type: 'multi_select',
|
||||||
|
options: [
|
||||||
|
{ value: 'hardware', label: 'Оборудование' },
|
||||||
|
{ value: 'software', label: 'Программное обеспечение' },
|
||||||
|
{ value: 'cloud', label: 'Облачные сервисы' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_color',
|
||||||
|
key: 'brand_color',
|
||||||
|
label: 'Фирменный цвет',
|
||||||
|
type: 'color',
|
||||||
|
defaultValue: '#2F684C',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_qty',
|
||||||
|
key: 'qty',
|
||||||
|
label: 'Количество',
|
||||||
|
type: 'integer',
|
||||||
|
defaultValue: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_price',
|
||||||
|
key: 'unit_price',
|
||||||
|
label: 'Цена за единицу',
|
||||||
|
type: 'decimal',
|
||||||
|
defaultValue: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_total',
|
||||||
|
key: 'total_cost',
|
||||||
|
label: 'Расчетная стоимость',
|
||||||
|
type: 'formula',
|
||||||
|
formula: '{qty} * {unit_price}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_json_config',
|
||||||
|
key: 'config',
|
||||||
|
label: 'Конфигурация (JSON)',
|
||||||
|
type: 'json',
|
||||||
|
defaultValue: '{"mode": "auto"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_is_active',
|
||||||
|
key: 'is_active',
|
||||||
|
label: 'Активен',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_location',
|
||||||
|
key: 'warehouse_loc',
|
||||||
|
label: 'Координаты склада',
|
||||||
|
type: 'location',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'f_address',
|
||||||
|
key: 'warehouse_addr',
|
||||||
|
label: 'Адрес склада',
|
||||||
|
type: 'address',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders specialized input controls for diverse field types', () => {
|
||||||
|
render(
|
||||||
|
<DynamicFormRenderer
|
||||||
|
entity={testEntity}
|
||||||
|
mode="create"
|
||||||
|
onSave={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Text & Money
|
||||||
|
expect(screen.getByText('Название продукта')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Бюджет поставки')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Rating (5 star buttons with accessible labels)
|
||||||
|
expect(screen.getByText('Приоритет качества')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Оценка 1 из 5 звёзд')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Оценка 5 из 5 звёзд')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Multi-select
|
||||||
|
expect(screen.getByText('Категории продукта')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('+ Добавить вариант...')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Formula & Real-time AST evaluation preview
|
||||||
|
expect(screen.getByText('Расчетная стоимость')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Вычисляемое поле')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('f(x) = {qty} * {unit_price}')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// JSON syntax validator badge
|
||||||
|
expect(screen.getByText('JSON валиден')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Boolean
|
||||||
|
expect(screen.getByText('Да (активно)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interacts with rating, multi-select chips, color, and updates dynamic formula', async () => {
|
||||||
|
let savedData: Record<string, unknown> | null = null;
|
||||||
|
const handleSave = (values: Record<string, unknown>) => {
|
||||||
|
savedData = values;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DynamicFormRenderer
|
||||||
|
entity={testEntity}
|
||||||
|
mode="create"
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Fill title
|
||||||
|
const titleInputs = screen.getAllByRole('textbox');
|
||||||
|
fireEvent.change(titleInputs[0], { target: { value: 'Сервер Krona Rack V2' } });
|
||||||
|
|
||||||
|
// 2. Click 5th star for rating
|
||||||
|
const star5 = screen.getByLabelText('Оценка 5 из 5 звёзд');
|
||||||
|
fireEvent.click(star5);
|
||||||
|
expect(screen.getByText('5 / 5')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 3. Add multi-select option chip
|
||||||
|
const multiSelect = screen.getByRole('combobox');
|
||||||
|
fireEvent.change(multiSelect, { target: { value: 'hardware' } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Оборудование')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Update formula inputs: qty = 20, unit_price = 150 -> total should be 3000
|
||||||
|
const numberInputs = screen.getAllByRole('spinbutton');
|
||||||
|
// qty is one of the number inputs
|
||||||
|
const qtyInput = numberInputs.find((inp) => (inp as HTMLInputElement).value === '5');
|
||||||
|
if (qtyInput) {
|
||||||
|
fireEvent.change(qtyInput, { target: { value: '20' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Formula AST calculates 20 * 100 = 2000
|
||||||
|
expect(screen.getByText('2 000')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Submit form
|
||||||
|
const submitBtn = screen.getByTestId('dynamic-form-submit');
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(savedData).not.toBeNull();
|
||||||
|
expect(savedData?.title).toBe('Сервер Krona Rack V2');
|
||||||
|
expect(savedData?.rating).toBe(5);
|
||||||
|
expect(savedData?.tags).toEqual(['hardware']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates JSON live syntax and reports error on invalid syntax', async () => {
|
||||||
|
render(
|
||||||
|
<DynamicFormRenderer
|
||||||
|
entity={testEntity}
|
||||||
|
mode="create"
|
||||||
|
onSave={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find JSON textarea
|
||||||
|
const textareas = screen.getAllByRole('textbox');
|
||||||
|
const jsonTextarea = textareas.find((t) => (t as HTMLTextAreaElement).value.includes('"mode"'));
|
||||||
|
expect(jsonTextarea).toBeDefined();
|
||||||
|
|
||||||
|
if (jsonTextarea) {
|
||||||
|
// Type broken JSON
|
||||||
|
fireEvent.change(jsonTextarea, { target: { value: '{"mode": broken_json' } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Ошибка синтаксиса JSON')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix JSON
|
||||||
|
fireEvent.change(jsonTextarea, { target: { value: '{"mode": "manual", "cluster": 3}' } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('JSON валиден')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes full round-trip: Form input -> IndexedDB putRecord -> DynamicCardRenderer view', async () => {
|
||||||
|
const recordId = ulid();
|
||||||
|
let savedRecord: RecordEntity | null = null;
|
||||||
|
|
||||||
|
const handleSave = async (values: Record<string, unknown>) => {
|
||||||
|
const recordToSave: RecordEntity = {
|
||||||
|
id: recordId,
|
||||||
|
entityType: 'product_spec',
|
||||||
|
...values,
|
||||||
|
audit: {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdById: 'user_01',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedById: 'user_01',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
savedRecord = await putRecord(recordToSave);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<DynamicFormRenderer
|
||||||
|
entity={testEntity}
|
||||||
|
mode="create"
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Fill fields in form
|
||||||
|
const titleInputs = screen.getAllByRole('textbox');
|
||||||
|
fireEvent.change(titleInputs[0], { target: { value: 'Рабочая станция Krona Pro' } });
|
||||||
|
|
||||||
|
// Budget: 75000 RUB -> 7500000 minor units
|
||||||
|
const budgetInput = screen.getByPlaceholderText('0.00');
|
||||||
|
fireEvent.change(budgetInput, { target: { value: '75000' } });
|
||||||
|
|
||||||
|
// Set rating to 4
|
||||||
|
const star4 = screen.getByLabelText('Оценка 4 из 5 звёзд');
|
||||||
|
fireEvent.click(star4);
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
const submitBtn = screen.getByTestId('dynamic-form-submit');
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(savedRecord).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// 2. Fetch record from IndexedDB directly to ensure persistence
|
||||||
|
const loadedRecord = await getRecord(recordId);
|
||||||
|
expect(loadedRecord).not.toBeNull();
|
||||||
|
expect(loadedRecord?.title).toBe('Рабочая станция Krona Pro');
|
||||||
|
expect(loadedRecord?.budget).toBe(7500000);
|
||||||
|
expect(loadedRecord?.rating).toBe(4);
|
||||||
|
|
||||||
|
// 3. Render loaded record in DynamicCardRenderer
|
||||||
|
render(
|
||||||
|
<DynamicCardRenderer
|
||||||
|
entity={testEntity}
|
||||||
|
record={loadedRecord!}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify rich card readout
|
||||||
|
expect(screen.getAllByText('Рабочая станция Krona Pro').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('75 000,00 ₽')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('4/5')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('v1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { EntityMetadata } from '../../entities/types';
|
import {
|
||||||
|
EntityMetadata,
|
||||||
|
FieldDefinition,
|
||||||
|
FileMetadata,
|
||||||
|
StructuredAddress,
|
||||||
|
GeoLocation,
|
||||||
|
normalizeFieldType,
|
||||||
|
} from '../../entities/types';
|
||||||
import { LayoutDefinition } from '../../services/contracts/metadata';
|
import { LayoutDefinition } from '../../services/contracts/metadata';
|
||||||
import { RecordEntity, getDb } from '../../services/storage/indexedDb';
|
import { RecordEntity, getDb } from '../../services/storage/indexedDb';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { Badge } from '../../shared/ui/Badge';
|
import { Badge } from '../../shared/ui/Badge';
|
||||||
import { formatCurrencyMinor } from '../../shared/lib/currency';
|
import { formatCurrencyMinor } from '../../shared/lib/currency';
|
||||||
import { formatDateTime } from '../../shared/lib/date';
|
import { formatDate, formatDateTime } from '../../shared/lib/date';
|
||||||
|
import { evaluateSafeFormula } from '../../shared/lib/fieldEngine';
|
||||||
import {
|
import {
|
||||||
Edit2,
|
Edit2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
|
@ -15,6 +23,14 @@ import {
|
||||||
Layers,
|
Layers,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
|
Star,
|
||||||
|
ExternalLink,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Navigation,
|
||||||
|
FileText,
|
||||||
|
Binary,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useToast } from '../../shared/ui/useToast';
|
import { useToast } from '../../shared/ui/useToast';
|
||||||
|
|
||||||
|
|
@ -75,7 +91,253 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
|
||||||
setTimeout(() => setCopiedJson(false), 2000);
|
setTimeout(() => setCopiedJson(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const recordTitle = String(record.name || record.title || record[entity.primaryFieldKey || 'name'] || record.id);
|
const recordTitle = String(
|
||||||
|
record.name || record.title || record[entity.primaryFieldKey || 'name'] || record.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rich render function for any field
|
||||||
|
const renderFieldValue = (f: FieldDefinition) => {
|
||||||
|
const val = record[f.key];
|
||||||
|
const canonical = normalizeFieldType(f.type);
|
||||||
|
|
||||||
|
if (val === undefined || val === null || val === '') {
|
||||||
|
return <span className="text-krona-secondary/60 font-normal">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rating
|
||||||
|
if (canonical === 'rating') {
|
||||||
|
const r = Math.min(5, Math.max(1, Number(val) || 5));
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`w-3.5 h-3.5 ${
|
||||||
|
i <= r ? 'fill-amber-400 text-amber-400' : 'text-krona-secondary opacity-30'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="text-[11px] font-semibold text-krona-primary ml-1">{r}/5</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color
|
||||||
|
if (canonical === 'color') {
|
||||||
|
const hex = String(val);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-4 h-4 rounded border border-krona-border/60 shrink-0"
|
||||||
|
style={{ backgroundColor: hex }}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs">{hex}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL
|
||||||
|
if (canonical === 'url') {
|
||||||
|
const urlStr = String(val);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={urlStr}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-krona-green hover:underline inline-flex items-center gap-1 break-all"
|
||||||
|
>
|
||||||
|
{urlStr}
|
||||||
|
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email
|
||||||
|
if (canonical === 'email') {
|
||||||
|
const emailStr = String(val);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`mailto:${emailStr}`}
|
||||||
|
className="text-krona-green hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Mail className="w-3 h-3 shrink-0" />
|
||||||
|
{emailStr}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone
|
||||||
|
if (canonical === 'phone') {
|
||||||
|
const phoneStr = String(val);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`tel:${phoneStr.replace(/\D/g, '')}`}
|
||||||
|
className="text-krona-green hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Phone className="w-3 h-3 shrink-0" />
|
||||||
|
{phoneStr}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-Select or Checkbox Group
|
||||||
|
if (canonical === 'multi_select' || canonical === 'checkbox_group') {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
if (val.length === 0) return <span className="text-krona-secondary/60">—</span>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{val.map((item) => {
|
||||||
|
const opt = f.options?.find((o) => o.value === item);
|
||||||
|
return (
|
||||||
|
<Badge key={String(item)} variant="neutral" size="sm">
|
||||||
|
{opt?.label || String(item)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{String(val)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money
|
||||||
|
if (canonical === 'money') {
|
||||||
|
const minor = typeof val === 'number' ? val : 0;
|
||||||
|
return <span className="font-semibold text-krona-primary">{formatCurrencyMinor(minor)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percentage
|
||||||
|
if (canonical === 'percentage') {
|
||||||
|
return <span className="font-semibold text-krona-primary">{String(val)}%</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean
|
||||||
|
if (canonical === 'boolean') {
|
||||||
|
return (
|
||||||
|
<Badge variant={val ? 'green' : 'neutral'} size="sm">
|
||||||
|
{val ? 'Да' : 'Нет'}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date & Datetime
|
||||||
|
if (canonical === 'date' || canonical === 'datetime') {
|
||||||
|
return <span>{canonical === 'datetime' ? formatDateTime(String(val)) : formatDate(String(val))}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time
|
||||||
|
if (canonical === 'time') {
|
||||||
|
return <span className="font-mono">{String(val)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status
|
||||||
|
if (canonical === 'status') {
|
||||||
|
const opt = f.options?.find((o) => o.value === val);
|
||||||
|
return (
|
||||||
|
<Badge variant="green" size="sm">
|
||||||
|
{opt?.label || String(val)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select & Radio
|
||||||
|
if (canonical === 'select' || canonical === 'radio') {
|
||||||
|
const opt = f.options?.find((o) => o.value === val);
|
||||||
|
return <span>{opt?.label || String(val)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File / Image
|
||||||
|
if (canonical === 'file' || canonical === 'image') {
|
||||||
|
if (typeof val === 'object' && val !== null && 'name' in val) {
|
||||||
|
const fileMeta = val as FileMetadata;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canonical === 'image' && fileMeta.url ? (
|
||||||
|
<img
|
||||||
|
src={fileMeta.url}
|
||||||
|
alt={fileMeta.name}
|
||||||
|
className="w-8 h-8 rounded object-cover border border-krona-border shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileText className="w-4 h-4 text-krona-green shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="truncate">
|
||||||
|
<span className="font-semibold text-krona-primary truncate block">{fileMeta.name}</span>
|
||||||
|
<span className="text-[10px] text-krona-secondary font-mono">
|
||||||
|
{(fileMeta.size / 1024).toFixed(1)} КБ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{String(val)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address
|
||||||
|
if (canonical === 'address') {
|
||||||
|
if (typeof val === 'object' && val !== null) {
|
||||||
|
const a = val as StructuredAddress;
|
||||||
|
const parts = [a.city, a.street, a.postalCode, a.country].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-krona-green shrink-0" />
|
||||||
|
<span>{parts.join(', ') || '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-krona-green shrink-0" />
|
||||||
|
<span>{String(val)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location
|
||||||
|
if (canonical === 'location') {
|
||||||
|
if (typeof val === 'object' && val !== null) {
|
||||||
|
const loc = val as GeoLocation;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 font-mono text-xs">
|
||||||
|
<Navigation className="w-3.5 h-3.5 text-krona-green shrink-0" />
|
||||||
|
<span>{loc.lat.toFixed(4)}, {loc.lng.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{String(val)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formula
|
||||||
|
if (canonical === 'formula') {
|
||||||
|
const computed = f.formula ? evaluateSafeFormula(f.formula, record) : val;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Binary className="w-3.5 h-3.5 text-krona-green shrink-0" />
|
||||||
|
<span className="font-mono font-bold text-krona-primary">
|
||||||
|
{computed !== null ? String(computed) : String(val)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON
|
||||||
|
if (canonical === 'json') {
|
||||||
|
const jsonStr = typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||||
|
return (
|
||||||
|
<pre className="p-1 bg-krona-workspace rounded text-[11px] font-mono text-krona-primary truncate max-w-xs">
|
||||||
|
{jsonStr}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autonumber
|
||||||
|
if (canonical === 'autonumber') {
|
||||||
|
return <span className="font-mono font-bold text-krona-green">{String(val)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback default string representation
|
||||||
|
return <span>{String(val)}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 text-left">
|
<div className="space-y-4 text-left">
|
||||||
|
|
@ -179,45 +441,19 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-xs">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-xs">
|
||||||
{entity.fields.map((f) => {
|
{entity.fields.map((f) => (
|
||||||
const val = record[f.key];
|
<div
|
||||||
|
key={f.key}
|
||||||
let displayVal = '—';
|
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
|
||||||
if (val !== undefined && val !== null && val !== '') {
|
>
|
||||||
if (f.type === 'money') {
|
<span className="text-[11px] text-krona-secondary block mb-1">
|
||||||
displayVal = typeof val === 'number' ? formatCurrencyMinor(val) : String(val);
|
{f.label}
|
||||||
} else if (f.type === 'boolean') {
|
</span>
|
||||||
displayVal = val ? 'Да' : 'Нет';
|
<div className="font-semibold text-krona-primary break-words">
|
||||||
} else if (f.type === 'date' || f.type === 'datetime') {
|
{renderFieldValue(f)}
|
||||||
displayVal = formatDateTime(String(val));
|
|
||||||
} else if (f.type === 'status' || f.type === 'select') {
|
|
||||||
const opt = f.options?.find((o) => o.value === val);
|
|
||||||
displayVal = opt?.label || String(val);
|
|
||||||
} else {
|
|
||||||
displayVal = String(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={f.key}
|
|
||||||
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
|
|
||||||
>
|
|
||||||
<span className="text-[11px] text-krona-secondary block mb-1">
|
|
||||||
{f.label}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-krona-primary break-words">
|
|
||||||
{f.type === 'status' ? (
|
|
||||||
<Badge variant="neutral" size="sm">
|
|
||||||
{displayVal}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
displayVal
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,41 @@
|
||||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { EntityMetadata, FieldDefinition } from '../../entities/types';
|
import {
|
||||||
|
EntityMetadata,
|
||||||
|
FieldDefinition,
|
||||||
|
FileMetadata,
|
||||||
|
StructuredAddress,
|
||||||
|
GeoLocation,
|
||||||
|
normalizeFieldType,
|
||||||
|
} from '../../entities/types';
|
||||||
import { LayoutDefinition, LayoutSection } from '../../services/contracts/metadata';
|
import { LayoutDefinition, LayoutSection } from '../../services/contracts/metadata';
|
||||||
import { RecordEntity } from '../../services/storage/indexedDb';
|
import { RecordEntity } from '../../services/storage/indexedDb';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
|
import { Badge } from '../../shared/ui/Badge';
|
||||||
import { formatCurrencyMinor, parseCurrencyToMinor } from '../../shared/lib/currency';
|
import { formatCurrencyMinor, parseCurrencyToMinor } from '../../shared/lib/currency';
|
||||||
|
import {
|
||||||
|
getDefaultFieldValue,
|
||||||
|
validateFieldValue,
|
||||||
|
evaluateSafeFormula,
|
||||||
|
serializeFieldValue,
|
||||||
|
} from '../../shared/lib/fieldEngine';
|
||||||
|
import { ulid } from '../../shared/lib/ulid';
|
||||||
import { useToast } from '../../shared/ui/useToast';
|
import { useToast } from '../../shared/ui/useToast';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Star,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
ExternalLink,
|
||||||
|
MapPin,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
List as ListIcon,
|
||||||
|
Quote,
|
||||||
|
FileText,
|
||||||
|
Binary,
|
||||||
|
Code as CodeIcon,
|
||||||
|
Navigation,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
export interface DynamicFormRendererProps {
|
export interface DynamicFormRendererProps {
|
||||||
entity: EntityMetadata;
|
entity: EntityMetadata;
|
||||||
|
|
@ -17,6 +47,51 @@ export interface DynamicFormRendererProps {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preset relation options when field definition doesn't have custom options
|
||||||
|
const DEFAULT_RELATION_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
||||||
|
user: [
|
||||||
|
{ value: 'usr_01', label: 'Иван Иванов (Администратор)' },
|
||||||
|
{ value: 'usr_02', label: 'Анна Смирнова (Менеджер)' },
|
||||||
|
{ value: 'usr_03', label: 'Михаил Соколов (Аналитик)' },
|
||||||
|
{ value: 'usr_04', label: 'Елена Кузнецова (Оператор)' },
|
||||||
|
],
|
||||||
|
employee: [
|
||||||
|
{ value: 'emp_01', label: 'Дмитрий Кузнецов (Ведущий инженер)' },
|
||||||
|
{ value: 'emp_02', label: 'Ольга Морозова (Менеджер проекта)' },
|
||||||
|
{ value: 'emp_03', label: 'Сергей Попов (Разработчик)' },
|
||||||
|
],
|
||||||
|
team: [
|
||||||
|
{ value: 'team_01', label: 'Отдел продаж и работы с клиентами' },
|
||||||
|
{ value: 'team_02', label: 'Департамент разработки ПО' },
|
||||||
|
{ value: 'team_03', label: 'Служба логистики и снабжения' },
|
||||||
|
{ value: 'team_04', label: 'Финансово-экономический отдел' },
|
||||||
|
],
|
||||||
|
company: [
|
||||||
|
{ value: 'comp_01', label: 'ООО «Северная Звезда»' },
|
||||||
|
{ value: 'comp_02', label: 'АО «ПромИнвестХолдинг»' },
|
||||||
|
{ value: 'comp_03', label: 'ПАО «ТехноСфера»' },
|
||||||
|
{ value: 'comp_04', label: 'ИП Сидоров А.В.' },
|
||||||
|
],
|
||||||
|
contact: [
|
||||||
|
{ value: 'cont_01', label: 'Алексей Петров (+7 900 111-22-33)' },
|
||||||
|
{ value: 'cont_02', label: 'Екатерина Васильева (e.vas@prom.ru)' },
|
||||||
|
{ value: 'cont_03', label: 'Николай Федоров (+7 911 444-55-66)' },
|
||||||
|
],
|
||||||
|
product: [
|
||||||
|
{ value: 'prod_01', label: 'Лицензия Krona Enterprise (Годовая)' },
|
||||||
|
{ value: 'prod_02', label: 'Модуль расширенной бизнес-аналитики' },
|
||||||
|
{ value: 'prod_03', label: 'Консалтинг по интеграции ERP/CRM' },
|
||||||
|
{ value: 'prod_04', label: 'Пакет технической поддержки 24/7' },
|
||||||
|
],
|
||||||
|
entity_relation: [
|
||||||
|
{ value: 'rel_01', label: 'Связанный документ #01' },
|
||||||
|
{ value: 'rel_02', label: 'Связанный договор #02' },
|
||||||
|
{ value: 'rel_03', label: 'Связанный контрагент #03' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_PRESETS = ['#2F684C', '#2563EB', '#D97706', '#DC2626', '#7C3AED', '#4B5563', '#059669', '#0891B2'];
|
||||||
|
|
||||||
export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
entity,
|
entity,
|
||||||
record,
|
record,
|
||||||
|
|
@ -33,12 +108,8 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
ent.fields.forEach((field) => {
|
ent.fields.forEach((field) => {
|
||||||
if (rec && rec[field.key] !== undefined) {
|
if (rec && rec[field.key] !== undefined) {
|
||||||
initial[field.key] = rec[field.key];
|
initial[field.key] = rec[field.key];
|
||||||
} else if (field.defaultValue !== undefined) {
|
|
||||||
initial[field.key] = field.defaultValue;
|
|
||||||
} else if (field.type === 'boolean') {
|
|
||||||
initial[field.key] = false;
|
|
||||||
} else {
|
} else {
|
||||||
initial[field.key] = '';
|
initial[field.key] = getDefaultFieldValue(field.type, field);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return initial;
|
return initial;
|
||||||
|
|
@ -81,7 +152,7 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
}, [layout, entity]);
|
}, [layout, entity]);
|
||||||
|
|
||||||
// Check conditional visibility
|
// Check conditional visibility
|
||||||
const isFieldVisible = (field: FieldDefinition): boolean => {
|
const isFieldVisible = useCallback((field: FieldDefinition): boolean => {
|
||||||
if (field.hidden) return false;
|
if (field.hidden) return false;
|
||||||
if (!field.conditionalVisibility || !field.conditionalVisibility.fieldKey) return true;
|
if (!field.conditionalVisibility || !field.conditionalVisibility.fieldKey) return true;
|
||||||
|
|
||||||
|
|
@ -98,7 +169,7 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
};
|
}, [formValues]);
|
||||||
|
|
||||||
const handleFieldChange = (key: string, val: unknown) => {
|
const handleFieldChange = (key: string, val: unknown) => {
|
||||||
setFormValues((prev) => ({ ...prev, [key]: val }));
|
setFormValues((prev) => ({ ...prev, [key]: val }));
|
||||||
|
|
@ -119,45 +190,13 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
if (!isFieldVisible(field)) return;
|
if (!isFieldVisible(field)) return;
|
||||||
|
|
||||||
const val = formValues[field.key];
|
const val = formValues[field.key];
|
||||||
|
const result = validateFieldValue(field.type, val, field.validation, field);
|
||||||
|
|
||||||
// 1. Required validation
|
if (!result.valid) {
|
||||||
if (field.required) {
|
if (result.error === 'Обязательное поле для заполнения') {
|
||||||
if (val === undefined || val === null || val === '') {
|
|
||||||
newErrors[field.key] = `Поле «${field.label}» обязательно для заполнения`;
|
newErrors[field.key] = `Поле «${field.label}» обязательно для заполнения`;
|
||||||
return;
|
} else {
|
||||||
}
|
newErrors[field.key] = result.error || `Поле «${field.label}» заполнено неверно`;
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Custom validation rules
|
|
||||||
if (field.validation) {
|
|
||||||
const { min, max, minLength, maxLength, pattern } = field.validation;
|
|
||||||
|
|
||||||
if (typeof val === 'number') {
|
|
||||||
if (min !== undefined && val < min) {
|
|
||||||
newErrors[field.key] = `Значение должно быть не меньше ${min}`;
|
|
||||||
}
|
|
||||||
if (max !== undefined && val > max) {
|
|
||||||
newErrors[field.key] = `Значение должно быть не больше ${max}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof val === 'string' && val.trim() !== '') {
|
|
||||||
if (minLength !== undefined && val.length < minLength) {
|
|
||||||
newErrors[field.key] = `Длина должна быть не менее ${minLength} символов`;
|
|
||||||
}
|
|
||||||
if (maxLength !== undefined && val.length > maxLength) {
|
|
||||||
newErrors[field.key] = `Длина должна быть не более ${maxLength} символов`;
|
|
||||||
}
|
|
||||||
if (pattern) {
|
|
||||||
try {
|
|
||||||
const regex = new RegExp(pattern);
|
|
||||||
if (!regex.test(val)) {
|
|
||||||
newErrors[field.key] = `Значение не соответствует требуемому формату`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore invalid regex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -176,7 +215,19 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSave(formValues);
|
const serializedValues: Record<string, unknown> = {};
|
||||||
|
entity.fields.forEach((f) => {
|
||||||
|
if (formValues[f.key] !== undefined) {
|
||||||
|
serializedValues[f.key] = serializeFieldValue(f.type, formValues[f.key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.keys(formValues).forEach((k) => {
|
||||||
|
if (serializedValues[k] === undefined) {
|
||||||
|
serializedValues[k] = formValues[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await onSave(serializedValues);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast({
|
showToast({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
|
@ -186,14 +237,18 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render individual input based on field type
|
// ---------------------------------------------------------------------------
|
||||||
|
// Specialized Input Renderers for All Canonical Field Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const renderFieldInput = (field: FieldDefinition) => {
|
const renderFieldInput = (field: FieldDefinition) => {
|
||||||
const rawVal = formValues[field.key];
|
const rawVal = formValues[field.key];
|
||||||
const isReadOnly = mode === 'readonly' || field.readOnly;
|
const isReadOnly = mode === 'readonly' || field.readOnly;
|
||||||
const hasError = !!errors[field.key];
|
const hasError = !!errors[field.key];
|
||||||
|
const canonical = normalizeFieldType(field.type);
|
||||||
|
|
||||||
// Money in minor units (rubles / kopecks)
|
// 1. Money in minor units (integer kopecks)
|
||||||
if (field.type === 'money') {
|
if (canonical === 'money') {
|
||||||
const minorVal = typeof rawVal === 'number' ? rawVal : 0;
|
const minorVal = typeof rawVal === 'number' ? rawVal : 0;
|
||||||
const majorVal = (minorVal / 100).toString();
|
const majorVal = (minorVal / 100).toString();
|
||||||
|
|
||||||
|
|
@ -228,12 +283,8 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select / Status
|
// 2. Select & Status (Single choice)
|
||||||
if (
|
if (canonical === 'select' || canonical === 'status') {
|
||||||
field.type === 'select' ||
|
|
||||||
field.type === 'status' ||
|
|
||||||
field.type === 'radio'
|
|
||||||
) {
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
const opt = field.options?.find((o) => o.value === rawVal);
|
const opt = field.options?.find((o) => o.value === rawVal);
|
||||||
return (
|
return (
|
||||||
|
|
@ -262,8 +313,185 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boolean / Checkbox
|
// 3. Radio Buttons
|
||||||
if (field.type === 'boolean') {
|
if (canonical === 'radio') {
|
||||||
|
const options = field.options || [
|
||||||
|
{ value: 'option_1', label: 'Вариант 1' },
|
||||||
|
{ value: 'option_2', label: 'Вариант 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isReadOnly) {
|
||||||
|
const opt = options.find((o) => o.value === rawVal);
|
||||||
|
return (
|
||||||
|
<div className="text-xs font-medium text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
|
||||||
|
{opt?.label || String(rawVal || '—')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<label key={opt.value} className="flex items-center gap-2 cursor-pointer text-xs text-krona-primary">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`radio_${field.key}`}
|
||||||
|
value={opt.value}
|
||||||
|
checked={rawVal === opt.value}
|
||||||
|
onChange={() => handleFieldChange(field.key, opt.value)}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
className="text-krona-green focus:ring-krona-green"
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Multi-Select with Chips
|
||||||
|
if (canonical === 'multi_select') {
|
||||||
|
const selected = Array.isArray(rawVal) ? (rawVal as string[]) : [];
|
||||||
|
const options = field.options || [];
|
||||||
|
|
||||||
|
if (isReadOnly) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5 p-2 bg-krona-workspace rounded border border-krona-border/60">
|
||||||
|
{selected.length > 0 ? (
|
||||||
|
selected.map((val) => {
|
||||||
|
const label = options.find((o) => o.value === val)?.label || val;
|
||||||
|
return (
|
||||||
|
<Badge key={val} variant="neutral" size="sm">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-krona-secondary">Ничего не выбрано</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unselectedOptions = options.filter((o) => !selected.includes(o.value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 p-2 bg-krona-workspace/60 rounded border border-krona-border/60">
|
||||||
|
{selected.map((val) => {
|
||||||
|
const label = options.find((o) => o.value === val)?.label || val;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={val}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-krona-green/10 text-krona-green border border-krona-green/20"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleFieldChange(field.key, selected.filter((s) => s !== val))}
|
||||||
|
className="hover:text-krona-danger ml-0.5"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
handleFieldChange(field.key, [...selected, e.target.value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green ${
|
||||||
|
hasError ? 'border-krona-danger' : 'border-krona-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<option value="">+ Добавить вариант...</option>
|
||||||
|
{unselectedOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Checkbox Group
|
||||||
|
if (canonical === 'checkbox_group') {
|
||||||
|
const selected = Array.isArray(rawVal) ? (rawVal as string[]) : [];
|
||||||
|
const options = field.options || [
|
||||||
|
{ value: 'opt_1', label: 'Вариант А' },
|
||||||
|
{ value: 'opt_2', label: 'Вариант Б' },
|
||||||
|
{ value: 'opt_3', label: 'Вариант В' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
{options.map((opt) => {
|
||||||
|
const isChecked = selected.includes(opt.value);
|
||||||
|
return (
|
||||||
|
<label key={opt.value} className="flex items-center gap-2 cursor-pointer text-xs text-krona-primary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
handleFieldChange(field.key, [...selected, opt.value]);
|
||||||
|
} else {
|
||||||
|
handleFieldChange(field.key, selected.filter((s) => s !== opt.value));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded text-krona-green focus:ring-krona-green"
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Rating (1–5 Stars)
|
||||||
|
if (canonical === 'rating') {
|
||||||
|
const rating = Math.min(5, Math.max(1, Number(rawVal) || 5));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 py-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
onClick={() => handleFieldChange(field.key, star)}
|
||||||
|
aria-label={`Оценка ${star} из 5 звёзд`}
|
||||||
|
className={`p-1 rounded transition-transform ${
|
||||||
|
!isReadOnly ? 'hover:scale-125 cursor-pointer' : 'cursor-default'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
star <= rating
|
||||||
|
? 'fill-amber-400 text-amber-400'
|
||||||
|
: 'text-krona-secondary opacity-30'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="text-xs font-semibold text-krona-primary ml-2">{rating} / 5</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Boolean / Checkbox
|
||||||
|
if (canonical === 'boolean') {
|
||||||
return (
|
return (
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer text-xs font-medium text-krona-primary py-1">
|
<label className="flex items-center gap-2.5 cursor-pointer text-xs font-medium text-krona-primary py-1">
|
||||||
<input
|
<input
|
||||||
|
|
@ -271,16 +499,16 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
checked={Boolean(rawVal)}
|
checked={Boolean(rawVal)}
|
||||||
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
|
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
className="w-4 h-4 rounded text-krona-green"
|
className="w-4 h-4 rounded text-krona-green focus:ring-krona-green"
|
||||||
/>
|
/>
|
||||||
<span>{rawVal ? 'Да (активно)' : 'Нет'}</span>
|
<span>{rawVal ? 'Да (активно)' : 'Нет'}</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date / Datetime
|
// 8. Date, Datetime, Time
|
||||||
if (field.type === 'date' || field.type === 'datetime') {
|
if (canonical === 'date' || canonical === 'datetime' || canonical === 'time') {
|
||||||
const inputType = field.type === 'datetime' ? 'datetime-local' : 'date';
|
const inputType = canonical === 'datetime' ? 'datetime-local' : canonical === 'time' ? 'time' : 'date';
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={inputType}
|
type={inputType}
|
||||||
|
|
@ -294,39 +522,444 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long text / Rich text / JSON
|
// 9. URL with External Link Preview
|
||||||
if (field.type === 'long_text' || field.type === 'rich_text' || field.type === 'json') {
|
if (canonical === 'url') {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<div className="relative flex items-center">
|
||||||
rows={3}
|
<input
|
||||||
value={typeof rawVal === 'object' ? JSON.stringify(rawVal, null, 2) : String(rawVal ?? '')}
|
type="url"
|
||||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
value={String(rawVal ?? '')}
|
||||||
disabled={isReadOnly}
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
placeholder={field.placeholder || 'Введите текст...'}
|
disabled={isReadOnly}
|
||||||
className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green ${
|
placeholder={field.placeholder || 'https://example.com'}
|
||||||
field.type === 'json' ? 'font-mono' : ''
|
className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green pr-8 ${
|
||||||
} ${hasError ? 'border-krona-danger' : 'border-krona-border'}`}
|
hasError ? 'border-krona-danger' : 'border-krona-border'
|
||||||
/>
|
}`}
|
||||||
|
/>
|
||||||
|
{rawVal ? (
|
||||||
|
<a
|
||||||
|
href={String(rawVal)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="absolute right-2.5 text-krona-secondary hover:text-krona-green"
|
||||||
|
title="Открыть ссылку"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Numbers
|
// 10. Color Picker + Hex Input + Swatches
|
||||||
if (
|
if (canonical === 'color') {
|
||||||
field.type === 'number' ||
|
const hex = String(rawVal || '#2F684C');
|
||||||
field.type === 'integer' ||
|
const validHex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex) ? hex : '#2F684C';
|
||||||
field.type === 'decimal' ||
|
|
||||||
field.type === 'percentage'
|
if (isReadOnly) {
|
||||||
) {
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-1.5 bg-krona-workspace rounded border border-krona-border/60">
|
||||||
|
<div className="w-5 h-5 rounded border border-krona-border/60" style={{ backgroundColor: validHex }} />
|
||||||
|
<span className="font-mono text-xs text-krona-primary">{hex}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<div className="space-y-2">
|
||||||
type="number"
|
<div className="flex items-center gap-2">
|
||||||
step={field.type === 'decimal' || field.type === 'percentage' ? '0.01' : '1'}
|
<input
|
||||||
value={rawVal !== undefined && rawVal !== null ? String(rawVal) : ''}
|
type="color"
|
||||||
onChange={(e) =>
|
value={validHex.length === 7 ? validHex : '#2F684C'}
|
||||||
handleFieldChange(field.key, e.target.value === '' ? '' : Number(e.target.value))
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
}
|
disabled={isReadOnly}
|
||||||
|
className="w-8 h-8 rounded cursor-pointer border border-krona-border bg-transparent p-0.5"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hex}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
placeholder="#2F684C"
|
||||||
|
maxLength={7}
|
||||||
|
className={`w-28 font-mono text-xs uppercase bg-krona-surface text-krona-primary border rounded-md px-2.5 py-1.5 focus:outline-none focus:border-krona-green ${
|
||||||
|
hasError ? 'border-krona-danger' : 'border-krona-border'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
|
{COLOR_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleFieldChange(field.key, preset)}
|
||||||
|
style={{ backgroundColor: preset }}
|
||||||
|
className="w-5 h-5 rounded-full border border-krona-border/60 hover:scale-110 transition-transform"
|
||||||
|
title={preset}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. File & Image Upload Abstraction
|
||||||
|
if (canonical === 'file' || canonical === 'image') {
|
||||||
|
const fileData = (rawVal && typeof rawVal === 'object' ? (rawVal as FileMetadata) : null);
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (uploadEv) => {
|
||||||
|
const meta: FileMetadata = {
|
||||||
|
id: ulid(),
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
url: String(uploadEv.target?.result || ''),
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
handleFieldChange(field.key, meta);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fileData) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
{canonical === 'image' && fileData.url ? (
|
||||||
|
<img
|
||||||
|
src={fileData.url}
|
||||||
|
alt={fileData.name}
|
||||||
|
className="w-10 h-10 rounded object-cover border border-krona-border shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileText className="w-6 h-6 text-krona-green shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="truncate">
|
||||||
|
<p className="text-xs font-semibold text-krona-primary truncate">{fileData.name}</p>
|
||||||
|
<p className="text-[11px] text-krona-secondary font-mono">
|
||||||
|
{(fileData.size / 1024).toFixed(1)} КБ • {fileData.mimeType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isReadOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleFieldChange(field.key, null)}
|
||||||
|
className="p-1 text-krona-secondary hover:text-krona-danger rounded transition-colors"
|
||||||
|
title="Удалить файл"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnly) {
|
||||||
|
return <div className="text-xs text-krona-secondary p-2 bg-krona-workspace rounded">Файл не прикреплен</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col items-center justify-center p-4 border-2 border-dashed border-krona-border rounded-lg cursor-pointer hover:bg-krona-workspace/40 transition-colors">
|
||||||
|
<Upload className="w-5 h-5 text-krona-secondary mb-1.5" />
|
||||||
|
<span className="text-xs font-medium text-krona-primary">
|
||||||
|
{canonical === 'image' ? 'Нажмите для загрузки изображения' : 'Нажмите для загрузки документа'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-krona-secondary mt-0.5">PNG, JPG, PDF, DOCX до 10 МБ</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={canonical === 'image' ? 'image/*' : undefined}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Structured Address
|
||||||
|
if (canonical === 'address') {
|
||||||
|
const addr: StructuredAddress =
|
||||||
|
typeof rawVal === 'object' && rawVal !== null
|
||||||
|
? (rawVal as StructuredAddress)
|
||||||
|
: { street: String(rawVal || ''), city: '', postalCode: '', country: 'Россия' };
|
||||||
|
|
||||||
|
if (isReadOnly) {
|
||||||
|
const parts = [addr.city, addr.street, addr.postalCode, addr.country].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-krona-green shrink-0" />
|
||||||
|
<span>{parts.join(', ') || '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-2.5 bg-krona-workspace/40 rounded-lg border border-krona-border/60">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Город (напр. Москва)"
|
||||||
|
value={addr.city || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, { ...addr, city: e.target.value })}
|
||||||
|
className="bg-krona-surface text-krona-primary border border-krona-border rounded px-2 py-1 text-xs focus:outline-none focus:border-krona-green"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Индекс (напр. 101000)"
|
||||||
|
value={addr.postalCode || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, { ...addr, postalCode: e.target.value })}
|
||||||
|
className="bg-krona-surface text-krona-primary border border-krona-border rounded px-2 py-1 text-xs focus:outline-none focus:border-krona-green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Улица, дом, офис (напр. Тверская ул., д. 12, оф. 405)"
|
||||||
|
value={addr.street || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, { ...addr, street: e.target.value })}
|
||||||
|
className="w-full bg-krona-surface text-krona-primary border border-krona-border rounded px-2 py-1 text-xs focus:outline-none focus:border-krona-green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Location (Latitude, Longitude)
|
||||||
|
if (canonical === 'location') {
|
||||||
|
const loc: GeoLocation =
|
||||||
|
typeof rawVal === 'object' && rawVal !== null
|
||||||
|
? (rawVal as GeoLocation)
|
||||||
|
: { lat: 0, lng: 0 };
|
||||||
|
|
||||||
|
if (isReadOnly) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
|
||||||
|
<Navigation className="w-3.5 h-3.5 text-krona-green shrink-0" />
|
||||||
|
<span>Широта: {loc.lat.toFixed(4)}, Долгота: {loc.lng.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
placeholder="Широта (Lat)"
|
||||||
|
value={loc.lat || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, { ...loc, lat: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="w-full bg-krona-surface text-krona-primary border border-krona-border rounded-md px-2.5 py-1.5 text-xs focus:outline-none focus:border-krona-green"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-2 top-2 text-[10px] text-krona-secondary font-mono">LAT</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="-180"
|
||||||
|
max="180"
|
||||||
|
placeholder="Долгота (Lng)"
|
||||||
|
value={loc.lng || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, { ...loc, lng: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="w-full bg-krona-surface text-krona-primary border border-krona-border rounded-md px-2.5 py-1.5 text-xs focus:outline-none focus:border-krona-green"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-2 top-2 text-[10px] text-krona-secondary font-mono">LNG</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. Entity Relations & References (user, company, contact, etc.)
|
||||||
|
if (
|
||||||
|
canonical === 'user' ||
|
||||||
|
canonical === 'employee' ||
|
||||||
|
canonical === 'team' ||
|
||||||
|
canonical === 'company' ||
|
||||||
|
canonical === 'contact' ||
|
||||||
|
canonical === 'product' ||
|
||||||
|
canonical === 'entity_relation'
|
||||||
|
) {
|
||||||
|
const options = field.options && field.options.length > 0 ? field.options : DEFAULT_RELATION_OPTIONS[canonical] || [];
|
||||||
|
|
||||||
|
if (isReadOnly) {
|
||||||
|
const opt = options.find((o) => o.value === rawVal);
|
||||||
|
return (
|
||||||
|
<div className="text-xs font-medium text-krona-primary p-2 bg-krona-workspace rounded border border-krona-border/60">
|
||||||
|
{opt?.label || String(rawVal || '—')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={String(rawVal ?? '')}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
placeholder={field.placeholder || '0'}
|
className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green ${
|
||||||
|
hasError ? 'border-krona-danger' : 'border-krona-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<option value="">Выберите запись...</option>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. Formula (Computed in real time via safe AST parser)
|
||||||
|
if (canonical === 'formula') {
|
||||||
|
const computedResult = evaluateSafeFormula(field.formula || '', formValues);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-2.5 bg-krona-workspace/80 border border-krona-border rounded-md flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Binary className="w-4 h-4 text-krona-green shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-krona-primary font-mono">
|
||||||
|
{computedResult !== null ? computedResult.toLocaleString('ru-RU') : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-krona-secondary font-mono">
|
||||||
|
f(x) = {field.formula || 'не задана'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="neutral" size="sm">
|
||||||
|
Вычисляемое поле
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. Autonumber (Read-only sequence)
|
||||||
|
if (canonical === 'autonumber') {
|
||||||
|
const displayVal = String(rawVal || 'AUTO-[Генерируется]');
|
||||||
|
return (
|
||||||
|
<div className="p-2 bg-krona-workspace rounded border border-krona-border/60 font-mono text-xs font-semibold text-krona-primary">
|
||||||
|
{displayVal}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. JSON Editor with Live Syntax Validation
|
||||||
|
if (canonical === 'json') {
|
||||||
|
const strVal = typeof rawVal === 'object' ? JSON.stringify(rawVal, null, 2) : String(rawVal ?? '{}');
|
||||||
|
let isValidJson = true;
|
||||||
|
try {
|
||||||
|
JSON.parse(strVal);
|
||||||
|
} catch {
|
||||||
|
isValidJson = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-mono text-krona-secondary flex items-center gap-1">
|
||||||
|
<CodeIcon className="w-3 h-3" /> JSON редактор
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-medium px-2 py-0.5 rounded ${
|
||||||
|
isValidJson
|
||||||
|
? 'bg-krona-green/10 text-krona-green'
|
||||||
|
: 'bg-krona-danger/10 text-krona-danger'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isValidJson ? 'JSON валиден' : 'Ошибка синтаксиса JSON'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={strVal}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
className={`w-full font-mono text-xs bg-krona-surface text-krona-primary border rounded-md p-2.5 focus:outline-none focus:border-krona-green ${
|
||||||
|
!isValidJson ? 'border-krona-danger' : 'border-krona-border'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 18. Rich Text with Formatting Controls
|
||||||
|
if (canonical === 'rich_text') {
|
||||||
|
const textVal = String(rawVal ?? '');
|
||||||
|
|
||||||
|
const applyFormat = (prefix: string, suffix: string = prefix) => {
|
||||||
|
handleFieldChange(field.key, `${textVal} ${prefix}текст${suffix}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{!isReadOnly && (
|
||||||
|
<div className="flex items-center gap-1 border border-krona-border/60 rounded p-1 bg-krona-workspace/60 w-fit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFormat('**', '**')}
|
||||||
|
className="p-1 hover:bg-krona-surface rounded text-krona-secondary hover:text-krona-primary"
|
||||||
|
title="Жирный"
|
||||||
|
>
|
||||||
|
<Bold className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFormat('*', '*')}
|
||||||
|
className="p-1 hover:bg-krona-surface rounded text-krona-secondary hover:text-krona-primary"
|
||||||
|
title="Курсив"
|
||||||
|
>
|
||||||
|
<Italic className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFormat('\n- ')}
|
||||||
|
className="p-1 hover:bg-krona-surface rounded text-krona-secondary hover:text-krona-primary"
|
||||||
|
title="Список"
|
||||||
|
>
|
||||||
|
<ListIcon className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyFormat('\n> ')}
|
||||||
|
className="p-1 hover:bg-krona-surface rounded text-krona-secondary hover:text-krona-primary"
|
||||||
|
title="Цитата"
|
||||||
|
>
|
||||||
|
<Quote className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={textVal}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
placeholder={field.placeholder || 'Введите форматированный текст...'}
|
||||||
|
className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green ${
|
||||||
|
hasError ? 'border-krona-danger' : 'border-krona-border'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 19. Long text
|
||||||
|
if (canonical === 'long_text') {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={String(rawVal ?? '')}
|
||||||
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
placeholder={field.placeholder || 'Введите текст...'}
|
||||||
className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green ${
|
className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green ${
|
||||||
hasError ? 'border-krona-danger' : 'border-krona-border'
|
hasError ? 'border-krona-danger' : 'border-krona-border'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -334,10 +967,37 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default string input (text, email, phone, url, etc.)
|
// 20. Numeric types (Integer, Decimal, Percentage)
|
||||||
|
if (canonical === 'integer' || canonical === 'decimal' || canonical === 'percentage') {
|
||||||
|
const step = canonical === 'integer' ? '1' : '0.01';
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={step}
|
||||||
|
value={rawVal !== undefined && rawVal !== null ? String(rawVal) : ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFieldChange(field.key, e.target.value === '' ? '' : Number(e.target.value))
|
||||||
|
}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
placeholder={field.placeholder || '0'}
|
||||||
|
className={`w-full bg-krona-surface text-krona-primary border rounded-md px-3 py-2 text-xs focus:outline-none focus:border-krona-green ${
|
||||||
|
canonical === 'percentage' ? 'pr-8' : ''
|
||||||
|
} ${hasError ? 'border-krona-danger' : 'border-krona-border'}`}
|
||||||
|
/>
|
||||||
|
{canonical === 'percentage' && (
|
||||||
|
<span className="absolute right-3 top-2.5 text-xs text-krona-secondary font-medium">
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21. Default string inputs (text, email, phone)
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={field.type === 'email' ? 'email' : field.type === 'phone' ? 'tel' : 'text'}
|
type={canonical === 'email' ? 'email' : canonical === 'phone' ? 'tel' : 'text'}
|
||||||
value={String(rawVal ?? '')}
|
value={String(rawVal ?? '')}
|
||||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ import { RecordEntity } from '../../services/storage/indexedDb';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { Input } from '../../shared/ui/Input';
|
import { Input } from '../../shared/ui/Input';
|
||||||
import { Badge } from '../../shared/ui/Badge';
|
import { Badge } from '../../shared/ui/Badge';
|
||||||
import { formatCurrencyMinor } from '../../shared/lib/currency';
|
import { formatFieldValue } from '../../shared/lib/fieldEngine';
|
||||||
import { formatDateTime } from '../../shared/lib/date';
|
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
|
@ -185,26 +184,23 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
|
||||||
>
|
>
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const val = row[col.key];
|
const val = row[col.key];
|
||||||
|
const display = formatFieldValue(col.type, val, col);
|
||||||
let display = '—';
|
|
||||||
if (val !== undefined && val !== null && val !== '') {
|
|
||||||
if (col.type === 'money') {
|
|
||||||
display = typeof val === 'number' ? formatCurrencyMinor(val) : String(val);
|
|
||||||
} else if (col.type === 'boolean') {
|
|
||||||
display = val ? 'Да' : 'Нет';
|
|
||||||
} else if (col.type === 'date' || col.type === 'datetime') {
|
|
||||||
display = formatDateTime(String(val));
|
|
||||||
} else if (col.type === 'status' || col.type === 'select') {
|
|
||||||
const opt = col.options?.find((o) => o.value === val);
|
|
||||||
display = opt?.label || String(val);
|
|
||||||
} else {
|
|
||||||
display = String(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td key={col.key} className="p-3 text-krona-secondary truncate max-w-xs">
|
<td key={col.key} className="p-3 text-krona-secondary truncate max-w-xs">
|
||||||
{col.key === 'status' ? (
|
{col.type === 'color' && val ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full border border-krona-border/60 shrink-0"
|
||||||
|
style={{ backgroundColor: String(val) }}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px]">{display}</span>
|
||||||
|
</div>
|
||||||
|
) : col.type === 'boolean' && val !== undefined && val !== null ? (
|
||||||
|
<Badge variant={val ? 'green' : 'neutral'} size="sm">
|
||||||
|
{val ? 'Да' : 'Нет'}
|
||||||
|
</Badge>
|
||||||
|
) : col.key === 'status' || col.type === 'status' ? (
|
||||||
<Badge variant="neutral" size="sm">
|
<Badge variant="neutral" size="sm">
|
||||||
{display}
|
{display}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,12 @@ export const ViewBuilder: React.FC = () => {
|
||||||
// Initialize active view
|
// Initialize active view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.resolve().then(() => {
|
void Promise.resolve().then(() => {
|
||||||
|
if (!currentEntity || !currentEntity.fields || currentEntity.fields.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!views || views.length === 0) {
|
if (!views || views.length === 0) {
|
||||||
if (entityCode && currentEntity) {
|
if (entityCode) {
|
||||||
const defaultView: ViewDefinition = {
|
const defaultView: ViewDefinition = {
|
||||||
id: `view_${entityCode}_default`,
|
id: `view_${entityCode}_default`,
|
||||||
entityCode,
|
entityCode,
|
||||||
|
|
@ -77,12 +81,20 @@ export const ViewBuilder: React.FC = () => {
|
||||||
|
|
||||||
if (!selectedViewId) {
|
if (!selectedViewId) {
|
||||||
const def = views.find((v) => v.isDefault) || views[0];
|
const def = views.find((v) => v.isDefault) || views[0];
|
||||||
|
const visibleKeys =
|
||||||
|
def.visibleFieldKeys && def.visibleFieldKeys.length > 0
|
||||||
|
? def.visibleFieldKeys
|
||||||
|
: currentEntity.fields.slice(0, 6).map((f) => f.key);
|
||||||
setSelectedViewId(def.id);
|
setSelectedViewId(def.id);
|
||||||
setCurrentView({ ...def });
|
setCurrentView({ ...def, visibleFieldKeys: visibleKeys });
|
||||||
} else {
|
} else {
|
||||||
const found = views.find((v) => v.id === selectedViewId);
|
const found = views.find((v) => v.id === selectedViewId);
|
||||||
if (found && !isDirty) {
|
if (found && !isDirty) {
|
||||||
setCurrentView({ ...found });
|
const visibleKeys =
|
||||||
|
found.visibleFieldKeys && found.visibleFieldKeys.length > 0
|
||||||
|
? found.visibleFieldKeys
|
||||||
|
: currentEntity.fields.slice(0, 6).map((f) => f.key);
|
||||||
|
setCurrentView({ ...found, visibleFieldKeys: visibleKeys });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { FieldDataType } from '../../../entities/types';
|
import { FieldDataType, normalizeFieldType } from '../../../entities/types';
|
||||||
import {
|
import {
|
||||||
Type,
|
Type,
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
|
|
@ -86,6 +86,7 @@ export const FIELD_TYPES_CATALOG: FieldTypeInfo[] = [
|
||||||
|
|
||||||
// Selection group
|
// Selection group
|
||||||
{ type: 'select', label: 'Выпадающий список', category: 'selection', icon: List, description: 'Одиночный выбор из списка вариантов', supportsOptions: true, defaultValue: '' },
|
{ type: 'select', label: 'Выпадающий список', category: 'selection', icon: List, description: 'Одиночный выбор из списка вариантов', supportsOptions: true, defaultValue: '' },
|
||||||
|
{ type: 'single_select', label: 'Одиночный выбор', category: 'selection', icon: List, description: 'Одиночный выбор из списка вариантов', supportsOptions: true, defaultValue: '' },
|
||||||
{ type: 'multiselect', label: 'Множественный выбор', category: 'selection', icon: CheckSquare, description: 'Несколько значений из списка', supportsOptions: true, defaultValue: [] },
|
{ type: 'multiselect', label: 'Множественный выбор', category: 'selection', icon: CheckSquare, description: 'Несколько значений из списка', supportsOptions: true, defaultValue: [] },
|
||||||
{ type: 'multi_select', label: 'Множественный список', category: 'selection', icon: CheckSquare, description: 'Несколько значений из списка', supportsOptions: true, defaultValue: [] },
|
{ type: 'multi_select', label: 'Множественный список', category: 'selection', icon: CheckSquare, description: 'Несколько значений из списка', supportsOptions: true, defaultValue: [] },
|
||||||
{ type: 'radio', label: 'Радио-кнопки', category: 'selection', icon: Radio, description: 'Одиночный выбор на форме в виде переключателей', supportsOptions: true, defaultValue: '' },
|
{ type: 'radio', label: 'Радио-кнопки', category: 'selection', icon: Radio, description: 'Одиночный выбор на форме в виде переключателей', supportsOptions: true, defaultValue: '' },
|
||||||
|
|
@ -116,6 +117,7 @@ export const FIELD_TYPES_CATALOG: FieldTypeInfo[] = [
|
||||||
{ type: 'product', label: 'Товар или услуга', category: 'relations', icon: Package, description: 'Связь с номенклатурой', defaultValue: '' },
|
{ type: 'product', label: 'Товар или услуга', category: 'relations', icon: Package, description: 'Связь с номенклатурой', defaultValue: '' },
|
||||||
{ type: 'entity_relation', label: 'Связь с сущностью (1:1, 1:N, N:M)', category: 'relations', icon: Link, description: 'Связь с любой системной или кастомной сущностью', defaultValue: '' },
|
{ type: 'entity_relation', label: 'Связь с сущностью (1:1, 1:N, N:M)', category: 'relations', icon: Link, description: 'Связь с любой системной или кастомной сущностью', defaultValue: '' },
|
||||||
{ type: 'entity_ref', label: 'Ссылка на запись', category: 'relations', icon: Link, description: 'Связь со справочником', defaultValue: '' },
|
{ type: 'entity_ref', label: 'Ссылка на запись', category: 'relations', icon: Link, description: 'Связь со справочником', defaultValue: '' },
|
||||||
|
{ type: 'lookup', label: 'Связь со справочником (Lookup)', category: 'relations', icon: Link, description: 'Связь со справочником', defaultValue: '' },
|
||||||
|
|
||||||
// Advanced group
|
// Advanced group
|
||||||
{ type: 'formula', label: 'Вычисляемая формула', category: 'advanced', icon: Binary, description: 'Автоматический расчёт на основе других полей', defaultValue: '' },
|
{ type: 'formula', label: 'Вычисляемая формула', category: 'advanced', icon: Binary, description: 'Автоматический расчёт на основе других полей', defaultValue: '' },
|
||||||
|
|
@ -123,11 +125,14 @@ export const FIELD_TYPES_CATALOG: FieldTypeInfo[] = [
|
||||||
{ type: 'json', label: 'JSON структура', category: 'advanced', icon: Code, description: 'Произвольный структурированный объект (Dev)', defaultValue: '{}' },
|
{ type: 'json', label: 'JSON структура', category: 'advanced', icon: Code, description: 'Произвольный структурированный объект (Dev)', defaultValue: '{}' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getFieldTypeInfo(type: FieldDataType): FieldTypeInfo {
|
export function getFieldTypeInfo(type: FieldDataType | string): FieldTypeInfo {
|
||||||
const found = FIELD_TYPES_CATALOG.find((f) => f.type === type);
|
const found = FIELD_TYPES_CATALOG.find((f) => f.type === type);
|
||||||
if (found) return found;
|
if (found) return found;
|
||||||
|
const canonical = normalizeFieldType(type);
|
||||||
|
const normFound = FIELD_TYPES_CATALOG.find((f) => f.type === canonical);
|
||||||
|
if (normFound) return normFound;
|
||||||
return {
|
return {
|
||||||
type,
|
type: canonical,
|
||||||
label: type,
|
label: type,
|
||||||
category: 'text',
|
category: 'text',
|
||||||
icon: Type,
|
icon: Type,
|
||||||
|
|
|
||||||
806
src/shared/lib/fieldEngine.ts
Normal file
806
src/shared/lib/fieldEngine.ts
Normal file
|
|
@ -0,0 +1,806 @@
|
||||||
|
import {
|
||||||
|
FieldDataType,
|
||||||
|
FieldDefinition,
|
||||||
|
FieldValidationRules,
|
||||||
|
FileMetadata,
|
||||||
|
GeoLocation,
|
||||||
|
MoneyAmount,
|
||||||
|
StructuredAddress,
|
||||||
|
normalizeFieldType,
|
||||||
|
} from '../../entities/types';
|
||||||
|
import { formatCurrencyMinor } from './currency';
|
||||||
|
import { formatDate, formatDateTime } from './date';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Safe Formula Evaluation Engine (NO eval, NO Function, Pure AST Evaluator)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type TokenType =
|
||||||
|
| 'NUMBER'
|
||||||
|
| 'IDENT'
|
||||||
|
| 'FIELD_REF'
|
||||||
|
| 'PLUS'
|
||||||
|
| 'MINUS'
|
||||||
|
| 'MUL'
|
||||||
|
| 'DIV'
|
||||||
|
| 'MOD'
|
||||||
|
| 'LPAREN'
|
||||||
|
| 'RPAREN'
|
||||||
|
| 'COMMA';
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
type: TokenType;
|
||||||
|
value: string;
|
||||||
|
num?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORBIDDEN_WORDS = new Set([
|
||||||
|
'eval',
|
||||||
|
'function',
|
||||||
|
'constructor',
|
||||||
|
'prototype',
|
||||||
|
'window',
|
||||||
|
'document',
|
||||||
|
'global',
|
||||||
|
'globalthis',
|
||||||
|
'process',
|
||||||
|
'require',
|
||||||
|
'import',
|
||||||
|
'this',
|
||||||
|
'class',
|
||||||
|
'var',
|
||||||
|
'let',
|
||||||
|
'const',
|
||||||
|
'return',
|
||||||
|
'while',
|
||||||
|
'for',
|
||||||
|
'if',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ALLOWED_FUNCTIONS = new Set(['round', 'floor', 'ceil', 'abs', 'min', 'max', 'sqrt']);
|
||||||
|
|
||||||
|
function tokenizeFormula(formula: string): Token[] {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < formula.length) {
|
||||||
|
const ch = formula[i];
|
||||||
|
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '{') {
|
||||||
|
const closeIdx = formula.indexOf('}', i);
|
||||||
|
if (closeIdx === -1) {
|
||||||
|
throw new Error('Незакрытая фигурная скобка в ссылке на поле');
|
||||||
|
}
|
||||||
|
const fieldKey = formula.slice(i + 1, closeIdx).trim();
|
||||||
|
tokens.push({ type: 'FIELD_REF', value: fieldKey });
|
||||||
|
i = closeIdx + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '+') {
|
||||||
|
tokens.push({ type: 'PLUS', value: '+' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '-') {
|
||||||
|
tokens.push({ type: 'MINUS', value: '-' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '*') {
|
||||||
|
tokens.push({ type: 'MUL', value: '*' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '/') {
|
||||||
|
tokens.push({ type: 'DIV', value: '/' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '%') {
|
||||||
|
tokens.push({ type: 'MOD', value: '%' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '(') {
|
||||||
|
tokens.push({ type: 'LPAREN', value: '(' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === ')') {
|
||||||
|
tokens.push({ type: 'RPAREN', value: ')' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === ',') {
|
||||||
|
tokens.push({ type: 'COMMA', value: ',' });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbers
|
||||||
|
if (/\d/.test(ch) || (ch === '.' && /\d/.test(formula[i + 1] || ''))) {
|
||||||
|
let numStr = '';
|
||||||
|
while (i < formula.length && /[\d.]/.test(formula[i])) {
|
||||||
|
numStr += formula[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
tokens.push({ type: 'NUMBER', value: numStr, num: parseFloat(numStr) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifiers (functions or unbraced field names)
|
||||||
|
if (/[a-zA-Z_а-яёА-ЯЁ]/.test(ch)) {
|
||||||
|
let ident = '';
|
||||||
|
while (i < formula.length && /[a-zA-Z0-9_а-яёА-ЯЁ]/.test(formula[i])) {
|
||||||
|
ident += formula[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const lower = ident.toLowerCase();
|
||||||
|
if (FORBIDDEN_WORDS.has(lower)) {
|
||||||
|
throw new Error(`Использование запрещённого идентификатора «${ident}» в формуле`);
|
||||||
|
}
|
||||||
|
tokens.push({ type: 'IDENT', value: ident });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Недопустимый символ в формуле: «${ch}»`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SafeParser {
|
||||||
|
private tokens: Token[];
|
||||||
|
private pos = 0;
|
||||||
|
private context: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(tokens: Token[], context: Record<string, unknown>) {
|
||||||
|
this.tokens = tokens;
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private peek(): Token | undefined {
|
||||||
|
return this.tokens[this.pos];
|
||||||
|
}
|
||||||
|
|
||||||
|
private consume(expected?: TokenType): Token {
|
||||||
|
const current = this.tokens[this.pos];
|
||||||
|
if (!current) {
|
||||||
|
throw new Error('Неожиданный конец формулы');
|
||||||
|
}
|
||||||
|
if (expected && current.type !== expected) {
|
||||||
|
throw new Error(`Ожидался токен ${expected}, получено ${current.type}`);
|
||||||
|
}
|
||||||
|
this.pos++;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(): number {
|
||||||
|
const res = this.parseExpression();
|
||||||
|
if (this.pos < this.tokens.length) {
|
||||||
|
throw new Error(`Лишние символы после формулы в позиции ${this.pos}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseExpression(): number {
|
||||||
|
let left = this.parseTerm();
|
||||||
|
|
||||||
|
while (this.peek() && (this.peek()!.type === 'PLUS' || this.peek()!.type === 'MINUS')) {
|
||||||
|
const op = this.consume().type;
|
||||||
|
const right = this.parseTerm();
|
||||||
|
if (op === 'PLUS') left += right;
|
||||||
|
else left -= right;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTerm(): number {
|
||||||
|
let left = this.parseFactor();
|
||||||
|
|
||||||
|
while (
|
||||||
|
this.peek() &&
|
||||||
|
(this.peek()!.type === 'MUL' || this.peek()!.type === 'DIV' || this.peek()!.type === 'MOD')
|
||||||
|
) {
|
||||||
|
const op = this.consume().type;
|
||||||
|
const right = this.parseFactor();
|
||||||
|
if (op === 'MUL') {
|
||||||
|
left *= right;
|
||||||
|
} else if (op === 'DIV') {
|
||||||
|
if (right === 0) throw new Error('Деление на ноль');
|
||||||
|
left /= right;
|
||||||
|
} else if (op === 'MOD') {
|
||||||
|
if (right === 0) throw new Error('Деление по модулю на ноль');
|
||||||
|
left %= right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFactor(): number {
|
||||||
|
const token = this.peek();
|
||||||
|
if (!token) throw new Error('Неожиданный конец выражения');
|
||||||
|
|
||||||
|
if (token.type === 'PLUS') {
|
||||||
|
this.consume();
|
||||||
|
return this.parseFactor();
|
||||||
|
}
|
||||||
|
if (token.type === 'MINUS') {
|
||||||
|
this.consume();
|
||||||
|
return -this.parseFactor();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parsePrimary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrimary(): number {
|
||||||
|
const token = this.consume();
|
||||||
|
|
||||||
|
if (token.type === 'NUMBER') {
|
||||||
|
return token.num ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'FIELD_REF') {
|
||||||
|
return this.resolveFieldValue(token.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'IDENT') {
|
||||||
|
const funcName = token.value.toLowerCase();
|
||||||
|
if (ALLOWED_FUNCTIONS.has(funcName) && this.peek()?.type === 'LPAREN') {
|
||||||
|
return this.parseFunctionCall(funcName);
|
||||||
|
}
|
||||||
|
// Unbraced field name
|
||||||
|
return this.resolveFieldValue(token.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'LPAREN') {
|
||||||
|
const val = this.parseExpression();
|
||||||
|
this.consume('RPAREN');
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Недопустимый элемент выражения: ${token.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFunctionCall(name: string): number {
|
||||||
|
this.consume('LPAREN');
|
||||||
|
const args: number[] = [];
|
||||||
|
|
||||||
|
if (this.peek()?.type !== 'RPAREN') {
|
||||||
|
args.push(this.parseExpression());
|
||||||
|
while (this.peek()?.type === 'COMMA') {
|
||||||
|
this.consume('COMMA');
|
||||||
|
args.push(this.parseExpression());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.consume('RPAREN');
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'round': {
|
||||||
|
const val = args[0] ?? 0;
|
||||||
|
const decimals = args[1] ?? 0;
|
||||||
|
const factor = Math.pow(10, decimals);
|
||||||
|
return Math.round(val * factor) / factor;
|
||||||
|
}
|
||||||
|
case 'floor':
|
||||||
|
return Math.floor(args[0] ?? 0);
|
||||||
|
case 'ceil':
|
||||||
|
return Math.ceil(args[0] ?? 0);
|
||||||
|
case 'abs':
|
||||||
|
return Math.abs(args[0] ?? 0);
|
||||||
|
case 'sqrt': {
|
||||||
|
const val = args[0] ?? 0;
|
||||||
|
if (val < 0) throw new Error('Квадратный корень из отрицательного числа');
|
||||||
|
return Math.sqrt(val);
|
||||||
|
}
|
||||||
|
case 'min':
|
||||||
|
if (args.length === 0) return 0;
|
||||||
|
return Math.min(...args);
|
||||||
|
case 'max':
|
||||||
|
if (args.length === 0) return 0;
|
||||||
|
return Math.max(...args);
|
||||||
|
default:
|
||||||
|
throw new Error(`Неизвестная функция: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveFieldValue(key: string): number {
|
||||||
|
const raw = this.context[key];
|
||||||
|
if (raw === undefined || raw === null || raw === '') return 0;
|
||||||
|
if (typeof raw === 'number') return raw;
|
||||||
|
if (typeof raw === 'boolean') return raw ? 1 : 0;
|
||||||
|
const parsed = parseFloat(String(raw));
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateSafeFormula(formula: string, context: Record<string, unknown>): number | null {
|
||||||
|
if (!formula || !formula.trim()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = tokenizeFormula(formula.trim());
|
||||||
|
if (tokens.length === 0) return null;
|
||||||
|
const parser = new SafeParser(tokens, context);
|
||||||
|
const result = parser.parse();
|
||||||
|
if (isNaN(result) || !isFinite(result)) return null;
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Default Values Generator for All 39 Field Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getDefaultFieldValue(type: string, fieldDef?: Partial<FieldDefinition>): unknown {
|
||||||
|
if (fieldDef?.defaultValue !== undefined) {
|
||||||
|
return fieldDef.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = normalizeFieldType(type);
|
||||||
|
|
||||||
|
switch (canonical) {
|
||||||
|
case 'text':
|
||||||
|
case 'long_text':
|
||||||
|
case 'rich_text':
|
||||||
|
case 'phone':
|
||||||
|
case 'email':
|
||||||
|
case 'url':
|
||||||
|
case 'date':
|
||||||
|
case 'datetime':
|
||||||
|
case 'time':
|
||||||
|
case 'select':
|
||||||
|
case 'radio':
|
||||||
|
case 'status':
|
||||||
|
case 'user':
|
||||||
|
case 'employee':
|
||||||
|
case 'team':
|
||||||
|
case 'company':
|
||||||
|
case 'contact':
|
||||||
|
case 'product':
|
||||||
|
case 'entity_relation':
|
||||||
|
case 'autonumber':
|
||||||
|
case 'formula':
|
||||||
|
return '';
|
||||||
|
|
||||||
|
case 'integer':
|
||||||
|
case 'decimal':
|
||||||
|
case 'percentage':
|
||||||
|
case 'money':
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case 'rating':
|
||||||
|
return 5;
|
||||||
|
|
||||||
|
case 'multi_select':
|
||||||
|
case 'checkbox_group':
|
||||||
|
return [];
|
||||||
|
|
||||||
|
case 'color':
|
||||||
|
return '#2F684C';
|
||||||
|
|
||||||
|
case 'file':
|
||||||
|
case 'image':
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'address':
|
||||||
|
return { street: '', city: '', postalCode: '', country: '' };
|
||||||
|
|
||||||
|
case 'location':
|
||||||
|
return { lat: 0, lng: 0 };
|
||||||
|
|
||||||
|
case 'json':
|
||||||
|
return '{}';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Robust Input Validation Engine for All 39 Field Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFieldValue(
|
||||||
|
type: string,
|
||||||
|
value: unknown,
|
||||||
|
rules?: FieldValidationRules,
|
||||||
|
fieldDef?: Partial<FieldDefinition>
|
||||||
|
): ValidationResult {
|
||||||
|
const canonical = normalizeFieldType(type);
|
||||||
|
|
||||||
|
// 1. Required check
|
||||||
|
if (fieldDef?.required) {
|
||||||
|
if (
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === '' ||
|
||||||
|
(Array.isArray(value) && value.length === 0) ||
|
||||||
|
(typeof value === 'object' && Object.keys(value as object).length === 0)
|
||||||
|
) {
|
||||||
|
return { valid: false, error: rules?.customMessage || 'Обязательное поле для заполнения' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If empty and not required, pass
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Custom min/max length & pattern for strings
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (rules?.minLength !== undefined && value.length < rules.minLength) {
|
||||||
|
return { valid: false, error: `Минимальная длина: ${rules.minLength} симв.` };
|
||||||
|
}
|
||||||
|
if (rules?.maxLength !== undefined && value.length > rules.maxLength) {
|
||||||
|
return { valid: false, error: `Максимальная длина: ${rules.maxLength} симв.` };
|
||||||
|
}
|
||||||
|
if (rules?.pattern) {
|
||||||
|
try {
|
||||||
|
const rx = new RegExp(rules.pattern);
|
||||||
|
if (!rx.test(value)) {
|
||||||
|
return { valid: false, error: rules.customMessage || 'Значение не соответствует формату' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore regex compilation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Type-specific validation
|
||||||
|
switch (canonical) {
|
||||||
|
case 'integer': {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!Number.isInteger(value)) {
|
||||||
|
return { valid: false, error: 'Должно быть целым числом' };
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
if (!/^-?\d+$/.test(value.trim())) {
|
||||||
|
return { valid: false, error: 'Должно быть целым числом' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { valid: false, error: 'Неверный числовой формат' };
|
||||||
|
}
|
||||||
|
const num = Number(value);
|
||||||
|
if (rules?.min !== undefined && num < rules.min) return { valid: false, error: `Минимум ${rules.min}` };
|
||||||
|
if (rules?.max !== undefined && num > rules.max) return { valid: false, error: `Максимум ${rules.max}` };
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'decimal':
|
||||||
|
case 'percentage':
|
||||||
|
case 'money': {
|
||||||
|
const num = Number(typeof value === 'object' && value && 'minor' in value ? (value as MoneyAmount).minor : value);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return { valid: false, error: 'Должно быть корректным числом' };
|
||||||
|
}
|
||||||
|
if (canonical === 'percentage' && (num < 0 || num > 100)) {
|
||||||
|
return { valid: false, error: 'Процент должен быть в диапазоне 0–100%' };
|
||||||
|
}
|
||||||
|
if (rules?.min !== undefined && num < rules.min) return { valid: false, error: `Минимум ${rules.min}` };
|
||||||
|
if (rules?.max !== undefined && num > rules.max) return { valid: false, error: `Максимум ${rules.max}` };
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rating': {
|
||||||
|
const r = Number(value);
|
||||||
|
if (!Number.isInteger(r) || r < 1 || r > 5) {
|
||||||
|
return { valid: false, error: 'Рейтинг должен быть целым числом от 1 до 5' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'email': {
|
||||||
|
const str = String(value).trim();
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(str)) {
|
||||||
|
return { valid: false, error: 'Введите корректный адрес электронной почты (например name@company.ru)' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'url': {
|
||||||
|
const str = String(value).trim();
|
||||||
|
try {
|
||||||
|
const u = new URL(str);
|
||||||
|
if (!['http:', 'https:'].includes(u.protocol)) {
|
||||||
|
return { valid: false, error: 'URL должен начинаться с http:// или https://' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: 'Некорректный веб-адрес URL' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'phone': {
|
||||||
|
const str = String(value).trim();
|
||||||
|
const digits = str.replace(/\D/g, '');
|
||||||
|
if (digits.length < 6 || digits.length > 16) {
|
||||||
|
return { valid: false, error: 'Номер телефона должен содержать от 6 до 16 цифр' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'color': {
|
||||||
|
const str = String(value).trim();
|
||||||
|
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(str)) {
|
||||||
|
return { valid: false, error: 'Цвет должен быть в формате HEX (#RGB или #RRGGBB)' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'time': {
|
||||||
|
const str = String(value).trim();
|
||||||
|
if (!/^\d{2}:\d{2}(:\d{2})?$/.test(str)) {
|
||||||
|
return { valid: false, error: 'Время должно быть в формате ЧЧ:ММ' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'json': {
|
||||||
|
if (typeof value === 'object' && value !== null) return { valid: true };
|
||||||
|
try {
|
||||||
|
JSON.parse(String(value));
|
||||||
|
return { valid: true };
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: 'Синтаксическая ошибка в структуре JSON' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'multi_select':
|
||||||
|
case 'checkbox_group': {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return { valid: false, error: 'Значение должно быть массивом вариантов' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'location': {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
const loc = value as GeoLocation;
|
||||||
|
if (typeof loc.lat === 'number' && typeof loc.lng === 'number') {
|
||||||
|
if (loc.lat < -90 || loc.lat > 90) return { valid: false, error: 'Широта (lat) должна быть от -90 до +90' };
|
||||||
|
if (loc.lng < -180 || loc.lng > 180) return { valid: false, error: 'Долгота (lng) должна быть от -180 до +180' };
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parts = value.split(',').map((p) => parseFloat(p.trim()));
|
||||||
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||||
|
if (parts[0] < -90 || parts[0] > 90) return { valid: false, error: 'Широта должна быть от -90 до +90' };
|
||||||
|
if (parts[1] < -180 || parts[1] > 180) return { valid: false, error: 'Долгота должна быть от -180 до +180' };
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: false, error: 'Укажите координаты в формате: широта, долгота' };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. Serialization and Deserialization Round-Trip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function serializeFieldValue(type: string, value: unknown): unknown {
|
||||||
|
const canonical = normalizeFieldType(type);
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (canonical) {
|
||||||
|
case 'integer':
|
||||||
|
return typeof value === 'number' ? Math.round(value) : parseInt(String(value), 10) || 0;
|
||||||
|
|
||||||
|
case 'decimal':
|
||||||
|
case 'percentage':
|
||||||
|
case 'money':
|
||||||
|
return typeof value === 'number' ? value : parseFloat(String(value)) || 0;
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return Boolean(value);
|
||||||
|
|
||||||
|
case 'multi_select':
|
||||||
|
case 'checkbox_group':
|
||||||
|
return Array.isArray(value) ? value.map(String) : [];
|
||||||
|
|
||||||
|
case 'json':
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
|
||||||
|
case 'address':
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return { street: String(value), city: '', postalCode: '', country: '' };
|
||||||
|
|
||||||
|
case 'location':
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parts = value.split(',').map((p) => parseFloat(p.trim()));
|
||||||
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||||
|
return { lat: parts[0], lng: parts[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { lat: 0, lng: 0 };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeFieldValue(type: string, value: unknown): unknown {
|
||||||
|
const canonical = normalizeFieldType(type);
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return getDefaultFieldValue(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (canonical) {
|
||||||
|
case 'integer':
|
||||||
|
return typeof value === 'number' ? Math.round(value) : parseInt(String(value), 10) || 0;
|
||||||
|
|
||||||
|
case 'decimal':
|
||||||
|
case 'percentage':
|
||||||
|
case 'money':
|
||||||
|
return typeof value === 'number' ? value : parseFloat(String(value)) || 0;
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return Boolean(value);
|
||||||
|
|
||||||
|
case 'multi_select':
|
||||||
|
case 'checkbox_group':
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
|
||||||
|
case 'json':
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
|
||||||
|
case 'address':
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
const a = value as StructuredAddress;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return { street: String(value), city: '', postalCode: '', country: '' };
|
||||||
|
|
||||||
|
case 'location':
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return value as GeoLocation;
|
||||||
|
}
|
||||||
|
return { lat: 0, lng: 0 };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. Value Formatter for Tables, Cards, and Badges
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function formatFieldValue(
|
||||||
|
type: FieldDataType | string,
|
||||||
|
value: unknown,
|
||||||
|
fieldDef?: Partial<FieldDefinition>
|
||||||
|
): string {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = normalizeFieldType(type);
|
||||||
|
|
||||||
|
switch (canonical) {
|
||||||
|
case 'money': {
|
||||||
|
const minor = typeof value === 'object' && value && 'minor' in value ? (value as MoneyAmount).minor : Number(value);
|
||||||
|
return formatCurrencyMinor(isNaN(minor) ? 0 : minor);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'percentage': {
|
||||||
|
const num = Number(value);
|
||||||
|
return `${isNaN(num) ? 0 : num}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return value ? 'Да' : 'Нет';
|
||||||
|
|
||||||
|
case 'date':
|
||||||
|
return formatDate(String(value));
|
||||||
|
|
||||||
|
case 'datetime':
|
||||||
|
return formatDateTime(String(value));
|
||||||
|
|
||||||
|
case 'time':
|
||||||
|
return String(value);
|
||||||
|
|
||||||
|
case 'rating': {
|
||||||
|
const r = Math.min(5, Math.max(0, Number(value) || 0));
|
||||||
|
return '★'.repeat(r) + '☆'.repeat(5 - r);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'multi_select':
|
||||||
|
case 'checkbox_group': {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (fieldDef?.options) {
|
||||||
|
const optMap = new Map(fieldDef.options.map((o) => [o.value, o.label]));
|
||||||
|
return value.map((v) => optMap.get(String(v)) || String(v)).join(', ');
|
||||||
|
}
|
||||||
|
return value.join(', ');
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
case 'radio':
|
||||||
|
case 'status': {
|
||||||
|
if (fieldDef?.options) {
|
||||||
|
const opt = fieldDef.options.find((o) => o.value === value);
|
||||||
|
if (opt) return opt.label;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'color':
|
||||||
|
return String(value);
|
||||||
|
|
||||||
|
case 'address': {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
const a = value as StructuredAddress;
|
||||||
|
const parts = [a.street, a.city, a.postalCode, a.country].filter(Boolean);
|
||||||
|
return parts.join(', ') || '—';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'location': {
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
const loc = value as GeoLocation;
|
||||||
|
return `${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'file':
|
||||||
|
case 'image': {
|
||||||
|
if (typeof value === 'object' && value !== null && 'name' in value) {
|
||||||
|
return String((value as FileMetadata).name);
|
||||||
|
}
|
||||||
|
return 'Файл';
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'json':
|
||||||
|
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue