feat: enhance DataTable with extensible registerFieldType API, rich formatters, and dynamic AutoForm schemas (by AI)
This commit is contained in:
parent
82b7ebc799
commit
2a81326df3
145
src/index.js
145
src/index.js
@ -19,6 +19,84 @@ const MODE_ICONS = {
|
||||
'=': '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 => {
|
||||
if (!container.state) container.state = NewState({})
|
||||
const state = container.state
|
||||
@ -60,6 +138,8 @@ Component.register('DataTable', container => {
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
@ -385,25 +465,32 @@ Component.register('DataTable', container => {
|
||||
state.isDirty = false;
|
||||
};
|
||||
|
||||
const FIELD_TYPES = [
|
||||
{ label: '{#Text#}', value: 'text', db: 'v4096' },
|
||||
{ label: '{#Number#}', value: 'number' },
|
||||
{ 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 = () => [
|
||||
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: FIELD_TYPES },
|
||||
{ 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)' },
|
||||
{ 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();
|
||||
@ -426,8 +513,8 @@ Component.register('DataTable', container => {
|
||||
d.show();
|
||||
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result)));
|
||||
if (result === 2) {
|
||||
const typeInfo = FIELD_TYPES.find(t => t.value === data.user_type);
|
||||
let dbType = typeInfo?.db || 'v255';
|
||||
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 = {
|
||||
@ -436,7 +523,10 @@ Component.register('DataTable', container => {
|
||||
settings: {
|
||||
formType: data.user_type,
|
||||
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];
|
||||
@ -451,11 +541,15 @@ Component.register('DataTable', container => {
|
||||
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: f.settings?.formType || 'text',
|
||||
decimals: f.settings?.decimals || 0,
|
||||
options_str: f.settings?.options ? f.settings.options.join('\n') : ''
|
||||
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'));
|
||||
@ -477,8 +571,8 @@ Component.register('DataTable', container => {
|
||||
if (result === 2) {
|
||||
const idx = state.fields.findIndex(item => item.id === f.id);
|
||||
if (idx !== -1) {
|
||||
const typeInfo = FIELD_TYPES.find(t => t.value === data.user_type);
|
||||
let dbType = typeInfo?.db || 'v255';
|
||||
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 = {
|
||||
@ -488,7 +582,10 @@ Component.register('DataTable', container => {
|
||||
...f.settings,
|
||||
formType: data.user_type,
|
||||
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;
|
||||
|
||||
@ -87,7 +87,17 @@ test('DataTable comprehensive tests and scrolling benchmarks', async ({ page })
|
||||
}, colIdx);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,21 +33,28 @@
|
||||
<script type="module">
|
||||
import { RefreshState } from '@web/state'
|
||||
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 fields = [
|
||||
{ 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: 'role', name: 'Role', type: 'string', settings: { width: 120, formType: 'select', options: ['Admin', 'Editor', 'Viewer'] } },
|
||||
{ id: 'active', name: 'Active', type: 'boolean', settings: { width: 80, formType: 'switch' } },
|
||||
{ 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', labelOn: 'YES', labelOff: 'NO' } },
|
||||
{ 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: '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: '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) => ({
|
||||
@ -58,7 +65,7 @@
|
||||
bio: 'Bio ' + (i + 1),
|
||||
gender: ['Male', 'Female', 'Other'][i % 3],
|
||||
tags: ['State.js', 'Vite'].slice(0, (i % 2) + 1),
|
||||
score: 80,
|
||||
score: 8000.5,
|
||||
created: '2026-05-17',
|
||||
actions: '...'
|
||||
}))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user