feat: enhance DataTable with extensible registerFieldType API, rich formatters, and dynamic AutoForm schemas (by AI)

This commit is contained in:
AI Engineer 2026-05-28 17:07:51 +08:00
parent 82b7ebc799
commit 2a81326df3
3 changed files with 148 additions and 34 deletions

View File

@ -19,6 +19,84 @@ const MODE_ICONS = {
'=': 'bi-calculator', '>': 'bi-chevron-right', '<': 'bi-chevron-left', 'between': 'bi-arrows-expand' '=': '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"' }]
});
Component.register('DataTable', container => { Component.register('DataTable', container => {
if (!container.state) container.state = NewState({}) if (!container.state) container.state = NewState({})
const state = container.state const state = container.state
@ -60,6 +138,8 @@ Component.register('DataTable', container => {
container.format = (val, field) => { container.format = (val, field) => {
if (field.formatter) return field.formatter(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)); return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val) : String(val));
}; };
@ -385,25 +465,32 @@ Component.register('DataTable', container => {
state.isDirty = false; state.isDirty = false;
}; };
const FIELD_TYPES = [ const getFieldSchema = () => {
{ label: '{#Text#}', value: 'text', db: 'v4096' }, const types = DataTable.getFieldTypes();
{ label: '{#Number#}', value: 'number' }, const baseSchema = [
{ label: '{#Single Select#}', value: 'select', db: 'v255' },
{ label: '{#Checkbox#}', value: 'checkbox', db: 'v1024' },
{ label: '{#Tags#}', value: 'TagsInput', db: 't' },
{ label: '{#DateTime#}', value: 'datetime', db: 'dt' },
{ label: '{#Switch#}', value: 'switch', db: 'b' }
];
const getFieldSchema = () => [
{ name: 'id', label: 'Field ID', type: 'text', setting: { required: true, placeholder: 'e.g. user_name' } }, { 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: 'name', label: 'Display Name', type: 'text', setting: { required: true, placeholder: 'e.g. 用户名' } },
{ name: 'user_type', label: 'Field Type', type: 'select', options: FIELD_TYPES }, { name: 'user_type', label: 'Field Type', type: 'select', options: types.map(t => ({ label: t.label, value: t.value })) }
{ name: 'decimals', label: 'Decimals', type: 'number', setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' }, ];
{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'One option per line' }, if: '["select", "checkbox"].includes(this.data.user_type)' }, const dynamicSchema = types.reduce((acc, t) => acc.concat(t.schema || []), []);
return baseSchema.concat(dynamicSchema, [
{ name: 'isIndex', label: 'Index', type: 'switch' }, { name: 'isIndex', label: 'Index', type: 'switch' },
{ name: 'memo', label: 'Memo', type: 'text' } { 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.addField = async () => {
container.hideColumnMenu(); container.hideColumnMenu();
@ -426,8 +513,8 @@ Component.register('DataTable', container => {
d.show(); d.show();
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result))); const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result)));
if (result === 2) { if (result === 2) {
const typeInfo = FIELD_TYPES.find(t => t.value === data.user_type); const typeInfo = DataTable._fieldTypes.get(data.user_type);
let dbType = typeInfo?.db || 'v255'; let dbType = typeInfo?.typeForDB || 'v1024';
if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi'; if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const field = { const field = {
@ -436,7 +523,10 @@ Component.register('DataTable', container => {
settings: { settings: {
formType: data.user_type, formType: data.user_type,
decimals: data.decimals, decimals: data.decimals,
options: data.options_str ? data.options_str.split('\n').map(s => s.trim()).filter(Boolean) : undefined 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.fields = [...state.fields, field];
@ -451,11 +541,15 @@ Component.register('DataTable', container => {
if (!state.activeField) return; if (!state.activeField) return;
container.hideColumnMenu(); container.hideColumnMenu();
const f = state.activeField; const f = state.activeField;
const s = f.settings || {};
const data = NewState({ const data = NewState({
id: f.id, name: f.name, memo: f.memo || '', isIndex: !!f.isIndex, id: f.id, name: f.name, memo: f.memo || '', isIndex: !!f.isIndex,
user_type: f.settings?.formType || 'text', user_type: s.formType || 'text',
decimals: f.settings?.decimals || 0, decimals: s.decimals || 0,
options_str: f.settings?.options ? f.settings.options.join('\n') : '' 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')); const d = document.body.appendChild(document.createElement('Dialog'));
@ -477,8 +571,8 @@ Component.register('DataTable', container => {
if (result === 2) { if (result === 2) {
const idx = state.fields.findIndex(item => item.id === f.id); const idx = state.fields.findIndex(item => item.id === f.id);
if (idx !== -1) { if (idx !== -1) {
const typeInfo = FIELD_TYPES.find(t => t.value === data.user_type); const typeInfo = DataTable._fieldTypes.get(data.user_type);
let dbType = typeInfo?.db || 'v255'; let dbType = typeInfo?.typeForDB || 'v1024';
if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi'; if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const updatedField = { const updatedField = {
@ -488,7 +582,10 @@ Component.register('DataTable', container => {
...f.settings, ...f.settings,
formType: data.user_type, formType: data.user_type,
decimals: data.decimals, decimals: data.decimals,
options: data.options_str ? data.options_str.split('\n').map(s => s.trim()).filter(Boolean) : undefined 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[idx] = updatedField;

View File

@ -87,7 +87,17 @@ test('DataTable comprehensive tests and scrolling benchmarks', async ({ page })
}, colIdx); }, colIdx);
console.log(`Column [${field.id}] UI text:`, cellText); console.log(`Column [${field.id}] UI text:`, cellText);
if (field.type !== 'switch' && field.id !== 'active') { if (field.id === 'score') {
expect(cellText).toContain('$99.00'); // 99 + formatting
} else if (field.id === 'role') {
expect(cellText).toContain('Content Editor'); // Editor mapped to label
} else if (field.id === 'active') {
expect(cellText).toContain('NO'); // false mapped to labelOff
} else if (field.id === 'tags') {
expect(cellText).toContain('State.js | Playwright'); // Formatter logic
} else if (field.id === 'actions') {
expect(cellText).toContain('🔧 Edit'); // Custom formatter
} else {
expect(cellText).toContain(String(newValue).split('\n')[0]); expect(cellText).toContain(String(newValue).split('\n')[0]);
} }
} }

View File

@ -33,21 +33,28 @@
<script type="module"> <script type="module">
import { RefreshState } from '@web/state' import { RefreshState } from '@web/state'
import '@web/base' import '@web/base'
import '../src/index.js' import { DataTable } from '../src/index.js'
// Externally register TagsInput
DataTable.registerFieldType({
value: 'TagsInput', label: '{#Tags#}', typeForDB: 'v4096',
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="TagsInput"' }],
formatter: (val) => Array.isArray(val) ? val.join(' | ') : val
});
const table = document.getElementById('myTable') const table = document.getElementById('myTable')
const fields = [ const fields = [
{ id: 'id', name: 'ID', type: 'string', settings: { width: 60, formType: 'text', pinned: 'left' } }, { id: 'id', name: 'ID', type: 'string', settings: { width: 60, formType: 'text', pinned: 'left' } },
{ id: 'name', name: 'User Name', type: 'string', settings: { width: 150, formType: 'text', pinned: 'left' } }, { id: 'name', name: 'User Name', type: 'string', settings: { width: 150, formType: 'text', pinned: 'left' } },
{ id: 'role', name: 'Role', type: 'string', settings: { width: 120, formType: 'select', options: ['Admin', 'Editor', 'Viewer'] } }, { id: 'role', name: 'Role', type: 'string', settings: { width: 120, formType: 'select', options: [{label:'Administrator', value:'Admin'}, {label:'Content Editor', value:'Editor'}, {label:'Viewer', value:'Viewer'}] } },
{ id: 'active', name: 'Active', type: 'boolean', settings: { width: 80, formType: 'switch' } }, { id: 'active', name: 'Active', type: 'boolean', settings: { width: 80, formType: 'switch', labelOn: 'YES', labelOff: 'NO' } },
{ id: 'bio', name: 'Bio', type: 'string', settings: { width: 250, formType: 'textarea' } }, { id: 'bio', name: 'Bio', type: 'string', settings: { width: 250, formType: 'textarea' } },
{ id: 'gender', name: 'Gender', type: 'string', settings: { width: 120, formType: 'radio', options: ['Male', 'Female', 'Other'] } }, { id: 'gender', name: 'Gender', type: 'string', settings: { width: 120, formType: 'radio', options: ['Male', 'Female', 'Other'] } },
{ id: 'tags', name: 'Tags', type: 'object', settings: { width: 150, formType: 'TagsInput' } }, { id: 'tags', name: 'Tags', type: 'object', settings: { width: 150, formType: 'TagsInput' } },
{ id: 'score', name: 'Score', type: 'number', settings: { width: 100, formType: 'number' } }, { id: 'score', name: 'Score', type: 'number', settings: { width: 100, formType: 'number', prefix: '$', decimals: 2, thousandSep: true } },
{ id: 'created', name: 'Created At', type: 'date', settings: { width: 150, formType: 'date' } }, { id: 'created', name: 'Created At', type: 'date', settings: { width: 150, formType: 'date' } },
{ id: 'actions', name: 'Actions', type: 'string', settings: { width: 100, formType: 'text', pinned: 'right' } } { id: 'actions', name: 'Actions', type: 'string', settings: { width: 100, formType: 'text', pinned: 'right' }, formatter: () => '🔧 Edit' }
] ]
const generateData = (count) => Array.from({ length: count }, (_, i) => ({ const generateData = (count) => Array.from({ length: count }, (_, i) => ({
@ -58,7 +65,7 @@
bio: 'Bio ' + (i + 1), bio: 'Bio ' + (i + 1),
gender: ['Male', 'Female', 'Other'][i % 3], gender: ['Male', 'Female', 'Other'][i % 3],
tags: ['State.js', 'Vite'].slice(0, (i % 2) + 1), tags: ['State.js', 'Vite'].slice(0, (i % 2) + 1),
score: 80, score: 8000.5,
created: '2026-05-17', created: '2026-05-17',
actions: '...' actions: '...'
})) }))