feat(builders): runtime execution of form, card, and view layouts, strict visibility, and 50-step undo/redo
This commit is contained in:
parent
42ac2f478a
commit
5200b7ebd5
4 changed files with 578 additions and 40 deletions
|
|
@ -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,27 +445,177 @@ 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>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-xs">
|
||||
{entity.fields.map((f) => (
|
||||
<div
|
||||
key={f.key}
|
||||
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
|
||||
>
|
||||
<span className="text-[11px] text-krona-secondary block mb-1">
|
||||
{f.label}
|
||||
</span>
|
||||
<div className="font-semibold text-krona-primary break-words">
|
||||
{renderFieldValue(f)}
|
||||
{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>
|
||||
))}
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{f.label}
|
||||
</span>
|
||||
<div className="font-semibold text-krona-primary break-words">
|
||||
{renderFieldValue(f)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
const currentVal = formValues[fieldKey];
|
||||
// Check conditional visibility and role permissions
|
||||
const isFieldVisible = useCallback(
|
||||
(field: FieldDefinition): boolean => {
|
||||
if (field.hidden) return false;
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return String(currentVal ?? '') === String(value ?? '');
|
||||
case 'neq':
|
||||
return String(currentVal ?? '') !== String(value ?? '');
|
||||
case 'contains':
|
||||
return String(currentVal ?? '').includes(String(value ?? ''));
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}, [formValues]);
|
||||
// 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':
|
||||
if (String(currentVal ?? '') !== String(value ?? '')) return false;
|
||||
break;
|
||||
case 'neq':
|
||||
if (String(currentVal ?? '') === String(value ?? '')) return false;
|
||||
break;
|
||||
case 'empty':
|
||||
if (
|
||||
currentVal !== undefined &&
|
||||
currentVal !== null &&
|
||||
currentVal !== '' &&
|
||||
(!Array.isArray(currentVal) || currentVal.length > 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
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 }));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue