feat: Phase 2.5 - Variant Collections Spreadsheet Editor
Replaces JSON textarea with professional Excel-like spreadsheet interface for managing product variant properties. Features: - Handsontable 14.6.1 spreadsheet component - Property presets (Size, Color, Material, Storage, Custom) - Inline cell editing with Tab/Enter navigation - Context menu for add/remove rows and columns - Keyboard shortcuts (Ctrl+D delete, Ctrl+Enter save, Ctrl+Z undo) - Mobile touch gestures (swipe to delete rows) - Automatic JSON serialization on form submit - Form validation before saving - Comprehensive user guide documentation Files Changed: - LittleShop/package.json: NPM package management setup - LittleShop/wwwroot/js/variant-editor.js: 400-line spreadsheet editor module - LittleShop/wwwroot/lib/handsontable/: Handsontable library (Community Edition) - LittleShop/wwwroot/lib/hammerjs/: Hammer.js touch gesture library - LittleShop/Areas/Admin/Views/VariantCollections/Edit.cshtml: Spreadsheet UI integration - VARIANT_COLLECTIONS_USER_GUIDE.md: Complete user guide (18+ pages) Technical Details: - Excel-like editing experience (no more manual JSON editing) - Mobile-first responsive design - Browser compatibility: Chrome 90+, Firefox 88+, Edge 90+, Safari 14+ - Touch-optimized for mobile administration - Automatic data validation and error handling
This commit is contained in:
367
LittleShop/wwwroot/js/variant-editor.js
Normal file
367
LittleShop/wwwroot/js/variant-editor.js
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Variant Collections Spreadsheet Editor
|
||||
*
|
||||
* Provides a professional spreadsheet-style interface for editing variant collection properties
|
||||
* using Handsontable. Replaces the JSON textarea with an intuitive grid editor.
|
||||
*
|
||||
* Features:
|
||||
* - Property presets (Size, Color, Material, Storage, Custom)
|
||||
* - Inline cell editing with Tab navigation
|
||||
* - Add/remove rows and columns dynamically
|
||||
* - Keyboard shortcuts (Ctrl+D delete, Ctrl+Enter save)
|
||||
* - Mobile-friendly touch gestures
|
||||
* - Automatic JSON serialization
|
||||
*/
|
||||
|
||||
class VariantEditor {
|
||||
constructor(containerId, initialData, options = {}) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.hot = null;
|
||||
this.initialData = this.parseInitialData(initialData);
|
||||
this.options = {
|
||||
minRows: 3,
|
||||
minCols: 2,
|
||||
maxCols: 10,
|
||||
...options
|
||||
};
|
||||
|
||||
// Property type presets
|
||||
this.propertyPresets = {
|
||||
'Size': ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
|
||||
'Color': ['Black', 'White', 'Red', 'Blue', 'Green', 'Yellow', 'Pink', 'Purple', 'Gray', 'Brown'],
|
||||
'Material': ['Cotton', 'Polyester', 'Silk', 'Wool', 'Leather', 'Denim', 'Linen'],
|
||||
'Storage': ['16GB', '32GB', '64GB', '128GB', '256GB', '512GB', '1TB'],
|
||||
'Custom': null // Freeform text
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse initial JSON data into spreadsheet format
|
||||
*/
|
||||
parseInitialData(jsonString) {
|
||||
if (!jsonString || jsonString.trim() === '' || jsonString === '[]') {
|
||||
return {
|
||||
properties: ['Size', 'Color'],
|
||||
values: [
|
||||
['S', 'Black'],
|
||||
['M', 'White'],
|
||||
['L', 'Red']
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const properties = JSON.parse(jsonString);
|
||||
if (!Array.isArray(properties) || properties.length === 0) {
|
||||
throw new Error('Invalid format');
|
||||
}
|
||||
|
||||
// Convert from JSON format to grid format
|
||||
const propertyNames = properties.map(p => p.name);
|
||||
const propertyValues = properties.map(p => p.values);
|
||||
|
||||
// Determine max number of rows needed
|
||||
const maxRows = Math.max(...propertyValues.map(v => Array.isArray(v) ? v.length : 0), 1);
|
||||
|
||||
// Build grid data
|
||||
const gridData = [];
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
const row = propertyNames.map((name, colIndex) => {
|
||||
const values = propertyValues[colIndex];
|
||||
if (values === null) {
|
||||
return null; // Freeform
|
||||
}
|
||||
return Array.isArray(values) && values[i] ? values[i] : null;
|
||||
});
|
||||
gridData.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
properties: propertyNames,
|
||||
values: gridData
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse initial data, using defaults:', e);
|
||||
return this.parseInitialData('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Handsontable instance
|
||||
*/
|
||||
init() {
|
||||
if (!this.container) {
|
||||
console.error('Container element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.hot = new Handsontable(this.container, {
|
||||
data: this.initialData.values,
|
||||
colHeaders: this.initialData.properties,
|
||||
rowHeaders: true,
|
||||
minRows: this.options.minRows,
|
||||
minSpareRows: 1,
|
||||
minCols: this.options.minCols,
|
||||
minSpareCols: 1,
|
||||
maxCols: this.options.maxCols,
|
||||
contextMenu: {
|
||||
items: {
|
||||
'row_above': {},
|
||||
'row_below': {},
|
||||
'separator1': '---------',
|
||||
'remove_row': {},
|
||||
'separator2': '---------',
|
||||
'col_left': {},
|
||||
'col_right': {},
|
||||
'separator3': '---------',
|
||||
'remove_col': {},
|
||||
'separator4': '---------',
|
||||
'clear_column': {},
|
||||
'undo': {},
|
||||
'redo': {}
|
||||
}
|
||||
},
|
||||
manualColumnResize: true,
|
||||
manualRowResize: true,
|
||||
undo: true,
|
||||
autoWrapRow: true,
|
||||
autoWrapCol: true,
|
||||
enterMoves: { row: 0, col: 1 }, // Tab behavior: move right
|
||||
tabMoves: { row: 0, col: 1 },
|
||||
fillHandle: {
|
||||
autoInsertRow: true
|
||||
},
|
||||
stretchH: 'all',
|
||||
licenseKey: 'non-commercial-and-evaluation', // Community Edition
|
||||
|
||||
// Enable column header editing for property names
|
||||
afterGetColHeader: (col, TH) => {
|
||||
TH.classList.add('editable-header');
|
||||
},
|
||||
|
||||
// Make headers clickable to change property type
|
||||
afterOnCellMouseDown: (event, coords) => {
|
||||
if (coords.row === -1 && coords.col >= 0) {
|
||||
this.showPropertyTypeMenu(coords.col, event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this.setupKeyboardShortcuts();
|
||||
|
||||
// Setup touch gestures for mobile
|
||||
if (typeof Hammer !== 'undefined') {
|
||||
this.setupTouchGestures();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show property type selection menu
|
||||
*/
|
||||
showPropertyTypeMenu(colIndex, event) {
|
||||
const currentHeader = this.hot.getColHeader(colIndex);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'property-type-menu';
|
||||
menu.style.position = 'absolute';
|
||||
menu.style.left = event.clientX + 'px';
|
||||
menu.style.top = event.clientY + 'px';
|
||||
menu.style.zIndex = 10000;
|
||||
menu.style.background = 'white';
|
||||
menu.style.border = '1px solid #ccc';
|
||||
menu.style.borderRadius = '4px';
|
||||
menu.style.boxShadow = '0 4px 6px rgba(0,0,0,0.1)';
|
||||
menu.style.padding = '8px 0';
|
||||
menu.style.minWidth = '150px';
|
||||
|
||||
const presetOptions = Object.keys(this.propertyPresets);
|
||||
presetOptions.push('Custom...');
|
||||
|
||||
presetOptions.forEach(preset => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'property-type-option';
|
||||
option.textContent = preset === 'Custom...' ? '✏️ ' + preset : preset;
|
||||
option.style.padding = '8px 16px';
|
||||
option.style.cursor = 'pointer';
|
||||
|
||||
if (preset === currentHeader) {
|
||||
option.style.background = '#e3f2fd';
|
||||
option.style.fontWeight = 'bold';
|
||||
}
|
||||
|
||||
option.addEventListener('mouseenter', () => {
|
||||
option.style.background = '#f5f5f5';
|
||||
});
|
||||
|
||||
option.addEventListener('mouseleave', () => {
|
||||
if (preset !== currentHeader) {
|
||||
option.style.background = 'white';
|
||||
} else {
|
||||
option.style.background = '#e3f2fd';
|
||||
}
|
||||
});
|
||||
|
||||
option.addEventListener('click', () => {
|
||||
if (preset === 'Custom...') {
|
||||
const customName = prompt('Enter custom property name:', currentHeader);
|
||||
if (customName) {
|
||||
this.setPropertyType(colIndex, customName, null);
|
||||
}
|
||||
} else {
|
||||
this.setPropertyType(colIndex, preset, this.propertyPresets[preset]);
|
||||
}
|
||||
document.body.removeChild(menu);
|
||||
});
|
||||
|
||||
menu.appendChild(option);
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Close menu when clicking outside
|
||||
const closeMenu = (e) => {
|
||||
if (!menu.contains(e.target)) {
|
||||
document.body.removeChild(menu);
|
||||
document.removeEventListener('click', closeMenu);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeMenu), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set property type for a column
|
||||
*/
|
||||
setPropertyType(colIndex, propertyName, presetValues) {
|
||||
// Update column header
|
||||
this.hot.updateSettings({
|
||||
colHeaders: this.hot.getSettings().colHeaders.map((header, index) => {
|
||||
return index === colIndex ? propertyName : header;
|
||||
})
|
||||
});
|
||||
|
||||
// If preset has values, populate the column
|
||||
if (presetValues && Array.isArray(presetValues)) {
|
||||
const data = this.hot.getData();
|
||||
presetValues.forEach((value, rowIndex) => {
|
||||
if (rowIndex < data.length) {
|
||||
this.hot.setDataAtCell(rowIndex, colIndex, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup keyboard shortcuts
|
||||
*/
|
||||
setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+D or Cmd+D: Delete selected rows/columns
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
const selected = this.hot.getSelected();
|
||||
if (selected) {
|
||||
const [[startRow, startCol, endRow, endCol]] = selected;
|
||||
if (startRow !== endRow) {
|
||||
// Delete rows
|
||||
this.hot.alter('remove_row', startRow, endRow - startRow + 1);
|
||||
} else if (startCol !== endCol) {
|
||||
// Delete columns
|
||||
this.hot.alter('remove_col', startCol, endCol - startCol + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Enter or Cmd+Enter: Trigger form save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const form = this.container.closest('form');
|
||||
if (form) {
|
||||
this.serializeToJSON();
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup touch gestures for mobile
|
||||
*/
|
||||
setupTouchGestures() {
|
||||
const hammertime = new Hammer(this.container);
|
||||
|
||||
// Swipe left/right to delete row
|
||||
hammertime.on('swipeleft swiperight', (ev) => {
|
||||
const coords = this.hot.getSelectedLast();
|
||||
if (coords) {
|
||||
const [row] = coords;
|
||||
if (confirm('Delete this row?')) {
|
||||
this.hot.alter('remove_row', row);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize spreadsheet data to JSON format
|
||||
*/
|
||||
serializeToJSON() {
|
||||
const data = this.hot.getData();
|
||||
const headers = this.hot.getColHeaders();
|
||||
const properties = [];
|
||||
|
||||
// Process each column
|
||||
for (let col = 0; col < headers.length; col++) {
|
||||
const propertyName = headers[col];
|
||||
if (!propertyName || propertyName.trim() === '') continue;
|
||||
|
||||
// Collect non-null values from this column
|
||||
const values = [];
|
||||
for (let row = 0; row < data.length; row++) {
|
||||
const cellValue = data[row][col];
|
||||
if (cellValue !== null && cellValue !== undefined && cellValue !== '') {
|
||||
values.push(cellValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Create property object
|
||||
const property = {
|
||||
name: propertyName,
|
||||
values: values.length > 0 ? values : null // null means freeform
|
||||
};
|
||||
|
||||
properties.push(property);
|
||||
}
|
||||
|
||||
const jsonString = JSON.stringify(properties, null, 2);
|
||||
|
||||
// Update hidden input or textarea
|
||||
const hiddenInput = document.getElementById('PropertiesJson');
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = jsonString;
|
||||
}
|
||||
|
||||
return jsonString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current data as JSON
|
||||
*/
|
||||
toJSON() {
|
||||
return this.serializeToJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the editor instance
|
||||
*/
|
||||
destroy() {
|
||||
if (this.hot) {
|
||||
this.hot.destroy();
|
||||
this.hot = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
window.VariantEditor = VariantEditor;
|
||||
Reference in New Issue
Block a user