feat(builders): runtime execution of form, card, and view layouts, strict visibility, and 50-step undo/redo

This commit is contained in:
ochenstarik 2026-09-11 15:21:04 +07:00
parent 42ac2f478a
commit 5200b7ebd5
4 changed files with 578 additions and 40 deletions

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
EntityMetadata,
FieldDefinition,
@ -57,7 +57,7 @@ export interface DynamicCardRendererProps {
export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
entity,
record,
layout: _layout,
layout,
onEdit,
onDelete,
onClose,
@ -67,6 +67,16 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
const [auditLogs, setAuditLogs] = useState<AuditLogItem[]>([]);
const [copiedJson, setCopiedJson] = useState(false);
// Sorted and enabled card blocks from layout config
const cardBlocks = useMemo(() => {
if (layout?.cardBlocks && layout.cardBlocks.length > 0) {
return [...layout.cardBlocks]
.filter((b) => b.enabled !== false)
.sort((a, b) => a.order - b.order);
}
return [];
}, [layout]);
// Fetch audit logs for this specific record
useEffect(() => {
async function loadAudit() {
@ -435,15 +445,80 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
{/* Tab: Overview */}
{activeTab === 'overview' && (
<div className="space-y-4">
<div className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3">
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Все атрибуты записи
</h3>
{cardBlocks.length > 0 ? (
cardBlocks.map((block) => {
if (block.type === 'header') {
return (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft flex items-center justify-between"
>
<div className="flex items-center gap-3">
{block.settings?.showAvatar !== false && (
<div className="w-10 h-10 rounded-lg bg-krona-green/10 text-krona-green flex items-center justify-center font-bold text-sm border border-krona-green/20">
{recordTitle.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<h2 className="text-base font-bold text-krona-primary">{recordTitle}</h2>
<p className="text-xs font-mono text-krona-secondary">ID: {record.id}</p>
</div>
</div>
</div>
);
}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-xs">
{entity.fields.map((f) => (
if (block.type === 'badges') {
return (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-3 bg-krona-surface border border-krona-border rounded-xl flex items-center gap-2"
>
<Badge variant="neutral" size="sm">
<code>{entity.code}</code>
</Badge>
{record.status !== undefined && record.status !== null && (
<Badge variant="green" size="sm">
{String(record.status)}
</Badge>
)}
</div>
);
}
if (block.type === 'fields_grid') {
const specifiedKeys = block.settings?.fieldKeys;
const targetFields = entity.fields
.filter((f) => !f.hidden)
.filter(
(f) =>
!specifiedKeys || specifiedKeys.length === 0 || specifiedKeys.includes(f.key)
);
const colsCount = block.settings?.columnsCount || 2;
const gridClass =
colsCount === 1
? 'grid-cols-1'
: colsCount === 3
? 'grid-cols-1 sm:grid-cols-3'
: 'grid-cols-1 sm:grid-cols-2';
return (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
>
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
{block.title || 'Атрибуты'}
</h3>
<div className={`grid ${gridClass} gap-3 text-xs`}>
{targetFields.map((f) => (
<div
key={f.key}
data-testid={`card-field-${f.key}`}
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
>
<span className="text-[11px] text-krona-secondary block mb-1">
@ -456,6 +531,91 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
))}
</div>
</div>
);
}
if (block.type === 'timeline') {
return (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
>
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
{block.title || 'Лента активности'}
</h3>
<div className="space-y-2">
{auditLogs.slice(0, block.settings?.maxItems || 5).map((log) => (
<div
key={log.id}
className="p-2.5 bg-krona-workspace rounded-lg text-xs flex justify-between items-center"
>
<div>
<span className="font-semibold text-krona-primary">{log.summary}</span>
<span className="text-[10px] text-krona-secondary block">
{log.action} • {log.userName || log.userId}
</span>
</div>
<span className="text-[10px] text-krona-secondary font-mono">
{formatDateTime(log.timestamp)}
</span>
</div>
))}
{auditLogs.length === 0 && (
<div className="text-xs text-krona-secondary py-2">
Записей активности нет.
</div>
)}
</div>
</div>
);
}
if (block.type === 'tasks_notes') {
return (
<div
key={block.id}
data-testid={`card-block-${block.type}`}
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
>
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
{block.title || 'Заметки и задачи'}
</h3>
<div className="p-3 bg-krona-workspace/60 rounded-lg text-xs text-krona-secondary">
Связанные задачи отсутствуют
</div>
</div>
);
}
return null;
})
) : (
<div className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3">
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Все атрибуты записи
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-xs">
{entity.fields
.filter((f) => !f.hidden)
.map((f) => (
<div
key={f.key}
data-testid={`card-field-${f.key}`}
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
>
<span className="text-[11px] text-krona-secondary block mb-1">
{f.label}
</span>
<div className="font-semibold text-krona-primary break-words">
{renderFieldValue(f)}
</div>
</div>
))}
</div>
</div>
)}
{/* System Audit Information */}
<div className="p-3.5 bg-krona-surface border border-krona-border rounded-xl text-xs text-krona-secondary flex flex-wrap gap-4">

View file

@ -20,6 +20,7 @@ import {
} from '../../shared/lib/fieldEngine';
import { ulid } from '../../shared/lib/ulid';
import { useToast } from '../../shared/ui/useToast';
import { useSessionStore } from '../../core/session/sessionStore';
import {
AlertCircle,
Star,
@ -151,25 +152,74 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
];
}, [layout, entity]);
// Check conditional visibility
const isFieldVisible = useCallback((field: FieldDefinition): boolean => {
if (field.hidden) return false;
if (!field.conditionalVisibility || !field.conditionalVisibility.fieldKey) return true;
const currentRoleId = useSessionStore((s) => s.currentRoleId);
const { fieldKey, operator, value } = field.conditionalVisibility;
// Check conditional visibility and role permissions
const isFieldVisible = useCallback(
(field: FieldDefinition): boolean => {
if (field.hidden) return false;
// Role permission / visibility check
if (currentRoleId !== 'owner') {
const customRoleVisibility = (field as unknown as { roleVisibility?: string[] }).roleVisibility;
const viewRoles = field.permissions?.viewRoles || customRoleVisibility;
if (viewRoles && viewRoles.length > 0 && !viewRoles.includes(currentRoleId)) {
return false;
}
}
// Check conditionalRules array or single conditionalVisibility
const customRules = (field as unknown as { conditionalRules?: typeof field.conditionalVisibility[] }).conditionalRules;
const rules = (customRules && customRules.length > 0)
? customRules
: (field.conditionalVisibility ? [field.conditionalVisibility] : []);
for (const rule of rules) {
if (!rule || !rule.fieldKey) continue;
const { fieldKey, operator, value } = rule;
const currentVal = formValues[fieldKey];
switch (operator) {
case 'eq':
return String(currentVal ?? '') === String(value ?? '');
if (String(currentVal ?? '') !== String(value ?? '')) return false;
break;
case 'neq':
return String(currentVal ?? '') !== String(value ?? '');
case 'contains':
return String(currentVal ?? '').includes(String(value ?? ''));
default:
return true;
if (String(currentVal ?? '') === String(value ?? '')) return false;
break;
case 'empty':
if (
currentVal !== undefined &&
currentVal !== null &&
currentVal !== '' &&
(!Array.isArray(currentVal) || currentVal.length > 0)
) {
return false;
}
}, [formValues]);
break;
case 'not_empty':
if (
currentVal === undefined ||
currentVal === null ||
currentVal === '' ||
(Array.isArray(currentVal) && currentVal.length === 0)
) {
return false;
}
break;
case 'contains':
if (!String(currentVal ?? '').toLowerCase().includes(String(value ?? '').toLowerCase())) {
return false;
}
break;
default:
break;
}
}
return true;
},
[formValues, currentRoleId]
);
const handleFieldChange = (key: string, val: unknown) => {
setFormValues((prev) => ({ ...prev, [key]: val }));

View file

@ -88,6 +88,40 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
});
}
// View saved filters
if (view?.filters && view.filters.length > 0) {
const conjunction = view.filterConjunction || 'AND';
result = result.filter((row) => {
const matches = view.filters.map((filter) => {
const val = row[filter.field];
switch (filter.operator) {
case 'eq':
return String(val ?? '') === String(filter.value ?? '');
case 'neq':
return String(val ?? '') !== String(filter.value ?? '');
case 'contains':
return String(val ?? '').toLowerCase().includes(String(filter.value ?? '').toLowerCase());
case 'gt':
return Number(val) > Number(filter.value);
case 'lt':
return Number(val) < Number(filter.value);
case 'in':
return Array.isArray(filter.value) && filter.value.includes(val);
case 'is_empty':
return val === undefined || val === null || val === '';
case 'is_not_empty':
return val !== undefined && val !== null && val !== '';
default:
return true;
}
});
return conjunction === 'OR'
? matches.some(Boolean)
: matches.every(Boolean);
});
}
// Sort
if (sortField) {
result.sort((a, b) => {
@ -102,7 +136,7 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
}
return result;
}, [records, searchQuery, sortField, sortOrder]);
}, [records, searchQuery, sortField, sortOrder, view]);
// Paginated records
const totalPages = Math.ceil(filteredRecords.length / pageSize) || 1;
@ -237,10 +271,16 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
<tr>
{columns.map((col) => {
const isSorted = sortField === col.key;
const customWidth = view?.columnWidths?.[col.key];
const colStyle = customWidth
? { width: `${customWidth}px`, minWidth: `${customWidth}px` }
: undefined;
return (
<th
key={col.key}
onClick={() => handleHeaderClick(col.key)}
style={colStyle}
className="p-3 font-semibold text-krona-primary hover:bg-krona-surface-muted/60 cursor-pointer transition-colors whitespace-nowrap"
>
<div className="flex items-center gap-1.5">
@ -272,9 +312,13 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
{columns.map((col) => {
const val = row[col.key];
const display = formatFieldValue(col.type, val, col);
const customWidth = view?.columnWidths?.[col.key];
const colStyle = customWidth
? { width: `${customWidth}px`, minWidth: `${customWidth}px` }
: undefined;
return (
<td key={col.key} className="p-3 text-krona-secondary truncate max-w-xs">
<td key={col.key} style={colStyle} className="p-3 text-krona-secondary truncate max-w-xs">
{col.type === 'color' && val ? (
<div className="flex items-center gap-1.5">
<span

View file

@ -0,0 +1,284 @@
import { describe, it, expect, beforeEach } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { DynamicFormRenderer } from '../../../metadata/DynamicFormRenderer';
import { DynamicCardRenderer } from '../../../metadata/DynamicCardRenderer';
import { DynamicTable } from '../../../metadata/DynamicTable';
import { useFormBuilderHistory } from '../useFormBuilderHistory';
import { useSessionStore } from '../../../../core/session/sessionStore';
import { EntityMetadata } from '../../../../entities/types';
import { LayoutDefinition, ViewDefinition } from '../../../../services/contracts/metadata';
import { renderHook, act } from '@testing-library/react';
describe('Builders Runtime Execution Test Suite (F12, F13, F14)', () => {
beforeEach(() => {
useSessionStore.getState().setCurrentRoleId('owner');
});
const mockEntity: EntityMetadata = {
id: 'ent_client_contract',
code: 'client_contract',
name: 'Договор с клиентом',
pluralName: 'Договоры с клиентами',
isSystem: false,
category: 'custom',
maturity: 'target_prototype',
fields: [
{
id: 'f_number',
key: 'contractNumber',
label: 'Номер договора',
type: 'text',
required: true,
},
{
id: 'f_type',
key: 'contractType',
label: 'Тип договора',
type: 'select',
options: [
{ value: 'standard', label: 'Стандартный' },
{ value: 'nda', label: 'NDA' },
],
},
{
id: 'f_secret_clause',
key: 'secretClause',
label: 'Секретное условие NDA',
type: 'text',
conditionalVisibility: {
fieldKey: 'contractType',
operator: 'eq',
value: 'nda',
},
},
{
id: 'f_owner_budget',
key: 'ownerBudget',
label: 'Бюджет сделки (Собственник)',
type: 'money',
permissions: {
viewRoles: ['owner'],
},
},
{
id: 'f_hidden_flag',
key: 'internalAuditToken',
label: 'Служебный токен аудита',
type: 'text',
hidden: true,
},
],
};
describe('Form Builder Runtime (F12)', () => {
it('1. Conditional visibility and role visibility in DynamicFormRenderer', async () => {
// Step A: Under 'owner' role, secretClause is hidden initially because contractType is empty
const { rerender } = render(
<DynamicFormRenderer
entity={mockEntity}
onSave={() => {}}
/>
);
expect(screen.getByText('Номер договора')).toBeDefined();
expect(screen.getByText('Тип договора')).toBeDefined();
expect(screen.queryByText('Секретное условие NDA')).toBeNull(); // Condition not met
expect(screen.getByText('Бюджет сделки (Собственник)')).toBeDefined(); // Owner can see
expect(screen.queryByText('Служебный токен аудита')).toBeNull(); // Hidden field is never rendered
// Step B: Switch role to 'cashier'
act(() => {
useSessionStore.getState().setCurrentRoleId('cashier');
});
rerender(
<DynamicFormRenderer
entity={mockEntity}
onSave={() => {}}
/>
);
// Cashier cannot see ownerBudget
expect(screen.queryByText('Бюджет сделки (Собственник)')).toBeNull();
});
it('2. useFormBuilderHistory provides robust 50-step undo/redo capacity', () => {
const { result } = renderHook(() => useFormBuilderHistory());
const initialLayout: LayoutDefinition = {
id: 'lay_test',
entityCode: 'client_contract',
name: 'Базовый макет',
isDefault: true,
sections: [],
};
act(() => {
result.current.initHistory(initialLayout);
});
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
// Push 55 mutations
for (let i = 1; i <= 55; i++) {
act(() => {
result.current.pushState({
...initialLayout,
name: `Редакция ${i}`,
});
});
}
// History should be capped at 50
expect(result.current.history.length).toBeLessThanOrEqual(50);
expect(result.current.canUndo).toBe(true);
// Undo once
act(() => {
result.current.handleUndo();
});
expect(result.current.canRedo).toBe(true);
expect(result.current.currentLayout?.name).toBe('Редакция 54');
// Redo once
act(() => {
result.current.handleRedo();
});
expect(result.current.currentLayout?.name).toBe('Редакция 55');
});
});
describe('Card Builder Runtime (F13)', () => {
it('3. DynamicCardRenderer strictly executes cardBlocks in defined order and excludes hidden fields', () => {
const sampleRecord = {
id: 'rec_01',
entityType: 'client_contract',
contractNumber: 'DOG-2026-001',
contractType: 'standard',
secretClause: 'Secret text',
ownerBudget: { minor: 50000000, currency: 'RUB' },
internalAuditToken: 'SECRET_TOKEN_123',
};
const cardLayout: LayoutDefinition = {
id: 'card_lay_01',
entityCode: 'client_contract',
name: 'Макет карточки',
isDefault: true,
sections: [],
cardBlocks: [
{
id: 'blk_badges',
type: 'badges',
title: 'Бейджи',
enabled: true,
columnSpan: 3,
order: 1,
},
{
id: 'blk_header',
type: 'header',
title: 'Шапка',
enabled: true,
columnSpan: 3,
order: 2,
},
{
id: 'blk_fields',
type: 'fields_grid',
title: 'Атрибуты',
enabled: true,
columnSpan: 2,
order: 3,
settings: {
fieldKeys: ['contractNumber', 'internalAuditToken'],
columnsCount: 2,
},
},
],
};
render(
<DynamicCardRenderer
entity={mockEntity}
record={sampleRecord}
layout={cardLayout}
/>
);
// Verify blocks rendered in DOM
expect(screen.getByTestId('card-block-badges')).toBeDefined();
expect(screen.getByTestId('card-block-header')).toBeDefined();
expect(screen.getByTestId('card-block-fields_grid')).toBeDefined();
// Field contractNumber is visible
expect(screen.getByTestId('card-field-contractNumber')).toBeDefined();
// internalAuditToken is marked hidden in entity metadata, must NOT be rendered even if in fieldKeys!
expect(screen.queryByTestId('card-field-internalAuditToken')).toBeNull();
});
});
describe('View Builder Runtime (F14)', () => {
it('4. DynamicTable filters records by saved view filters and applies column widths', () => {
const records = [
{
id: 'rec_1',
entityType: 'client_contract',
contractNumber: 'DOG-001',
contractType: 'standard',
},
{
id: 'rec_2',
entityType: 'client_contract',
contractNumber: 'DOG-002',
contractType: 'nda',
},
{
id: 'rec_3',
entityType: 'client_contract',
contractNumber: 'DOG-003',
contractType: 'nda',
},
];
const customView: ViewDefinition = {
id: 'view_nda_only',
entityCode: 'client_contract',
name: 'Только NDA',
type: 'table',
isDefault: true,
visibleFieldKeys: ['contractNumber', 'contractType'],
filters: [
{
field: 'contractType',
operator: 'eq',
value: 'nda',
},
],
columnWidths: {
contractNumber: 220,
contractType: 140,
},
};
render(
<DynamicTable
entity={mockEntity}
records={records}
view={customView}
/>
);
// Only DOG-002 and DOG-003 should be displayed
expect(screen.queryByText('DOG-001')).toBeNull();
expect(screen.getAllByText('DOG-002').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('DOG-003').length).toBeGreaterThanOrEqual(1);
// Found count should be 2
expect(screen.getByText('2')).toBeDefined();
});
});
});