From 368ed1ed9de76b4456013e4ab904ad64b6ed193c Mon Sep 17 00:00:00 2001 From: ochenstarik Date: Fri, 11 Sep 2026 01:48:37 +0700 Subject: [PATCH] feat(stage-c): implement full field types meta, dynamic renderer, and integration tests --- src/entities/types.ts | 191 +++- .../__tests__/fieldTypesContract.test.ts | 297 ++++++ .../__tests__/fieldTypesIntegration.test.tsx | 308 +++++++ src/features/metadata/DynamicCardRenderer.tsx | 318 ++++++- src/features/metadata/DynamicFormRenderer.tsx | 842 ++++++++++++++++-- src/features/metadata/DynamicTable.tsx | 34 +- .../settings/entities/ViewBuilder.tsx | 18 +- .../settings/entities/fieldTypesMeta.ts | 11 +- src/shared/lib/fieldEngine.ts | 806 +++++++++++++++++ 9 files changed, 2651 insertions(+), 174 deletions(-) create mode 100644 src/features/__tests__/fieldTypesContract.test.ts create mode 100644 src/features/__tests__/fieldTypesIntegration.test.tsx create mode 100644 src/shared/lib/fieldEngine.ts diff --git a/src/entities/types.ts b/src/entities/types.ts index b99a923..01708cb 100644 --- a/src/entities/types.ts +++ b/src/entities/types.ts @@ -25,43 +25,65 @@ export interface BaseEntity { tags?: string[]; } -export type FieldDataType = - // Text group +export interface FileMetadata { + 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' | 'long_text' | 'rich_text' - // Numeric group - | 'number' + // 2. Number | 'integer' | 'decimal' | 'money' | 'percentage' - // Logic group + // 3. Boolean | 'boolean' - // Date/Time group - | 'date' - | 'datetime' - | 'time' - // Selection group + // 4. Choice | 'select' - | 'multiselect' | 'multi_select' | 'radio' | 'checkbox_group' | 'status' | 'rating' - // Communication & Web group + // 5. DateTime + | 'date' + | 'datetime' + | 'time' + // 6. Communications | 'phone' | 'email' | 'url' - // Geographic group + // 7. Geo | 'address' | 'location' - // Media & Assets group + // 8. Media | 'file' | 'image' | 'color' - // Relational & People group + // 9. Relational / Entity references | 'user' | 'employee' | 'team' @@ -69,12 +91,147 @@ export type FieldDataType = | 'contact' | 'product' | 'entity_relation' - | 'entity_ref' - // Computed & Advanced group + // 10. Advanced / Computed | 'formula' | 'autonumber' | '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 { label: string; value: string; diff --git a/src/features/__tests__/fieldTypesContract.test.ts b/src/features/__tests__/fieldTypesContract.test.ts new file mode 100644 index 0000000..390f4f8 --- /dev/null +++ b/src/features/__tests__/fieldTypesContract.test.ts @@ -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); + }); + }); +}); diff --git a/src/features/__tests__/fieldTypesIntegration.test.tsx b/src/features/__tests__/fieldTypesIntegration.test.tsx new file mode 100644 index 0000000..91683b1 --- /dev/null +++ b/src/features/__tests__/fieldTypesIntegration.test.tsx @@ -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( + {}} + /> + ); + + // 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 | null = null; + const handleSave = (values: Record) => { + savedData = values; + }; + + render( + + ); + + // 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( + {}} + /> + ); + + // 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) => { + 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( + + ); + + // 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( + + ); + + // 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(); + }); +}); diff --git a/src/features/metadata/DynamicCardRenderer.tsx b/src/features/metadata/DynamicCardRenderer.tsx index abc6f4d..71b010b 100644 --- a/src/features/metadata/DynamicCardRenderer.tsx +++ b/src/features/metadata/DynamicCardRenderer.tsx @@ -1,11 +1,19 @@ 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 { RecordEntity, getDb } from '../../services/storage/indexedDb'; import { Button } from '../../shared/ui/Button'; import { Badge } from '../../shared/ui/Badge'; 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 { Edit2, Trash2, @@ -15,6 +23,14 @@ import { Layers, Copy, Check, + Star, + ExternalLink, + Mail, + Phone, + MapPin, + Navigation, + FileText, + Binary, } from 'lucide-react'; import { useToast } from '../../shared/ui/useToast'; @@ -75,7 +91,253 @@ export const DynamicCardRenderer: React.FC = ({ 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 —; + } + + // Rating + if (canonical === 'rating') { + const r = Math.min(5, Math.max(1, Number(val) || 5)); + return ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} + {r}/5 +
+ ); + } + + // Color + if (canonical === 'color') { + const hex = String(val); + return ( +
+ + {hex} +
+ ); + } + + // URL + if (canonical === 'url') { + const urlStr = String(val); + return ( + + {urlStr} + + + ); + } + + // Email + if (canonical === 'email') { + const emailStr = String(val); + return ( + + + {emailStr} + + ); + } + + // Phone + if (canonical === 'phone') { + const phoneStr = String(val); + return ( + + + {phoneStr} + + ); + } + + // Multi-Select or Checkbox Group + if (canonical === 'multi_select' || canonical === 'checkbox_group') { + if (Array.isArray(val)) { + if (val.length === 0) return —; + return ( +
+ {val.map((item) => { + const opt = f.options?.find((o) => o.value === item); + return ( + + {opt?.label || String(item)} + + ); + })} +
+ ); + } + return {String(val)}; + } + + // Money + if (canonical === 'money') { + const minor = typeof val === 'number' ? val : 0; + return {formatCurrencyMinor(minor)}; + } + + // Percentage + if (canonical === 'percentage') { + return {String(val)}%; + } + + // Boolean + if (canonical === 'boolean') { + return ( + + {val ? 'Да' : 'Нет'} + + ); + } + + // Date & Datetime + if (canonical === 'date' || canonical === 'datetime') { + return {canonical === 'datetime' ? formatDateTime(String(val)) : formatDate(String(val))}; + } + + // Time + if (canonical === 'time') { + return {String(val)}; + } + + // Status + if (canonical === 'status') { + const opt = f.options?.find((o) => o.value === val); + return ( + + {opt?.label || String(val)} + + ); + } + + // Select & Radio + if (canonical === 'select' || canonical === 'radio') { + const opt = f.options?.find((o) => o.value === val); + return {opt?.label || String(val)}; + } + + // File / Image + if (canonical === 'file' || canonical === 'image') { + if (typeof val === 'object' && val !== null && 'name' in val) { + const fileMeta = val as FileMetadata; + return ( +
+ {canonical === 'image' && fileMeta.url ? ( + {fileMeta.name} + ) : ( + + )} +
+ {fileMeta.name} + + {(fileMeta.size / 1024).toFixed(1)} КБ + +
+
+ ); + } + return {String(val)}; + } + + // 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 ( +
+ + {parts.join(', ') || '—'} +
+ ); + } + return ( +
+ + {String(val)} +
+ ); + } + + // Location + if (canonical === 'location') { + if (typeof val === 'object' && val !== null) { + const loc = val as GeoLocation; + return ( +
+ + {loc.lat.toFixed(4)}, {loc.lng.toFixed(4)} +
+ ); + } + return {String(val)}; + } + + // Formula + if (canonical === 'formula') { + const computed = f.formula ? evaluateSafeFormula(f.formula, record) : val; + return ( +
+ + + {computed !== null ? String(computed) : String(val)} + +
+ ); + } + + // JSON + if (canonical === 'json') { + const jsonStr = typeof val === 'object' ? JSON.stringify(val) : String(val); + return ( +
+          {jsonStr}
+        
+ ); + } + + // Autonumber + if (canonical === 'autonumber') { + return {String(val)}; + } + + // Fallback default string representation + return {String(val)}; + }; return (
@@ -179,45 +441,19 @@ export const DynamicCardRenderer: React.FC = ({
- {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 ( -
- - {f.label} - - - {f.type === 'status' ? ( - - {displayVal} - - ) : ( - displayVal - )} - + {entity.fields.map((f) => ( +
+ + {f.label} + +
+ {renderFieldValue(f)}
- ); - })} +
+ ))}
diff --git a/src/features/metadata/DynamicFormRenderer.tsx b/src/features/metadata/DynamicFormRenderer.tsx index 3ec3691..e38d0ce 100644 --- a/src/features/metadata/DynamicFormRenderer.tsx +++ b/src/features/metadata/DynamicFormRenderer.tsx @@ -1,11 +1,41 @@ 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 { RecordEntity } from '../../services/storage/indexedDb'; import { Button } from '../../shared/ui/Button'; +import { Badge } from '../../shared/ui/Badge'; 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 { 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 { entity: EntityMetadata; @@ -17,6 +47,51 @@ export interface DynamicFormRendererProps { isLoading?: boolean; } +// Preset relation options when field definition doesn't have custom options +const DEFAULT_RELATION_OPTIONS: Record = { + 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 = ({ entity, record, @@ -33,12 +108,8 @@ export const DynamicFormRenderer: React.FC = ({ ent.fields.forEach((field) => { if (rec && rec[field.key] !== undefined) { 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 { - initial[field.key] = ''; + initial[field.key] = getDefaultFieldValue(field.type, field); } }); return initial; @@ -81,7 +152,7 @@ export const DynamicFormRenderer: React.FC = ({ }, [layout, entity]); // Check conditional visibility - const isFieldVisible = (field: FieldDefinition): boolean => { + const isFieldVisible = useCallback((field: FieldDefinition): boolean => { if (field.hidden) return false; if (!field.conditionalVisibility || !field.conditionalVisibility.fieldKey) return true; @@ -98,7 +169,7 @@ export const DynamicFormRenderer: React.FC = ({ default: return true; } - }; + }, [formValues]); const handleFieldChange = (key: string, val: unknown) => { setFormValues((prev) => ({ ...prev, [key]: val })); @@ -119,45 +190,13 @@ export const DynamicFormRenderer: React.FC = ({ if (!isFieldVisible(field)) return; const val = formValues[field.key]; + const result = validateFieldValue(field.type, val, field.validation, field); - // 1. Required validation - if (field.required) { - if (val === undefined || val === null || val === '') { + if (!result.valid) { + if (result.error === 'Обязательное поле для заполнения') { newErrors[field.key] = `Поле «${field.label}» обязательно для заполнения`; - return; - } - } - - // 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 - } - } + } else { + newErrors[field.key] = result.error || `Поле «${field.label}» заполнено неверно`; } } }); @@ -176,7 +215,19 @@ export const DynamicFormRenderer: React.FC = ({ } try { - await onSave(formValues); + const serializedValues: Record = {}; + 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) { showToast({ type: 'error', @@ -186,14 +237,18 @@ export const DynamicFormRenderer: React.FC = ({ } }; - // Render individual input based on field type + // --------------------------------------------------------------------------- + // Specialized Input Renderers for All Canonical Field Types + // --------------------------------------------------------------------------- + const renderFieldInput = (field: FieldDefinition) => { const rawVal = formValues[field.key]; const isReadOnly = mode === 'readonly' || field.readOnly; const hasError = !!errors[field.key]; + const canonical = normalizeFieldType(field.type); - // Money in minor units (rubles / kopecks) - if (field.type === 'money') { + // 1. Money in minor units (integer kopecks) + if (canonical === 'money') { const minorVal = typeof rawVal === 'number' ? rawVal : 0; const majorVal = (minorVal / 100).toString(); @@ -228,12 +283,8 @@ export const DynamicFormRenderer: React.FC = ({ ); } - // Select / Status - if ( - field.type === 'select' || - field.type === 'status' || - field.type === 'radio' - ) { + // 2. Select & Status (Single choice) + if (canonical === 'select' || canonical === 'status') { if (isReadOnly) { const opt = field.options?.find((o) => o.value === rawVal); return ( @@ -262,8 +313,185 @@ export const DynamicFormRenderer: React.FC = ({ ); } - // Boolean / Checkbox - if (field.type === 'boolean') { + // 3. Radio Buttons + 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 ( +
+ {opt?.label || String(rawVal || '—')} +
+ ); + } + + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); + } + + // 4. Multi-Select with Chips + if (canonical === 'multi_select') { + const selected = Array.isArray(rawVal) ? (rawVal as string[]) : []; + const options = field.options || []; + + if (isReadOnly) { + return ( +
+ {selected.length > 0 ? ( + selected.map((val) => { + const label = options.find((o) => o.value === val)?.label || val; + return ( + + {label} + + ); + }) + ) : ( + Ничего не выбрано + )} +
+ ); + } + + const unselectedOptions = options.filter((o) => !selected.includes(o.value)); + + return ( +
+ {selected.length > 0 && ( +
+ {selected.map((val) => { + const label = options.find((o) => o.value === val)?.label || val; + return ( + + {label} + + + ); + })} +
+ )} + + +
+ ); + } + + // 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 ( +
+ {options.map((opt) => { + const isChecked = selected.includes(opt.value); + return ( + + ); + })} +
+ ); + } + + // 6. Rating (1–5 Stars) + if (canonical === 'rating') { + const rating = Math.min(5, Math.max(1, Number(rawVal) || 5)); + + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} + {rating} / 5 +
+ ); + } + + // 7. Boolean / Checkbox + if (canonical === 'boolean') { return ( ); } - // Date / Datetime - if (field.type === 'date' || field.type === 'datetime') { - const inputType = field.type === 'datetime' ? 'datetime-local' : 'date'; + // 8. Date, Datetime, Time + if (canonical === 'date' || canonical === 'datetime' || canonical === 'time') { + const inputType = canonical === 'datetime' ? 'datetime-local' : canonical === 'time' ? 'time' : 'date'; return ( = ({ ); } - // Long text / Rich text / JSON - if (field.type === 'long_text' || field.type === 'rich_text' || field.type === 'json') { + // 9. URL with External Link Preview + if (canonical === 'url') { return ( -