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'
|
'=': '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;
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: '...'
|
||||||
}))
|
}))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user