dataTable/src/index.js

747 lines
37 KiB
JavaScript
Raw Normal View History

2026-05-17 17:03:21 +08:00
import { Component, NewState, Util, RefreshState } from '@web/state'
import { State } from '@web/base'
import { createPerfMonitor } from './perf.js'
import { createScrollManager } from './scroll.js'
import { createSelectionManager } from './selection.js'
2026-05-17 17:03:21 +08:00
// Static configuration maps
const MODE_MAP = {
text: ['contains', 'equals', 'starts', 'ends'],
textarea: ['contains', 'equals', 'starts', 'ends'],
number: ['=', '>', '<', 'between'],
date: ['=', '>', '<', 'between'],
select: ['contains', 'equals'],
TagsInput: ['contains', 'equals', 'starts', 'ends']
};
const MODE_ICONS = {
'contains': 'bi-search', 'equals': 'bi-distribute-vertical', 'starts': 'bi-align-start', 'ends': 'bi-align-end',
'=': 'bi-calculator', '>': 'bi-chevron-right', '<': 'bi-chevron-left', 'between': 'bi-arrows-expand'
};
export const DataTable = {
_fieldTypes: new Map(),
registerFieldType: (config) => {
DataTable._fieldTypes.set(config.value, config);
},
getFieldTypes: () => Array.from(DataTable._fieldTypes.values())
};
// Register Built-in Types
DataTable.registerFieldType({
value: 'text', label: '{#Text#}', typeForDB: 'v4096',
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="text"' }]
});
DataTable.registerFieldType({
value: 'number', label: '{#Number#}', typeForDB: 'ff',
schema: [
{ name: 'decimals', label: 'Decimals', type: 'number', setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' },
{ name: 'prefix', label: 'Prefix (e.g. $)', type: 'text', if: 'this.data.user_type=="number"' },
{ name: 'suffix', label: 'Suffix (e.g. %)', type: 'text', if: 'this.data.user_type=="number"' },
{ name: 'thousandSep', label: 'Thousand Sep', type: 'switch', if: 'this.data.user_type=="number"' }
],
formatter: (val, field) => {
if (val == null || val === '') return '';
let num = Number(val);
if (isNaN(num)) return val;
const s = field.settings || {};
if (s.decimals !== undefined) num = num.toFixed(s.decimals);
let str = String(num);
if (s.thousandSep) {
const parts = str.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
str = parts.join('.');
}
return (s.prefix || '') + str + (s.suffix || '');
}
});
DataTable.registerFieldType({
value: 'select', label: '{#Single Select#}', typeForDB: 'v1024',
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="select"' }],
formatter: (val, field) => {
if (val == null || val === '') return '';
const opts = field.settings?.options || [];
const opt = opts.find(o => typeof o === 'object' ? o.value == val : o == val);
return opt ? (typeof opt === 'object' ? opt.label : opt) : val;
}
});
DataTable.registerFieldType({
value: 'checkbox', label: '{#Multi Select#}', typeForDB: 'v4096',
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="checkbox"' }],
formatter: (val, field) => {
if (!Array.isArray(val)) return val == null ? '' : String(val);
const opts = field.settings?.options || [];
return val.map(v => {
const opt = opts.find(o => typeof o === 'object' ? o.value == v : o == v);
return opt ? (typeof opt === 'object' ? opt.label : opt) : v;
}).join(', ');
}
});
DataTable.registerFieldType({
value: 'switch', label: '{#Switch#}', typeForDB: 'b',
schema: [
{ name: 'labelOn', label: 'Label On', type: 'text', if: 'this.data.user_type=="switch"' },
{ name: 'labelOff', label: 'Label Off', type: 'text', if: 'this.data.user_type=="switch"' }
],
formatter: (val, field) => {
const s = field.settings || {};
return val ? (s.labelOn || 'Yes') : (s.labelOff || 'No');
}
});
DataTable.registerFieldType({
value: 'datetime', label: '{#DateTime#}', typeForDB: 'dt',
schema: [{ name: 'format', label: 'Format', type: 'text', setting: { placeholder: 'YYYY-MM-DD' }, if: 'this.data.user_type=="datetime"' }]
});
DataTable.registerFieldType({
value: 'textarea', label: '{#Long Text#}', typeForDB: 't',
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="textarea"' }]
});
2026-05-17 17:03:21 +08:00
Component.register('DataTable', container => {
if (!container.state) container.state = NewState({})
2026-05-17 17:03:21 +08:00
const state = container.state
Object.assign(state, {
list: [], fields: [], _renderedList: [],
2026-05-17 17:03:21 +08:00
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
selectedRowCount: 0,
_originalList: [],
sortConfig: { fieldId: null, direction: null },
filterConfig: {}, // fieldId -> { mode, value, value2, selectedValues: [] }
activeFieldId: null,
activeField: null,
activeModes: [],
_columnStats: {},
_internalUpdate: false,
_appliedHash: '',
_fieldsDirty: false,
_masterCellNodes: null,
isDirty: false,
isBulkEdit: null
2026-05-17 17:03:21 +08:00
})
const perf = createPerfMonitor();
state.perf = perf.stats;
const selection = createSelectionManager(container, state);
const scroll = createScrollManager(container, state, () => selection.applySelectionUI());
const menuNode = container.querySelector('.dt-column-menu');
if (menuNode) menuNode._thisObj = container;
container.onColumnResizing = (field, e) => container.style.setProperty(`--w-${field.id}`, e.detail.newSize + 'px');
container.onColumnResize = (field, e) => {
const idx = state.fields.findIndex(f => f.id === field.id);
if (idx !== -1) { state.fields[idx].width = e.detail.newSize; state.fields = [...state.fields]; }
};
let _editorOverlay, currentEditingNode = null;
container.format = (val, field) => {
if (field.formatter) return field.formatter(val, field);
const typeInfo = DataTable._fieldTypes.get(field.settings?.formType || field.type || 'text');
if (typeInfo && typeInfo.formatter) return typeInfo.formatter(val, field);
return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val) : String(val));
};
2026-05-17 17:03:21 +08:00
container.onScroll = () => {
perf.onScroll(); scroll.refresh();
container.hideColumnMenu();
const prev = container.querySelector('.dt-spacer-prev'), post = container.querySelector('.dt-spacer-post');
if (prev) { prev.style.height = (state.prevHeight || 0) + 'px'; prev.style.display = state.prevHeight > 0 ? 'block' : 'none'; }
if (post) { post.style.height = (state.postHeight || 0) + 'px'; post.style.display = state.postHeight > 0 ? 'block' : 'none'; }
};
container.applySortFilter = (options = {}) => {
if (state._internalUpdate) return;
const targetFilters = { ...state.filterConfig, ...(options.filters || {}) };
const targetSort = options.sort !== undefined ? (options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null }) : state.sortConfig;
const currentHash = JSON.stringify({ s: targetSort, f: targetFilters });
if (state._appliedHash === currentHash && !options.force) return;
state._internalUpdate = true;
let list = [...(state._originalList || [])];
Object.keys(targetFilters).forEach(fieldId => {
const filter = targetFilters[fieldId];
if (!filter) return;
const { mode = 'contains', value, value2, selectedValues } = filter;
if (selectedValues?.length > 0) { list = list.filter(item => selectedValues.includes(String(item[fieldId] ?? ''))); return; }
if (value === '' || value == null) return;
const lowV = String(value).toLowerCase(), n1 = Number(value), n2 = Number(value2);
list = list.filter(item => {
const iv = item[fieldId], sv = String(iv ?? '').toLowerCase();
switch (mode) {
case 'contains': return sv.includes(lowV);
case 'equals': return sv === lowV;
case 'starts': return sv.startsWith(lowV);
case 'ends': return sv.endsWith(lowV);
case '>': return Number(iv) > n1;
case '<': return Number(iv) < n1;
case '=': return Number(iv) === n1;
case 'between': return Number(iv) >= n1 && Number(iv) <= n2;
default: return sv.includes(lowV);
}
});
});
if (targetSort.fieldId && targetSort.direction) {
list.sort((a, b) => {
let va = a[targetSort.fieldId], vb = b[targetSort.fieldId];
if (va === vb) return 0;
const res = va > vb ? 1 : -1;
return targetSort.direction === 'asc' ? res : -res;
});
}
state._appliedHash = currentHash; state.sortConfig = targetSort; state.list = list; state._internalUpdate = false;
};
container.showColumnMenu = (field, e) => {
e.stopPropagation();
const btn = e.currentTarget, menu = container.querySelector('.dt-column-menu');
const type = field.type || 'text';
state.activeModes = MODE_MAP[type] || (['boolean', 'switch', 'checkbox', 'radio'].includes(type) ? [] : MODE_MAP.text);
if (!state.filterConfig[field.id]) {
state.filterConfig[field.id] = { mode: state.activeModes[0] || 'contains', value: '', selectedValues: [] };
}
state.activeField = field; state.activeFieldId = field.id;
menu.style.display = 'block';
const cellNode = btn.closest('.dt-cell');
const rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
const menuWidth = menu.offsetWidth || 260;
let leftPos = rect.right - rootRect.left - menuWidth;
if (leftPos < 0) leftPos = Math.max(0, rect.left - rootRect.left);
menu.style.left = leftPos + 'px';
menu.style.top = (rect.bottom - rootRect.top + 5) + 'px';
const onGlobalClick = (ev) => {
if (menu.contains(ev.target) || btn.contains(ev.target)) return;
container.hideColumnMenu(); container.applySortFilter();
document.removeEventListener('mousedown', onGlobalClick);
};
document.addEventListener('mousedown', onGlobalClick);
setTimeout(() => menu.querySelector('input')?.focus(), 50);
};
container.toggleSelectedValue = (val) => {
const filter = state.filterConfig[state.activeFieldId];
if (!filter) return;
const idx = filter.selectedValues.indexOf(val);
if (idx === -1) filter.selectedValues.push(val); else filter.selectedValues.splice(idx, 1);
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
};
container.filterOnlyThis = (val) => {
state.filterConfig[state.activeFieldId] = { mode: 'contains', value: '', selectedValues: [String(val)] };
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
};
container.hideColumnMenu = () => {
const menu = container.querySelector('.dt-column-menu');
if (menu) menu.style.display = 'none';
};
container.setSort = (dir) => {
const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir;
container.applySortFilter({ sort: newDir });
};
container.clearColumnSettings = () => {
if (state.activeFieldId) {
delete state.filterConfig[state.activeFieldId];
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
}
};
container._initRow = (rowNode) => {
const row = rowNode._ref?.item;
if (row && row._editingF === undefined) {
Object.defineProperty(row, '_editingF', { set: (v) => { if (v === null) container.hideEditor(true); }, configurable: true });
}
Array.from(rowNode.children).forEach(cell => {
const fIdx = parseInt(cell.dataset.fidx);
if (!isNaN(fIdx)) cell._refExt = { f: state.fields[fIdx], fIdx: fIdx };
});
};
state.__watch('fields', fields => {
if (!fields) return;
state._fieldsDirty = true;
state._masterCellNodes = null; // Force template rebuild
container.style.setProperty('--dt-grid-template', fields.map(f => `var(--w-${f.id}, ${(f.settings?.width || f.width) || 150}px)`).join(' '));
container.style.setProperty('--dt-row-width', fields.reduce((sum, f) => sum + ((f.settings?.width || f.width) || 150), 0) + 'px');
let leftSum = 0;
fields.forEach(f => {
const pinned = f.settings?.pinned || f.pinned;
if (pinned === 'left') {
container.style.setProperty(`--l-${f.id}`, leftSum + 'px');
leftSum += ((f.settings?.width || f.width) || 150);
}
});
let rightSum = 0;
[...fields].reverse().forEach(f => {
const pinned = f.settings?.pinned || f.pinned;
if (pinned === 'right') {
container.style.setProperty(`--r-${f.id}`, rightSum + 'px');
rightSum += ((f.settings?.width || f.width) || 150);
}
});
});
2026-05-17 17:03:21 +08:00
state.__watch('list', list => {
if (state._fieldsDirty) {
state._fieldsDirty = false;
const fieldTemplate = container.querySelector('.dt-body template[index="rIdx"]')?.content.querySelector('template[as="f"]');
if (fieldTemplate) {
const masters = state._masterCellNodes || (state._masterCellNodes = Array.from(fieldTemplate.content.childNodes).map(n => n.cloneNode(true)));
fieldTemplate.removeAttribute('$each'); fieldTemplate.setAttribute('$if', 'true'); fieldTemplate.content.textContent = '';
state.fields.forEach((f, fIdx) => masters.forEach(master => {
const clone = master.cloneNode(true);
if (clone.nodeType === 1) {
clone.dataset.fidx = fIdx;
const pinned = f.settings?.pinned || f.pinned;
if (pinned) {
clone.classList.add('pinned-' + pinned);
clone.style.position = 'sticky';
clone.style.zIndex = '1';
clone.style.backgroundColor = 'inherit';
if (pinned === 'left') {
clone.style.left = `var(--l-${f.id})`;
clone.style.borderRight = '1px solid var(--bs-border-color)';
clone.style.boxShadow = '2px 0 5px -2px rgba(0,0,0,0.1)';
} else {
clone.style.right = `var(--r-${f.id})`;
clone.style.borderLeft = '1px solid var(--bs-border-color)';
clone.style.boxShadow = '-2px 0 5px -2px rgba(0,0,0,0.1)';
}
}
}
fieldTemplate.content.appendChild(clone);
}));
}
}
if (!state._internalUpdate) {
state._originalList = [...(list || [])];
setTimeout(() => {
const stats = {};
state.fields.forEach(f => {
const counts = {};
state._originalList.forEach(item => { const val = item[f.id], key = (val == null || val === '') ? '' : String(val); counts[key] = (counts[key] || 0) + 1; });
stats[f.id] = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([val, count]) => ({ val, count }));
});
state._columnStats = stats;
}, 200);
}
scroll.init(); scroll.reset(list);
});
2026-05-17 17:03:21 +08:00
container.editCell = (row, field, cellNode) => {
const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
currentEditingNode = cellNode;
const formType = field.settings?.formType || field.type || 'text';
const form = overlay.querySelector('AutoForm');
if (form) {
// row 已经是 Proxy (NewState),直接赋值即可实现双向同步
form.data = row;
form.state.schema = [{ ...field, type: formType, options: field.settings?.options || field.options, name: field.id, label: '' }];
RefreshState(form);
}
Object.assign(overlay.style, {
display: 'flex', left: (rect.left - rootRect.left) + 'px', top: (rect.top - rootRect.top) + 'px',
width: (formType === 'textarea' || formType === 'TagsInput' ? Math.max(rect.width, 300) : rect.width) + 'px',
height: (formType === 'textarea' || formType === 'TagsInput' ? 'auto' : rect.height + 'px')
});
setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30);
};
container.hideEditor = (save = true) => {
if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay');
if (!_editorOverlay || _editorOverlay.style.display === 'none') return;
const form = _editorOverlay.querySelector('AutoForm');
if (save && form && form.data) {
const input = _editorOverlay.querySelector('input:focus, select:focus, textarea:focus');
if (input) input.dispatchEvent(new Event(input.type === 'number' || input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }));
RefreshState(form);
// 手动同步数据回原始行 (解决 Proxy 隔离问题)
const schema = form.state.schema || [];
schema.forEach(field => {
const row = currentEditingNode?.closest('.dt-row')?._ref?.item;
if (row) row[field.name] = form.data[field.name];
});
if (state.isBulkEdit) {
const { minRow, maxRow, fIdx } = state.isBulkEdit;
const field = state.fields[fIdx];
const newValue = form.data[field.id];
for (let i = minRow; i <= maxRow; i++) {
if (state.list[i]) {
state.list[i][field.id] = newValue;
}
}
state.list = [...state.list];
}
if (currentEditingNode) RefreshState(currentEditingNode);
state.isDirty = true;
}
_editorOverlay.style.display = 'none';
if (form) { form.state.schema = []; form.data = null; }
currentEditingNode = null;
state.isBulkEdit = null;
container.focus();
};
container.onMainMouseDown = e => {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (!row || row.classList.contains('dt-header-row')) return;
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
selection.startSelect(rIdx + state._listStartIndex, fIdx, e);
};
container.onMainMouseOver = e => {
if (state.isSelecting) {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (row && !row.classList.contains('dt-header-row')) {
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
selection.updateSelect(rIdx + state._listStartIndex, fIdx);
}
}
};
container.onMainDblClick = e => {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (row && !row.classList.contains('dt-header-row')) {
const item = row._ref?.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
const absoluteRow = rIdx + state._listStartIndex;
if (item && state.fields[fIdx]) {
const bounds = selection.getSelectionBounds();
if (bounds && absoluteRow >= bounds.minRow && absoluteRow <= bounds.maxRow && fIdx >= bounds.minCol && fIdx <= bounds.maxCol) {
const affectedRows = bounds.maxRow - bounds.minRow + 1;
if (affectedRows > 1) {
state.isBulkEdit = { ...bounds, fIdx };
if (globalThis.UI?.toast) UI.toast(`Bulk Edit: Updating ${affectedRows} rows in column "${state.fields[fIdx].name}"`, { type: 'warning' });
}
}
container.editCell(item, state.fields[fIdx], cell);
}
}
};
container.addRow = () => {
const newRow = {};
state.fields.forEach(f => newRow[f.id] = '');
state._originalList.push(newRow);
state.list = [...state._originalList]; // Apply to current view too
state.isDirty = true;
setTimeout(() => {
scroll.reset(state.list);
container.querySelector('.dt-main').scrollTop = container.querySelector('.dt-main').scrollHeight;
}, 50);
};
container.deleteSelectedRow = async () => {
const bounds = selection.getSelectionBounds();
if (!bounds) return;
const count = bounds.maxRow - bounds.minRow + 1;
if (await UI.confirm(`Are you sure you want to delete ${count} row(s)?`)) {
const rMin = bounds.minRow, rMax = bounds.maxRow;
const removedItems = state.list.slice(rMin, rMax + 1);
state.list = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
state._originalList = state._originalList.filter(item => !removedItems.includes(item));
state.isDirty = true;
selection.clearAllActive();
container.dispatchEvent(new CustomEvent('remove', { detail: { items: removedItems } }));
}
};
container.saveChanges = () => {
container.dispatchEvent(new CustomEvent('save', { detail: { list: state._originalList, fields: state.fields } }));
state.isDirty = false;
};
const getFieldSchema = () => {
const types = DataTable.getFieldTypes();
const baseSchema = [
{ name: 'id', label: 'Field ID', type: 'text', setting: { required: true, placeholder: 'e.g. user_name' } },
{ name: 'name', label: 'Display Name', type: 'text', setting: { required: true, placeholder: 'e.g. 用户名' } },
{ name: 'user_type', label: 'Field Type', type: 'select', options: types.map(t => ({ label: t.label, value: t.value })) }
];
const dynamicSchema = types.reduce((acc, t) => acc.concat(t.schema || []), []);
return baseSchema.concat(dynamicSchema, [
{ name: 'isIndex', label: 'Index', type: 'switch' },
{ name: 'memo', label: 'Memo', type: 'text' }
]);
};
const parseOptionsStr = (str) => {
if (!str) return undefined;
return str.split('\n').map(s => s.trim()).filter(Boolean).map(line => {
const idx = line.indexOf(':');
if (idx > -1) return { label: line.slice(0, idx).trim(), value: line.slice(idx + 1).trim() };
return line;
});
};
const formatOptionsStr = (opts) => {
if (!opts) return '';
return opts.map(o => typeof o === 'object' ? `${o.label}:${o.value}` : o).join('\n');
};
container.addField = async () => {
container.hideColumnMenu();
const data = NewState({ id: 'c' + Date.now().toString().slice(-4), name: 'New Field', user_type: 'text', decimals: 0, isIndex: false, memo: '', options_str: '' });
const d = document.body.appendChild(document.createElement('Dialog'));
await new Promise(r => setTimeout(r, 0));
Object.assign(d.state, { title: 'Add Field', buttons: ['Cancel', 'Save'] });
RefreshState(d);
const body = d.querySelector('.modal-body');
const form = body.appendChild(document.createElement('AutoForm'));
form.setAttribute('nobutton', '');
RefreshState(form);
form.data = data;
form.state.schema = getFieldSchema();
form.addEventListener('change', (e) => e.stopPropagation());
d.show();
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result)));
if (result === 2) {
const typeInfo = DataTable._fieldTypes.get(data.user_type);
let dbType = typeInfo?.typeForDB || 'v1024';
if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const field = {
id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex,
type: dbType,
settings: {
formType: data.user_type,
decimals: data.decimals,
prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep,
labelOn: data.labelOn, labelOff: data.labelOff, format: data.format,
placeholder: data.placeholder,
options: parseOptionsStr(data.options_str)
}
};
state.fields = [...state.fields, field];
state.isDirty = true;
container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields }));
state.list = [...state.list];
}
d.remove();
};
container.editField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
const f = state.activeField;
const s = f.settings || {};
const data = NewState({
id: f.id, name: f.name, memo: f.memo || '', isIndex: !!f.isIndex,
user_type: s.formType || 'text',
decimals: s.decimals || 0,
prefix: s.prefix || '', suffix: s.suffix || '', thousandSep: !!s.thousandSep,
labelOn: s.labelOn || '', labelOff: s.labelOff || '', format: s.format || '',
placeholder: s.placeholder || '',
options_str: formatOptionsStr(s.options)
});
const d = document.body.appendChild(document.createElement('Dialog'));
await new Promise(r => setTimeout(r, 0));
Object.assign(d.state, { title: 'Edit Field', buttons: ['Cancel', 'Save'] });
RefreshState(d);
const body = d.querySelector('.modal-body');
const form = body.appendChild(document.createElement('AutoForm'));
form.setAttribute('nobutton', '');
RefreshState(form);
form.data = data;
form.state.schema = getFieldSchema();
form.addEventListener('change', (e) => e.stopPropagation());
d.show();
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result)));
if (result === 2) {
const idx = state.fields.findIndex(item => item.id === f.id);
if (idx !== -1) {
const typeInfo = DataTable._fieldTypes.get(data.user_type);
let dbType = typeInfo?.typeForDB || 'v1024';
if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const updatedField = {
...f, id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex,
type: dbType,
settings: {
...f.settings,
formType: data.user_type,
decimals: data.decimals,
prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep,
labelOn: data.labelOn, labelOff: data.labelOff, format: data.format,
placeholder: data.placeholder,
options: parseOptionsStr(data.options_str)
}
};
state.fields[idx] = updatedField;
state.fields = [...state.fields];
state.isDirty = true;
container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields }));
state.list = [...state.list];
}
}
d.remove();
};
container.deleteField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
if (await UI.confirm(`Are you sure you want to delete field "${state.activeField.name}"?`)) {
const idx = state.fields.findIndex(f => f.id === state.activeField.id);
if (idx !== -1) {
state.fields.splice(idx, 1);
state.fields = [...state.fields];
state.isDirty = true;
container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields }));
state.list = [...state.list]; // Trigger body refresh
}
}
};
window.addEventListener('mouseup', selection.endSelect);
document.addEventListener('mousedown', e => {
const overlay = container.querySelector('.dt-editor-overlay');
const menu = container.querySelector('.dt-column-menu');
if (overlay?.style.display !== 'none' && !overlay.contains(e.target)) container.hideEditor(true);
if (!container.contains(e.target) && !overlay?.contains(e.target) && !menu?.contains(e.target)) selection.clearAllActive();
});
// Exposure for templates
state._MODE_ICONS = MODE_ICONS;
2026-05-17 17:03:21 +08:00
// NOTE: For $class and $style directives, ALWAYS use the template literal syntax:
// $class="base-class \${condition ? 'active' : ''}"
// DO NOT use string concatenation like $class="'base-class ' + (condition ? 'active' : '')".
// Since the HTML is wrapped in backticks (``), remember to escape the dollar sign: \${ }
2026-05-17 17:03:21 +08:00
}, Util.makeDom(/*html*/`
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" tabindex="0">
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
$onmousedown="this.onMainMouseDown(event)" $onmouseover="this.onMainMouseOver(event)" $ondblclick="this.onMainDblClick(event)"
style="overflow-anchor:none; min-height: 0">
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
<div class="dt-header-row fw-bold text-muted small">
<template $each="this.state?.fields || []">
<div $data-id="item.id" $class="dt-cell dt-col border-end d-flex align-items-center header-cell \${(item.settings?.pinned || item.pinned) ? 'pinned-' + (item.settings?.pinned || item.pinned) : ''}" $style="((item.settings?.pinned || item.pinned) ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + ((item.settings?.pinned || item.pinned) === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((item.settings?.pinned || item.pinned) === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))">
<div class="d-flex align-items-center overflow-hidden flex-grow-1 h-100 px-2 cursor-pointer" $onclick="this.showColumnMenu(item, event)">
<i $if="this.state?.filterConfig?.[item.id] && (this.state.filterConfig[item.id].value || this.state.filterConfig[item.id].selectedValues?.length)" class="bi bi-filter me-1 text-primary"></i>
<i $if="this.state?.sortConfig?.fieldId === item.id && this.state.sortConfig.direction" $class="bi bi-sort-\${this.state.sortConfig.direction === 'asc' ? 'down' : 'up-alt'} me-1 text-primary"></i>
<span $text="item.name" class="text-truncate flex-grow-1"></span>
</div>
<button class="btn btn-xs btn-link text-muted p-0 border-0 me-1 header-menu-btn" $onclick="this.showColumnMenu(item, event)"><i class="bi bi-chevron-down"></i></button>
<Resizer $.target="thisNode.parentElement" style="position:absolute; right:0; top:0; bottom:0; width:4px; z-index:10" min="50" max="1000" $onresizing="this.onColumnResizing(item, event)" $onresize="this.onColumnResize(item, event)"></Resizer>
</div>
</template>
</div>
2026-05-17 17:03:21 +08:00
</div>
<div class="dt-body" style="position:relative">
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
<template as="f"><div $class="dt-cell border-end px-2 d-flex align-items-center \${(f.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style="((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || f.pinned) === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((f.settings?.pinned || f.pinned) === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[f.id], f)" class="text-truncate"></span></div></template>
</div>
</template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div>
2026-05-17 17:03:21 +08:00
</div>
<div class="dt-column-menu bg-body shadow-lg rounded p-2" style="display:none; position:absolute; z-index:2000; min-width:240px; max-width:300px; border: 1px solid var(--bs-primary)">
<template $if="this.state?.activeFieldId">
<div class="d-flex gap-1 mb-2">
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'asc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('asc')"><i class="bi bi-sort-alpha-down me-1"></i> ASC</button>
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'desc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('desc')"><i class="bi bi-sort-alpha-up-alt me-1"></i> DESC</button>
</div>
<div $if="this.state?.activeModes?.length" class="dt-filter-tabs d-flex overflow-auto border-bottom bg-light-subtle rounded-top py-1" style="white-space:nowrap; scrollbar-width: none;">
<template $each="this.state?.activeModes || []" as="m">
<div $class="px-2 py-1 cursor-pointer fs-5 \${this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === m ? 'text-primary border-bottom border-primary border-2' : 'text-muted'}" $title="m.toUpperCase()" $onclick="this.state.filterConfig[this.state.activeFieldId].mode = m; this.state.filterConfig = {...this.state.filterConfig}">
<i $class="bi \${this.state?._MODE_ICONS?.[m] || 'bi-filter'}"></i>
</div>
</template>
</div>
<template $if="this.state?.activeModes?.length">
<div class="py-2 border-bottom" style="min-height: 48px">
<input type="text" class="form-control form-control-sm mb-1" $placeholder="(this.state?.filterConfig?.[this.state?.activeFieldId]?.mode || 'Search').toUpperCase() + '...'" $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
<input $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === 'between'" type="text" class="form-control form-control-sm" placeholder="And..." $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value2" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
</div>
</template>
<div class="mt-2" style="max-height: 180px; overflow-y: auto;">
<div class="text-muted fw-bold mb-1" style="font-size: 9px; letter-spacing: 0.5px">TOP FREQUENT VALUES</div>
<template $each="this.state?._columnStats?.[this.state?.activeFieldId] || []">
<label class="d-flex align-items-center mb-1 small cursor-pointer p-1 rounded-1 menu-item-row" onmouseover="this.style.background='var(--bs-light)'" onmouseout="this.style.background='transparent'">
<input type="checkbox" class="form-check-input me-2" $checked="this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.includes(String(item.val))" $onclick="this.toggleSelectedValue(String(item.val))">
<span class="text-truncate flex-grow-1"><span $text="item.val || '(Empty)'"></span> <span class="text-muted ms-1" style="font-size: 0.7rem" $text="'(' + item.count + ')'"></span></span>
<button class="btn btn-xs btn-link p-0 text-primary only-btn" style="font-size: 10px; text-decoration: none" $onclick="this.filterOnlyThis(item.val); event.preventDefault(); event.stopPropagation();">Only</button>
</label>
</template>
</div>
<div $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.value || this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.length" class="mt-2 pt-1 border-top text-center">
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span>
</div>
<div class="mt-3 pt-2 border-top d-flex flex-column gap-1">
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.editField()"><i class="bi bi-pencil me-2"></i> Edit Field</button>
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.addField()"><i class="bi bi-plus-lg me-2"></i> Add Field</button>
<button class="btn btn-xs btn-outline-danger border d-flex align-items-center px-2 py-1" $onclick="this.deleteField()"><i class="bi bi-trash me-2"></i> Delete Field</button>
</div>
</template>
2026-05-17 17:03:21 +08:00
</div>
<div class="dt-editor-overlay dt-editor-container" style="display: none; position: absolute; z-index: 1000; background: var(--bs-body-bg); box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: 1px solid var(--bs-primary);"><AutoForm inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"/></div>
<div class="dt-footer border-top bg-light d-flex align-items-center px-3 py-1 shadow-sm" style="height:40px; z-index: 10">
<div class="d-flex align-items-center gap-3 flex-grow-1">
<div class="btn-group shadow-sm">
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.addRow()" title="Add Row"><i class="bi bi-plus-lg text-primary me-1"></i> Add</button>
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.deleteSelectedRow()" $disabled="!this.state?.selectedRowCount" title="Delete Selected Rows"><i class="bi bi-trash text-danger me-1"></i> Delete</button>
</div>
<div class="vr h-50 my-auto text-muted opacity-25"></div>
<div class="d-flex align-items-center gap-2 text-muted" style="font-size: 0.75rem">
<i class="bi bi-check-all fs-6"></i>
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button $if="this.state?.isDirty" class="btn btn-xs btn-primary px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save Changes</button>
<button $if="!this.state?.isDirty" class="btn btn-xs btn-light border px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Up to date</button>
</div>
</div>
2026-05-17 17:03:21 +08:00
</div>
`), Util.makeDom(/*html*/`
<style>
DataTable { display: block; }
.dt-root { font-size: 0.875rem; }
.dt-row, .dt-header-row { display: grid; grid-template-columns: var(--dt-grid-template); width: var(--dt-row-width, max-content); min-width: 100%; height: 40px; contain: paint layout; }
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); }
.dt-cell { background: inherit; white-space: nowrap; flex-shrink: 0; contain: content; }
.dt-cell-selected { background-color: rgba(var(--bs-primary-rgb), 0.15) !important; outline: 1px solid var(--bs-primary); outline-offset: -1px; }
.dt-body-row:hover { background-color: var(--bs-secondary-bg) !important; }
.header-cell .header-menu-btn { opacity: 0; transition: opacity 0.2s; }
.header-cell:hover .header-menu-btn { opacity: 1; }
.dt-column-menu { background-color: var(--bs-body-bg); border: 1px solid var(--bs-primary); box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important; z-index: 2100 !important; }
.btn-xs { padding: 1px 5px; line-height: 1.5; }
.cursor-pointer { cursor: pointer; }
.dt-filter-tabs i { font-size: 1.1rem; }
.dt-filter-tabs div:hover i { color: var(--bs-primary); }
.menu-item-row .only-btn { opacity: 0; }
.menu-item-row:hover .only-btn { opacity: 1; }
</style>
2026-05-17 17:03:21 +08:00
`))