Здравствуйте!
Сейчас в Nocobase к полю в таблице можно подключить только один JS-обработчик рендера (ctx.element / ctx.render). Из-за этого приходится складывать всю логику отображения ячейки в один монолитный скрипт: форматирование значений, цветовая заливка (зебра, RGB, статусные цвета, подсветка расхождений), кнопка копирования, тултипы, inline-редактирование, открытие просмотра записи и т. д. У нас такой скрипт уже разросся до ~400 строк и переключается флагами в шапке.
Это создаёт несколько проблем:
- Переиспользование: нельзя взять «только кнопку Copy» или «только зебру» в другом поле — приходится копировать весь файл и отключать ненужное флагами.
- Поддержка: правка одного эффекта требует понимания всего скрипта, велик риск сломать соседнюю логику.
- Композиция: невозможно собрать поведение поля «из кубиков» — например, «зебра + тултип + редактирование» без копипасты.
- Производительность: один большой скрипт с общим
ctx.observeсрабатывает на любое изменение, даже если активна лишь часть фич.
Предложение: дать возможность подключать к одному полю несколько независимых JS-скриптов (цепочка / стек рендереров), которые применяются последовательно к одному и тому же ctx.element. Каждый скрипт включается/отключается отдельно в настройках поля. Можно представить это как «плагины ячейки»: один отвечает за фон, другой за кнопку Copy, третий за тултип и т. д.
Вопросы:
- Рассматривается ли такая возможность в роадмапе?
- Если да, можно ли заложить порядок применения (приоритет) и общий доступ к
ctxмежду скриптами? - Если нет — какой подход вы рекомендуете для модульной композиции поведения ячейки в текущей архитектуре?
Спасибо!
Сейчас у меня код выглядит так:
// Версия кода 0115 (Добавлены переменные enableRgbBackground и rgbBackgroundColor для кастомной заливки цвета ячеек. Добавлена специальная обработка Position_units: извлекает value.Position_units ?? value.id; createdBy/updatedBy показывают displayName)
const utils = {
isValidUrl(string) {
try { new URL(string); return true; } catch (_) { return false; }
},
normalizeUrl(url) {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ftp://')) return url;
return 'https://' + url;
},
formatDate(value, ctx) {
if (!value) return '';
// Обработка belongsTo объектов
if (typeof value === 'object' && !Array.isArray(value)) {
const fieldName = ctx.collectionField?.name ?? '';
const baseName = fieldName.replace(/_current$|_requests$/, '');
// Специальная обработка для Position_units
if (baseName === 'Position_units') {
value = value.Position_units ?? value.id ?? '';
}
// Специальная обработка для пользовательских полей (createdBy, updatedBy)
else if (fieldName.includes('createdBy') || fieldName.includes('updatedBy')) {
if (value.nickname_short) {
value = value.nickname_short;
} else if (value.nickname) {
value = value.nickname;
} else if (value.GIS_name_two && value.GIS_name_one && value.GIS_name_three) {
value = `${value.GIS_name_two} ${value.GIS_name_one} ${value.GIS_name_three}`;
} else {
value = value.id ?? '';
}
}
// Для остальных объектов - преобразуем в строку как есть
}
const fieldType = ctx.collectionField?.type;
const fieldInterface = ctx.collectionField?.interface;
if (fieldType === 'date' || fieldInterface === 'createdAt' || fieldInterface === 'updatedAt') {
try { return ctx.libs.dayjs(value).format('DD.MM.YYYY в HH:mm'); } catch (e) {}
}
return String(value ?? '');
},
getDisplayValue(ctx) {
const fieldName = ctx.collectionField?.name ?? '';
const record = ctx.record ?? {};
let value = record[fieldName];
if (!value) return '';
// Если это объект (belongsTo связь), извлекаем нужное поле
if (typeof value === 'object' && !Array.isArray(value)) {
const baseName = fieldName.replace(/_current$|_requests$/, '');
if (baseName === 'Position_units') {
value = value.Position_units ?? value.id ?? '';
} else {
// Приоритет: nickname_short → nickname → ФИО → ID
if (value.nickname_short) {
value = value.nickname_short;
} else if (value.nickname) {
value = value.nickname;
} else if (value.GIS_name_two && value.GIS_name_one && value.GIS_name_three) {
value = `${value.GIS_name_two} ${value.GIS_name_one} ${value.GIS_name_three}`;
} else {
value = value.id ?? '';
}
}
}
// Если это дата, форматируем её
const fieldType = ctx.collectionField?.type;
const fieldInterface = ctx.collectionField?.interface;
if (fieldType === 'date' || fieldInterface === 'createdAt' || fieldInterface === 'updatedAt') {
try {
const formatted = ctx.libs.dayjs(value).format('DD.MM.YYYY в HH:mm');
if (!formatted.includes('Invalid')) {
return formatted;
}
} catch (e) {}
}
return String(value ?? '');
},
getBackgroundColor({ enableComparison, enableGrayBackground, enableFieldBackground, fieldBackgroundColor, enableRgbBackground, rgbBackgroundColor, Requests, Current }) {
if (enableFieldBackground && fieldBackgroundColor) return fieldBackgroundColor;
if (enableComparison && Requests !== null && Requests !== Current) return '#ffef88';
if (enableRgbBackground && rgbBackgroundColor) return rgbBackgroundColor;
if (enableGrayBackground) return '#F7F7F7';
return 'transparent';
},
applyTextHighlight(element, { shouldHighlightRed, shouldHighlightGreen }) {
if (shouldHighlightRed) {
element.style.color = '#d32f2f';
element.style.fontWeight = '600';
} else if (shouldHighlightGreen) {
element.style.color = '#7BD960';
element.style.fontWeight = '600';
}
},
resolveComparisonFields(ctx) {
const fieldName = ctx.collectionField?.name ?? '';
const record = ctx.record ?? {};
let currentValue = record[fieldName];
let requestsValue = null;
if (fieldName.endsWith('_current')) {
requestsValue = record[fieldName.replace(/_current$/, '_requests')];
} else if (fieldName.endsWith('_requests')) {
requestsValue = record[fieldName];
currentValue = record[fieldName.replace(/_requests$/, '_current')];
}
// Логика извлечения значения из объектов связей
const extractValue = (val) => {
if (!val) return null;
if (typeof val === 'object' && !Array.isArray(val)) {
const baseName = fieldName.replace(/_current$|_requests$/, '');
if (baseName === 'Position_units') {
return val.Position_units ?? val.id ?? val;
}
// Приоритет: nickname_short → nickname → ФИО
if (val.nickname_short) {
return val.nickname_short;
} else if (val.nickname) {
return val.nickname;
} else if (val.GIS_name_two && val.GIS_name_one && val.GIS_name_three) {
return `${val.GIS_name_two} ${val.GIS_name_one} ${val.GIS_name_three}`;
}
return val.id ?? val;
}
return val;
};
return {
Current: extractValue(currentValue) ?? null,
Requests: extractValue(requestsValue) ?? null
};
},
resolveFieldStatusBackground(ctx) {
const fieldName = ctx.collectionField?.name ?? '';
const record = ctx.record ?? {};
const codeIUS = record['Code_IUS']; const codeES = record['Code_ES'];
const requestIUS = record['Request_IUS']; const requestES = record['Request_ES'];
const hasCodeIUS = !!String(codeIUS || '').trim();
const hasCodeES = !!String(codeES || '').trim();
const hasRequestIUS = !!String(requestIUS || '').trim();
const hasRequestES = !!String(requestES || '').trim();
if (fieldName === 'Request_IUS') return (!hasCodeIUS && hasRequestIUS) ? '#EDF9E8' : null;
if (fieldName === 'Code_IUS') return hasCodeIUS ? '#EDF9E8' : null;
if (fieldName === 'Request_ES') return (!hasCodeES && hasRequestES) ? '#E7F1FF' : null;
if (fieldName === 'Code_ES') return hasCodeES ? '#E7F1FF' : null;
return null;
},
async saveFieldValue({ ctx, recordId, fieldName, newValue, onSuccess }) {
try {
await ctx.request?.({
url: `${ctx.collection?.name}:update`,
method: 'patch',
params: { filterByTk: String(recordId) },
data: { [fieldName]: newValue },
});
onSuccess?.();
} catch (error) { ctx.message.error('Ошибка сохранения'); }
}
};
// ==========================================
// 1. КОНФИГУРАЦИЯ
// ==========================================
const isTargetField = false; // Открывать просмотр записи по клику? (true/false)
const enableComparison = false; // Подсвечивать желтым расхождения current/requests?
const enableRedHighlight = false; // (Зарезервировано)
const enableGreenHighlight = false; // (Зарезервировано)
const enableGrayBackground = true; // Чередование серого фона (зебра)?
const enableRgbBackground = false; // Включить заливку кастомным цветом?
const rgbBackgroundColor = '#19A8E1'; // Цвет заливки для enableRgbBackground
const enableWordWrap = true; // Перенос строк (true) или обрезка многоточием (false)?
const copyButtonPosition = 'inline'; // Позиция кнопки копирования: 'right' (справа) или 'inline' (рядом с текстом)
const urlDisplayMode = 'label'; // Вид ссылок: 'label' (текст "Ссылка на сайт") или 'full' (полный URL)
const enableFieldStatus = false; // Включить логику статусных цветов для полей IUS/ES?
const enableInlineEdit = false; // Редактирование текста по двойному клику?
const enableTooltip = false; // Показывать тултип с полным текстом при наведении?
const tooltipMaxWidth = 300; // Макс. ширина тултипа
const tooltipBgColor = ''; // Цвет фона тултипа: '' = по умолчанию, или '#000000', '#fff' и т.д.
const tooltipTextColor = ''; // Цвет текста тултипа: '' = по умолчанию, или '#fff', '#333' и т.д.
// ==========================================
// 2. ФУНКЦИЯ РЕНДЕРА
// ==========================================
// Инициализация кэша для оптимизации
if (!ctx.element.__cacheData) {
ctx.element.__cacheData = {
lastFieldValue: undefined,
lastComparisonState: null,
lastStatusBg: null
};
}
const render = () => {
// Проверка изменений перед рендером
const fieldName = ctx.collectionField?.name;
const currentValue = ctx.record?.[fieldName];
const cache = ctx.element.__cacheData;
let needsRender = cache.lastFieldValue !== currentValue;
if ((enableComparison || enableFieldStatus) && !needsRender) {
const { Current, Requests } = utils.resolveComparisonFields(ctx);
const currentComparison = JSON.stringify({ Current, Requests });
if (cache.lastComparisonState !== currentComparison) needsRender = true;
if (!needsRender && enableFieldStatus) {
const statusBg = utils.resolveFieldStatusBackground(ctx);
if (cache.lastStatusBg !== statusBg) needsRender = true;
}
}
// При первом рендере всегда пересоздаём (cache.lastFieldValue === undefined)
if (cache.lastFieldValue === undefined) needsRender = true;
// Если ничего не изменилось, не пересоздаём компонент
if (!needsRender) return;
const { Current, Requests } = utils.resolveComparisonFields(ctx);
const fieldStatusColor = enableFieldStatus ? utils.resolveFieldStatusBackground(ctx) : null;
const bgColor = utils.getBackgroundColor({
enableComparison, enableGrayBackground,
enableFieldBackground: !!fieldStatusColor, fieldBackgroundColor: fieldStatusColor,
enableRgbBackground, rgbBackgroundColor,
Requests, Current
});
const shouldHighlightRed = enableRedHighlight && Requests !== null && Requests !== Current;
const shouldHighlightGreen = enableGreenHighlight && Requests !== null && Requests !== Current;
ctx.element.innerHTML = '';
Object.assign(ctx.element.style, {
backgroundColor: bgColor, width: '100%', display: 'flex', position: 'relative',
minHeight: '28px', height: enableWordWrap ? 'auto' : '28px',
borderRadius: '8px', overflow: 'hidden', alignItems: 'flex-start'
});
const text = utils.formatDate(ctx.value, ctx);
const hasValue = text.trim().length > 0;
const isUrl = utils.isValidUrl(text);
const openRecordView = async () => {
try {
const popupUid = ctx.model.uid + '-1';
await ctx.openView(popupUid, { mode: 'dialog', title: ctx.t('View Details'), size: 'medium' });
} catch (error) { ctx.setAction?.('view'); }
};
const { Tooltip } = ctx.libs.antd;
const React = ctx.libs.React;
// ─── CopyBtn ───────────────────────────────────────────────────────────────
// Всегда height: 20px — одинаковая высота во всех столбцах.
// Позиционирование (absolute / inline) решается снаружи через обёртку.
const CopyBtn = ({ visible }) => {
const [hovered, setHovered] = React.useState(false);
const isRight = copyButtonPosition === 'right';
return React.createElement('div', {
style: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
height: isRight ? '100%' : '20px', // right — на всю высоту ячейки, inline — фиксированные 20px
flexShrink: 0,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
verticalAlign: 'middle',
borderRadius: isRight ? '0 8px 8px 0' : '4px',
// right-режим: только левая граница — остальные обрезаются overflow:hidden ячейки
border: isRight ? 'none' : '1px solid #1677ff',
borderLeft: '1px solid #1677ff',
padding: hovered ? '2px 8px' : '2px 4px',
backgroundColor: hovered ? '#1677ff' : '#f0f5ff',
color: hovered ? '#fff' : '#1677ff',
fontSize: '11px',
transition: 'all 0.15s ease-in-out',
opacity: visible ? 1 : 0,
visibility: visible ? 'visible' : 'hidden',
},
onMouseEnter: () => setHovered(true),
onMouseLeave: () => setHovered(false),
onClick: (e) => {
e.stopPropagation();
navigator.clipboard.writeText(text);
ctx.message.success('Скопировано');
}
}, [
React.createElement('span', {
key: 'icon',
dangerouslySetInnerHTML: {
__html: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'
}
}),
hovered && React.createElement('span', { key: 'label', style: { marginLeft: '4px' } }, 'Copy')
]);
};
// ─── MainContent ───────────────────────────────────────────────────────────
const MainContent = () => {
const [showBtn, setShowBtn] = React.useState(false);
const [isEdit, setIsEdit] = React.useState(false);
const [isHovered, setIsHovered] = React.useState(false);
const [tempVal, setTempVal] = React.useState(text);
const [isTextOverflowing, setIsTextOverflowing] = React.useState(false);
const textRef = React.useRef(null);
// Проверяем переполнение при первом рендере и при изменении текста
React.useEffect(() => {
const checkOverflow = () => {
if (!textRef.current) return;
try {
const element = textRef.current;
const isOverflowing = element.scrollWidth > element.clientWidth ||
element.scrollHeight > element.clientHeight;
setIsTextOverflowing(isOverflowing);
} catch (e) {
setIsTextOverflowing(false);
}
};
// Проверяем с небольшой задержкой, чтобы DOM обновился
const timer = setTimeout(checkOverflow, 0);
return () => clearTimeout(timer);
}, [text, enableWordWrap]);
// Режим редактирования
if (isEdit) {
return React.createElement(enableWordWrap ? 'textarea' : 'input', {
autoFocus: true, value: tempVal,
style: {
width: '100%', height: '100%', minHeight: '28px',
padding: '4px 8px', margin: 0,
border: 'none', outline: 'none', backgroundColor: '#fff',
boxShadow: 'inset 0 0 0 2px #1677ff', borderRadius: '8px',
boxSizing: 'border-box', display: 'block',
resize: 'none', fontFamily: 'inherit', fontSize: 'inherit'
},
onChange: (e) => setTempVal(e.target.value),
onBlur: () => {
if (tempVal !== text) {
const fieldName = ctx.collectionField?.name;
utils.saveFieldValue({
ctx, recordId: ctx.record?.id,
fieldName, newValue: tempVal,
onSuccess: () => {
// Патчим ctx.record и ctx.value сразу, не ждём обновления таблицы.
// Это позволяет render() увидеть новое значение и пересчитать
// цвет заливки (enableFieldStatus) без перезагрузки страницы.
if (ctx.record && fieldName) ctx.record[fieldName] = tempVal;
if ('value' in ctx) ctx.value = tempVal;
render(); // перерисовываем ячейку с новыми данными
ctx.emit?.('tableBlock:refreshByUid', { uid: ctx.blockModel?.uid });
}
});
} else { setIsEdit(false); }
},
onKeyDown: (e) => {
if (!enableWordWrap && e.key === 'Enter') e.target.blur();
if (e.key === 'Escape') setIsEdit(false);
},
onClick: (e) => e.stopPropagation()
});
}
const isInline = copyButtonPosition === 'inline';
const isRight = copyButtonPosition === 'right';
// Текстовый/ссылочный узел
const textNode = isUrl
? React.createElement('a', {
href: utils.normalizeUrl(text), target: '_blank',
style: { color: '#1677ff', textDecoration: 'underline' },
onClick: (e) => e.stopPropagation()
}, urlDisplayMode === 'label' ? 'Ссылка на сайт' : text)
: React.createElement('span', {
style: {
color: shouldHighlightRed ? '#d32f2f' : shouldHighlightGreen ? '#7BD960' : (isTargetField && hasValue && isHovered && !isUrl) ? '#1677ff' : 'inherit',
textDecoration: (isTargetField && hasValue && isHovered && !isUrl) ? 'underline' : 'none',
fontWeight: (shouldHighlightRed || shouldHighlightGreen) ? '600' : 'inherit',
}
}, text || '\u00A0');
// ── Сборка содержимого ячейки по режиму ──────────────────────────────
//
// RIGHT (оба варианта wordWrap):
// Текст занимает всю ширину. Кнопка — абсолютная, на всю высоту ячейки справа.
//
// INLINE + wordWrap:
// Текст переносится. Кнопка вставляется inline-flex сразу после текста.
//
// INLINE + !wordWrap:
// Текст в одну строку, обрезается ellipsis. Кнопка — flex-сосед справа.
let cellChildren;
if (isRight) {
// Кнопка: position absolute, top/bottom=0 → на всю высоту ячейки, прижата к правому краю
cellChildren = [
React.createElement('div', {
key: 'text',
ref: textRef,
style: {
flex: 1, minWidth: 0,
overflow: 'hidden',
whiteSpace: enableWordWrap ? 'pre-wrap' : 'nowrap',
wordBreak: enableWordWrap ? 'break-word' : 'normal',
textOverflow: enableWordWrap ? 'clip' : 'ellipsis',
}
}, textNode),
hasValue && React.createElement('div', {
key: 'btn-wrap',
style: {
position: 'absolute',
right: 0,
top: 0,
bottom: 0, // ← растягивается на всю высоту ячейки
display: 'flex',
alignItems: 'stretch',
}
}, React.createElement(CopyBtn, { visible: showBtn }))
];
} else if (enableWordWrap) {
// INLINE + wordWrap: кнопка встроена в текстовый поток
cellChildren = [
React.createElement('div', {
key: 'text',
ref: textRef,
style: {
flex: 1, minWidth: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}
}, [
textNode,
// Кнопка сразу после текста, внутри строки
hasValue && React.createElement('span', {
key: 'btn-wrap',
style: { display: 'inline-flex', verticalAlign: 'middle', marginLeft: '6px' }
}, React.createElement(CopyBtn, { visible: showBtn }))
])
];
} else {
// INLINE + !wordWrap: текст обрезается, кнопка — flex-сосед
cellChildren = [
React.createElement('div', {
key: 'text',
ref: textRef,
style: {
flex: 1, minWidth: 0,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}
}, textNode),
hasValue && React.createElement('div', {
key: 'btn-wrap',
style: { flexShrink: 0, marginLeft: '6px', display: 'flex', alignItems: 'center' }
}, React.createElement(CopyBtn, { visible: showBtn }))
];
}
const cellBody = React.createElement('div', {
style: {
width: '100%',
padding: '4px 8px',
minWidth: 0,
boxSizing: 'border-box',
position: 'relative', // нужно для absolute-кнопки в right-режиме
display: 'flex',
flexDirection: 'row',
alignItems: enableWordWrap ? 'flex-start' : 'center',
minHeight: '28px',
cursor: (isTargetField && hasValue) ? 'pointer' : 'default',
},
onMouseEnter: () => { setShowBtn(true); setIsHovered(true); },
onMouseLeave: () => { setShowBtn(false); setIsHovered(false); },
onDoubleClick: (e) => {
if (!isTargetField && enableInlineEdit) {
e.stopPropagation();
setIsEdit(true);
}
},
onClick: () => { if (isTargetField && hasValue && !isUrl) openRecordView(); }
}, cellChildren);
// ИЗМЕНЕННАЯ ЛОГИКА: показываем тултип ТОЛЬКО если текст обрезан
const tooltipProps = {
title: text,
placement: 'top',
overlayStyle: { maxWidth: tooltipMaxWidth },
};
if (tooltipBgColor) {
tooltipProps.color = tooltipBgColor;
}
if (tooltipTextColor) {
tooltipProps.overlayInnerStyle = { color: tooltipTextColor };
}
return (enableTooltip && hasValue && isTextOverflowing)
? React.createElement(Tooltip, tooltipProps, cellBody)
: cellBody;
};
const root = document.createElement('div');
root.style.width = '100%';
root.style.height = '100%';
ctx.render(React.createElement(MainContent), root);
ctx.element.appendChild(root);
// Обновляем кэш после успешного рендера
cache.lastFieldValue = ctx.record?.[fieldName];
if (enableComparison || enableFieldStatus) {
const { Current, Requests } = utils.resolveComparisonFields(ctx);
cache.lastComparisonState = JSON.stringify({ Current, Requests });
if (enableFieldStatus) {
cache.lastStatusBg = utils.resolveFieldStatusBackground(ctx);
}
}
};
render();
if (ctx.record && !ctx.element.__observeSet) {
ctx.element.__observeSet = true;
const fieldName = ctx.collectionField?.name;
// Вместо { deep: true } наблюдаем только за конкретными полями
// Это существенно снижает количество срабатываний наблюдателя
ctx.observe(() => {
// Собираем только нужные значения для наблюдения
return {
fieldValue: ctx.record?.[fieldName],
// Если есть сравнение/статус - следим за релевантными полями
...(enableComparison || enableFieldStatus ? {
codeIUS: ctx.record?.Code_IUS,
codeES: ctx.record?.Code_ES,
requestIUS: ctx.record?.Request_IUS,
requestES: ctx.record?.Request_ES,
compareField: fieldName?.endsWith('_current')
? ctx.record?.[fieldName.replace(/_current$/, '_requests')]
: fieldName?.endsWith('_requests')
? ctx.record?.[fieldName.replace(/_requests$/, '_current')]
: null
} : {})
};
}, render, { deep: false });
}