littleshop/LittleShop/wwwroot/js/variant-editor.js
SysAdmin 0dbc49ee89 fix: Critical data loss bug in variant editor - removed overly aggressive column skip logic
Problem: Variant editor was skipping ALL columns with headers starting with 'Property '
(e.g., 'Property 1'), which caused complete data loss during serialization.

When users entered data but didn't rename the default column header, serializeToJSON()
would skip the column entirely, returning an empty array [] to the database.

Fix: Only skip columns with truly empty names, not default 'Property X' names.
Users can now save data even if they haven't renamed column headers.

Files changed:
- wwwroot/js/variant-editor.js: Removed propertyName.startsWith('Property ') check
- Areas/Admin/Views/VariantCollections/Create.cshtml: Updated cache-busting to v=20251113d
- Areas/Admin/Views/VariantCollections/Edit.cshtml: Updated cache-busting to v=20251113d
2025-11-14 00:35:55 +00:00

458 lines
15 KiB
JavaScript

/**
* 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)
* - Preset shortcut buttons for quick column addition
* - Inline cell editing with Tab navigation
* - Add/remove rows and columns via context menu
* - 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: 5,
minCols: 1,
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']
};
this.init();
}
/**
* Parse initial JSON data into spreadsheet format
*/
parseInitialData(jsonString) {
// If no data, start with empty grid
if (!jsonString || jsonString.trim() === '' || jsonString === '[]') {
return {
properties: ['Property 1'],
values: [
[null],
[null],
[null],
[null],
[null]
]
};
}
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), this.options.minRows);
// 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 empty grid:', e);
return this.parseInitialData('');
}
}
/**
* Initialize Handsontable instance
*/
init() {
if (!this.container) {
console.error('Container element not found');
return;
}
// Create preset buttons toolbar
this.createPresetToolbar();
// Create spreadsheet container
const spreadsheetDiv = document.createElement('div');
spreadsheetDiv.id = this.container.id + '-grid';
spreadsheetDiv.style.marginTop = '10px';
this.container.appendChild(spreadsheetDiv);
this.hot = new Handsontable(spreadsheetDiv, {
data: this.initialData.values,
colHeaders: this.initialData.properties,
rowHeaders: true,
minRows: this.options.minRows,
minSpareRows: 1,
minCols: this.options.minCols,
minSpareCols: 0,
maxCols: this.options.maxCols,
contextMenu: {
items: {
'row_above': {
name: 'Insert row above'
},
'row_below': {
name: 'Insert row below'
},
'separator1': '---------',
'remove_row': {
name: 'Delete row'
},
'separator2': '---------',
'col_left': {
name: 'Insert column left'
},
'col_right': {
name: 'Insert column right'
},
'separator3': '---------',
'remove_col': {
name: 'Delete column'
},
'separator4': '---------',
'rename_column': {
name: 'Rename column...',
callback: (key, selection) => {
const col = selection[0].start.col;
this.showRenameDialog(col);
}
},
'separator5': '---------',
'clear_column': {
name: 'Clear column values'
},
'undo': {},
'redo': {}
}
},
manualColumnResize: true,
manualRowResize: true,
undo: true,
autoWrapRow: true,
autoWrapCol: true,
enterMoves: { row: 1, col: 0 }, // Enter moves down
tabMoves: { row: 0, col: 1 }, // Tab moves right
fillHandle: {
autoInsertRow: true
},
stretchH: 'all',
licenseKey: 'non-commercial-and-evaluation', // Community Edition
// Double-click header to rename
afterOnCellMouseDown: (event, coords) => {
if (coords.row === -1 && coords.col >= 0 && event.detail === 2) {
this.showRenameDialog(coords.col);
}
}
});
// Setup keyboard shortcuts
this.setupKeyboardShortcuts();
// Setup touch gestures for mobile
if (typeof Hammer !== 'undefined') {
this.setupTouchGestures();
}
}
/**
* Create preset shortcut buttons toolbar
*/
createPresetToolbar() {
const toolbar = document.createElement('div');
toolbar.className = 'variant-preset-toolbar';
toolbar.style.marginBottom = '10px';
toolbar.style.display = 'flex';
toolbar.style.gap = '8px';
toolbar.style.flexWrap = 'wrap';
const label = document.createElement('strong');
label.textContent = 'Quick Add: ';
label.style.marginRight = '8px';
label.style.alignSelf = 'center';
toolbar.appendChild(label);
// Add preset buttons
Object.keys(this.propertyPresets).forEach(presetName => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-sm btn-outline-primary';
button.innerHTML = `<i class="fas fa-plus"></i> ${presetName}`;
button.style.fontSize = '12px';
button.addEventListener('click', () => {
this.addPresetColumn(presetName);
});
toolbar.appendChild(button);
});
// Add custom column button
const customButton = document.createElement('button');
customButton.type = 'button';
customButton.className = 'btn btn-sm btn-outline-secondary';
customButton.innerHTML = `<i class="fas fa-pencil-alt"></i> Custom`;
customButton.style.fontSize = '12px';
customButton.addEventListener('click', () => {
this.addCustomColumn();
});
toolbar.appendChild(customButton);
this.container.appendChild(toolbar);
}
/**
* Add a preset column to the spreadsheet
*/
addPresetColumn(presetName) {
const presetValues = this.propertyPresets[presetName];
const currentColCount = this.hot.countCols();
// Insert new column
this.hot.alter('insert_col_end', currentColCount, 1);
// Update column header with new array reference
const currentHeaders = this.hot.getColHeaders();
const newHeaders = Array.isArray(currentHeaders)
? [...currentHeaders]
: Array.from({ length: this.hot.countCols() }, (_, i) => this.hot.getColHeader(i));
newHeaders[currentColCount] = presetName;
this.hot.updateSettings({
colHeaders: newHeaders
});
// Populate with preset values
if (presetValues && Array.isArray(presetValues)) {
presetValues.forEach((value, rowIndex) => {
if (rowIndex < this.hot.countRows()) {
this.hot.setDataAtCell(rowIndex, currentColCount, value);
}
});
}
console.log('Added preset column:', { presetName, columnIndex: currentColCount, valuesCount: presetValues ? presetValues.length : 0 });
}
/**
* Add a custom column to the spreadsheet
*/
addCustomColumn() {
const customName = prompt('Enter column name:', 'Custom Property');
if (!customName || customName.trim() === '') {
return;
}
const currentColCount = this.hot.countCols();
// Insert new column
this.hot.alter('insert_col_end', currentColCount, 1);
// Update column header with new array reference
const currentHeaders = this.hot.getColHeaders();
const newHeaders = Array.isArray(currentHeaders)
? [...currentHeaders]
: Array.from({ length: this.hot.countCols() }, (_, i) => this.hot.getColHeader(i));
newHeaders[currentColCount] = customName.trim();
this.hot.updateSettings({
colHeaders: newHeaders
});
console.log('Added custom column:', { columnName: customName.trim(), columnIndex: currentColCount });
}
/**
* Show rename dialog for column
*/
showRenameDialog(colIndex) {
const currentHeader = this.hot.getColHeader(colIndex);
const newName = prompt('Rename column:', currentHeader);
if (newName && newName.trim() !== '') {
// Get current headers and create a new array (force immutability)
const currentHeaders = this.hot.getColHeaders();
const newHeaders = Array.isArray(currentHeaders)
? [...currentHeaders]
: Array.from({ length: this.hot.countCols() }, (_, i) => this.hot.getColHeader(i));
// Update the specific column header
newHeaders[colIndex] = newName.trim();
// Force update with new array reference
this.hot.updateSettings({
colHeaders: newHeaders
});
// Render to ensure visual update
this.hot.render();
console.log('Column renamed:', { colIndex, oldName: currentHeader, newName: newName.trim() });
}
}
/**
* Setup keyboard shortcuts
*/
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Only process if the spreadsheet is focused
if (!this.container.contains(document.activeElement)) {
return;
}
// 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 = [];
console.log('Serializing spreadsheet:', { totalColumns: headers.length, totalRows: data.length });
// Process each column
for (let col = 0; col < headers.length; col++) {
const propertyName = headers[col];
// Skip only truly empty column names
if (!propertyName || propertyName.trim() === '') {
console.log('Skipping column:', { colIndex: col, name: propertyName, reason: 'empty name' });
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(String(cellValue).trim());
}
}
// Create property object
const property = {
name: propertyName.trim(),
values: values.length > 0 ? values : null // null means freeform
};
properties.push(property);
console.log('Added property:', { name: property.name, valueCount: values.length });
}
const jsonString = JSON.stringify(properties, null, 2);
console.log('Final JSON:', jsonString);
// Update hidden input
const hiddenInput = document.getElementById('PropertiesJson');
if (hiddenInput) {
hiddenInput.value = jsonString;
console.log('Updated hidden input PropertiesJson');
} else {
console.error('Hidden input PropertiesJson not found!');
}
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;