feat(stage-c): implement full field types meta, dynamic renderer, and integration tests

This commit is contained in:
ochenstarik 2026-09-11 01:48:37 +07:00
parent 1930e41530
commit 368ed1ed9d
9 changed files with 2651 additions and 174 deletions

View file

@ -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;

View 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);
});
});
});

View 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();
});
});

View file

@ -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,26 +441,7 @@ 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];
let displayVal = '—';
if (val !== undefined && val !== null && val !== '') {
if (f.type === 'money') {
displayVal = typeof val === 'number' ? formatCurrencyMinor(val) : String(val);
} else if (f.type === 'boolean') {
displayVal = val ? 'Да' : 'Нет';
} else if (f.type === 'date' || f.type === 'datetime') {
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 <div
key={f.key} key={f.key}
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between" className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
@ -206,18 +449,11 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
<span className="text-[11px] text-krona-secondary block mb-1"> <span className="text-[11px] text-krona-secondary block mb-1">
{f.label} {f.label}
</span> </span>
<span className="font-semibold text-krona-primary break-words"> <div className="font-semibold text-krona-primary break-words">
{f.type === 'status' ? ( {renderFieldValue(f)}
<Badge variant="neutral" size="sm">
{displayVal}
</Badge>
) : (
displayVal
)}
</span>
</div> </div>
); </div>
})} ))}
</div> </div>
</div> </div>

View file

@ -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"
value={String(rawVal ?? '')}
onChange={(e) => handleFieldChange(field.key, e.target.value)} onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={isReadOnly} disabled={isReadOnly}
placeholder={field.placeholder || 'Введите текст...'} placeholder={field.placeholder || 'https://example.com'}
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 pr-8 ${
field.type === 'json' ? 'font-mono' : '' 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 ( 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 (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={validHex.length === 7 ? validHex : '#2F684C'}
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 <input
type="number" type="number"
step={field.type === 'decimal' || field.type === 'percentage' ? '0.01' : '1'} step="0.0001"
value={rawVal !== undefined && rawVal !== null ? String(rawVal) : ''} min="-90"
onChange={(e) => max="90"
handleFieldChange(field.key, e.target.value === '' ? '' : Number(e.target.value)) 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}

View file

@ -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>

View file

@ -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 });
} }
} }
}); });

View file

@ -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,

View 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);
}
}