feat: Redesign variant editor with preset buttons

Major UX improvements based on user feedback:
- Replaced auto-populated columns with preset shortcut buttons
- Quick Add buttons for Size, Color, Material, Storage
- Custom button for user-defined property names
- Double-click column headers to rename
- Rename column option in context menu
- Starts with single empty column instead of defaults
- Improved usage instructions in UI
- Cache-busting version updated to force reload

This design is more flexible and less confusing than auto-generating columns.
This commit is contained in:
sysadmin 2025-11-14 00:05:02 +00:00
parent b53597f250
commit abe01cb8a0
3 changed files with 176 additions and 119 deletions

View File

@ -42,11 +42,11 @@
<div class="form-text mt-2"> <div class="form-text mt-2">
<strong><i class="fas fa-info-circle"></i> How to use:</strong> <strong><i class="fas fa-info-circle"></i> How to use:</strong>
<ul class="mb-1"> <ul class="mb-1">
<li><strong>Click column headers</strong> to select property type (Size, Color, Material, Storage, or Custom)</li> <li><strong>Quick Add buttons:</strong> Click preset buttons (Size, Color, Material, Storage) to add pre-populated columns</li>
<li><strong>Click cells</strong> to edit values directly</li> <li><strong>Custom button:</strong> Click Custom to add a column with your own name</li>
<li><strong>Right-click</strong> for menu to add/remove rows and columns</li> <li><strong>Double-click headers:</strong> Double-click column headers to rename them</li>
<li><strong>Right-click menu:</strong> Add/remove rows and columns, rename columns, clear values</li>
<li><strong>Keyboard shortcuts:</strong> Tab to move right, Enter to move down, Ctrl+D to delete, Ctrl+Enter to save</li> <li><strong>Keyboard shortcuts:</strong> Tab to move right, Enter to move down, Ctrl+D to delete, Ctrl+Enter to save</li>
<li><strong>Mobile:</strong> Swipe rows left/right to delete</li>
</ul> </ul>
<small class="text-muted">Changes are automatically saved when you click "Create Collection" below.</small> <small class="text-muted">Changes are automatically saved when you click "Create Collection" below.</small>
</div> </div>
@ -67,14 +67,14 @@
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");} @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<!-- Handsontable Spreadsheet Library --> <!-- Handsontable Spreadsheet Library -->
<link rel="stylesheet" href="~/lib/handsontable/handsontable.full.min.css?v=20251113" /> <link rel="stylesheet" href="~/lib/handsontable/handsontable.full.min.css?v=20251113b" />
<script src="~/lib/handsontable/handsontable.full.min.js?v=20251113"></script> <script src="~/lib/handsontable/handsontable.full.min.js?v=20251113b"></script>
<!-- Hammer.js for Touch Gestures --> <!-- Hammer.js for Touch Gestures -->
<script src="~/lib/hammerjs/hammer.min.js?v=20251113"></script> <script src="~/lib/hammerjs/hammer.min.js?v=20251113b"></script>
<!-- Variant Editor Module --> <!-- Variant Editor Module -->
<script src="~/js/variant-editor.js?v=20251113"></script> <script src="~/js/variant-editor.js?v=20251113b"></script>
<!-- Initialize Variant Editor --> <!-- Initialize Variant Editor -->
<script> <script>

View File

@ -43,11 +43,11 @@
<div class="form-text mt-2"> <div class="form-text mt-2">
<strong><i class="fas fa-info-circle"></i> How to use:</strong> <strong><i class="fas fa-info-circle"></i> How to use:</strong>
<ul class="mb-1"> <ul class="mb-1">
<li><strong>Click column headers</strong> to select property type (Size, Color, Material, Storage, or Custom)</li> <li><strong>Quick Add buttons:</strong> Click preset buttons (Size, Color, Material, Storage) to add pre-populated columns</li>
<li><strong>Click cells</strong> to edit values directly</li> <li><strong>Custom button:</strong> Click Custom to add a column with your own name</li>
<li><strong>Right-click</strong> for menu to add/remove rows and columns</li> <li><strong>Double-click headers:</strong> Double-click column headers to rename them</li>
<li><strong>Right-click menu:</strong> Add/remove rows and columns, rename columns, clear values</li>
<li><strong>Keyboard shortcuts:</strong> Tab to move right, Enter to move down, Ctrl+D to delete, Ctrl+Enter to save</li> <li><strong>Keyboard shortcuts:</strong> Tab to move right, Enter to move down, Ctrl+D to delete, Ctrl+Enter to save</li>
<li><strong>Mobile:</strong> Swipe rows left/right to delete</li>
</ul> </ul>
<small class="text-muted">Changes are automatically saved when you click "Save Changes" below.</small> <small class="text-muted">Changes are automatically saved when you click "Save Changes" below.</small>
</div> </div>
@ -78,14 +78,14 @@
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");} @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<!-- Handsontable Spreadsheet Library --> <!-- Handsontable Spreadsheet Library -->
<link rel="stylesheet" href="~/lib/handsontable/handsontable.full.min.css?v=20251113" /> <link rel="stylesheet" href="~/lib/handsontable/handsontable.full.min.css?v=20251113b" />
<script src="~/lib/handsontable/handsontable.full.min.js?v=20251113"></script> <script src="~/lib/handsontable/handsontable.full.min.js?v=20251113b"></script>
<!-- Hammer.js for Touch Gestures --> <!-- Hammer.js for Touch Gestures -->
<script src="~/lib/hammerjs/hammer.min.js?v=20251113"></script> <script src="~/lib/hammerjs/hammer.min.js?v=20251113b"></script>
<!-- Variant Editor Module --> <!-- Variant Editor Module -->
<script src="~/js/variant-editor.js?v=20251113"></script> <script src="~/js/variant-editor.js?v=20251113b"></script>
<!-- Initialize Variant Editor --> <!-- Initialize Variant Editor -->
<script> <script>

View File

@ -6,8 +6,9 @@
* *
* Features: * Features:
* - Property presets (Size, Color, Material, Storage, Custom) * - Property presets (Size, Color, Material, Storage, Custom)
* - Preset shortcut buttons for quick column addition
* - Inline cell editing with Tab navigation * - Inline cell editing with Tab navigation
* - Add/remove rows and columns dynamically * - Add/remove rows and columns via context menu
* - Keyboard shortcuts (Ctrl+D delete, Ctrl+Enter save) * - Keyboard shortcuts (Ctrl+D delete, Ctrl+Enter save)
* - Mobile-friendly touch gestures * - Mobile-friendly touch gestures
* - Automatic JSON serialization * - Automatic JSON serialization
@ -19,8 +20,8 @@ class VariantEditor {
this.hot = null; this.hot = null;
this.initialData = this.parseInitialData(initialData); this.initialData = this.parseInitialData(initialData);
this.options = { this.options = {
minRows: 3, minRows: 5,
minCols: 2, minCols: 1,
maxCols: 10, maxCols: 10,
...options ...options
}; };
@ -30,8 +31,7 @@ class VariantEditor {
'Size': ['XS', 'S', 'M', 'L', 'XL', 'XXL'], 'Size': ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
'Color': ['Black', 'White', 'Red', 'Blue', 'Green', 'Yellow', 'Pink', 'Purple', 'Gray', 'Brown'], 'Color': ['Black', 'White', 'Red', 'Blue', 'Green', 'Yellow', 'Pink', 'Purple', 'Gray', 'Brown'],
'Material': ['Cotton', 'Polyester', 'Silk', 'Wool', 'Leather', 'Denim', 'Linen'], 'Material': ['Cotton', 'Polyester', 'Silk', 'Wool', 'Leather', 'Denim', 'Linen'],
'Storage': ['16GB', '32GB', '64GB', '128GB', '256GB', '512GB', '1TB'], 'Storage': ['16GB', '32GB', '64GB', '128GB', '256GB', '512GB', '1TB']
'Custom': null // Freeform text
}; };
this.init(); this.init();
@ -41,13 +41,16 @@ class VariantEditor {
* Parse initial JSON data into spreadsheet format * Parse initial JSON data into spreadsheet format
*/ */
parseInitialData(jsonString) { parseInitialData(jsonString) {
// If no data, start with empty grid
if (!jsonString || jsonString.trim() === '' || jsonString === '[]') { if (!jsonString || jsonString.trim() === '' || jsonString === '[]') {
return { return {
properties: ['Size', 'Color'], properties: ['Property 1'],
values: [ values: [
['S', 'Black'], [null],
['M', 'White'], [null],
['L', 'Red'] [null],
[null],
[null]
] ]
}; };
} }
@ -63,7 +66,7 @@ class VariantEditor {
const propertyValues = properties.map(p => p.values); const propertyValues = properties.map(p => p.values);
// Determine max number of rows needed // Determine max number of rows needed
const maxRows = Math.max(...propertyValues.map(v => Array.isArray(v) ? v.length : 0), 1); const maxRows = Math.max(...propertyValues.map(v => Array.isArray(v) ? v.length : 0), this.options.minRows);
// Build grid data // Build grid data
const gridData = []; const gridData = [];
@ -83,7 +86,7 @@ class VariantEditor {
values: gridData values: gridData
}; };
} catch (e) { } catch (e) {
console.warn('Failed to parse initial data, using defaults:', e); console.warn('Failed to parse initial data, using empty grid:', e);
return this.parseInitialData(''); return this.parseInitialData('');
} }
} }
@ -97,28 +100,59 @@ class VariantEditor {
return; return;
} }
this.hot = new Handsontable(this.container, { // 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, data: this.initialData.values,
colHeaders: this.initialData.properties, colHeaders: this.initialData.properties,
rowHeaders: true, rowHeaders: true,
minRows: this.options.minRows, minRows: this.options.minRows,
minSpareRows: 1, minSpareRows: 1,
minCols: this.options.minCols, minCols: this.options.minCols,
minSpareCols: 1, minSpareCols: 0,
maxCols: this.options.maxCols, maxCols: this.options.maxCols,
contextMenu: { contextMenu: {
items: { items: {
'row_above': {}, 'row_above': {
'row_below': {}, name: 'Insert row above'
},
'row_below': {
name: 'Insert row below'
},
'separator1': '---------', 'separator1': '---------',
'remove_row': {}, 'remove_row': {
name: 'Delete row'
},
'separator2': '---------', 'separator2': '---------',
'col_left': {}, 'col_left': {
'col_right': {}, name: 'Insert column left'
},
'col_right': {
name: 'Insert column right'
},
'separator3': '---------', 'separator3': '---------',
'remove_col': {}, 'remove_col': {
name: 'Delete column'
},
'separator4': '---------', 'separator4': '---------',
'clear_column': {}, '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': {}, 'undo': {},
'redo': {} 'redo': {}
} }
@ -128,23 +162,18 @@ class VariantEditor {
undo: true, undo: true,
autoWrapRow: true, autoWrapRow: true,
autoWrapCol: true, autoWrapCol: true,
enterMoves: { row: 0, col: 1 }, // Tab behavior: move right enterMoves: { row: 1, col: 0 }, // Enter moves down
tabMoves: { row: 0, col: 1 }, tabMoves: { row: 0, col: 1 }, // Tab moves right
fillHandle: { fillHandle: {
autoInsertRow: true autoInsertRow: true
}, },
stretchH: 'all', stretchH: 'all',
licenseKey: 'non-commercial-and-evaluation', // Community Edition licenseKey: 'non-commercial-and-evaluation', // Community Edition
// Enable column header editing for property names // Double-click header to rename
afterGetColHeader: (col, TH) => {
TH.classList.add('editable-header');
},
// Make headers clickable to change property type
afterOnCellMouseDown: (event, coords) => { afterOnCellMouseDown: (event, coords) => {
if (coords.row === -1 && coords.col >= 0) { if (coords.row === -1 && coords.col >= 0 && event.detail === 2) {
this.showPropertyTypeMenu(coords.col, event); this.showRenameDialog(coords.col);
} }
} }
}); });
@ -159,104 +188,128 @@ class VariantEditor {
} }
/** /**
* Show property type selection menu * Create preset shortcut buttons toolbar
*/ */
showPropertyTypeMenu(colIndex, event) { createPresetToolbar() {
const currentHeader = this.hot.getColHeader(colIndex); const toolbar = document.createElement('div');
const menu = document.createElement('div'); toolbar.className = 'variant-preset-toolbar';
menu.className = 'property-type-menu'; toolbar.style.marginBottom = '10px';
menu.style.position = 'absolute'; toolbar.style.display = 'flex';
menu.style.left = event.clientX + 'px'; toolbar.style.gap = '8px';
menu.style.top = event.clientY + 'px'; toolbar.style.flexWrap = 'wrap';
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); const label = document.createElement('strong');
presetOptions.push('Custom...'); label.textContent = 'Quick Add: ';
label.style.marginRight = '8px';
label.style.alignSelf = 'center';
toolbar.appendChild(label);
presetOptions.forEach(preset => { // Add preset buttons
const option = document.createElement('div'); Object.keys(this.propertyPresets).forEach(presetName => {
option.className = 'property-type-option'; const button = document.createElement('button');
option.textContent = preset === 'Custom...' ? '✏️ ' + preset : preset; button.type = 'button';
option.style.padding = '8px 16px'; button.className = 'btn btn-sm btn-outline-primary';
option.style.cursor = 'pointer'; button.innerHTML = `<i class="fas fa-plus"></i> ${presetName}`;
button.style.fontSize = '12px';
if (preset === currentHeader) { button.addEventListener('click', () => {
option.style.background = '#e3f2fd'; this.addPresetColumn(presetName);
option.style.fontWeight = 'bold';
}
option.addEventListener('mouseenter', () => {
option.style.background = '#f5f5f5';
}); });
option.addEventListener('mouseleave', () => { toolbar.appendChild(button);
if (preset !== currentHeader) {
option.style.background = 'white';
} else {
option.style.background = '#e3f2fd';
}
}); });
option.addEventListener('click', () => { // Add custom column button
if (preset === 'Custom...') { const customButton = document.createElement('button');
const customName = prompt('Enter custom property name:', currentHeader); customButton.type = 'button';
if (customName) { customButton.className = 'btn btn-sm btn-outline-secondary';
this.setPropertyType(colIndex, customName, null); customButton.innerHTML = `<i class="fas fa-pencil-alt"></i> Custom`;
} customButton.style.fontSize = '12px';
} else {
this.setPropertyType(colIndex, preset, this.propertyPresets[preset]); customButton.addEventListener('click', () => {
} this.addCustomColumn();
document.body.removeChild(menu);
}); });
menu.appendChild(option); toolbar.appendChild(customButton);
});
document.body.appendChild(menu); this.container.appendChild(toolbar);
// 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 * Add a preset column to the spreadsheet
*/ */
setPropertyType(colIndex, propertyName, presetValues) { 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 // Update column header
const headers = this.hot.getColHeaders();
headers[currentColCount] = presetName;
this.hot.updateSettings({ this.hot.updateSettings({
colHeaders: this.hot.getSettings().colHeaders.map((header, index) => { colHeaders: headers
return index === colIndex ? propertyName : header;
})
}); });
// If preset has values, populate the column // Populate with preset values
if (presetValues && Array.isArray(presetValues)) { if (presetValues && Array.isArray(presetValues)) {
const data = this.hot.getData();
presetValues.forEach((value, rowIndex) => { presetValues.forEach((value, rowIndex) => {
if (rowIndex < data.length) { if (rowIndex < this.hot.countRows()) {
this.hot.setDataAtCell(rowIndex, colIndex, value); this.hot.setDataAtCell(rowIndex, currentColCount, value);
} }
}); });
} }
} }
/**
* 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
const headers = this.hot.getColHeaders();
headers[currentColCount] = customName.trim();
this.hot.updateSettings({
colHeaders: headers
});
}
/**
* Show rename dialog for column
*/
showRenameDialog(colIndex) {
const currentHeader = this.hot.getColHeader(colIndex);
const newName = prompt('Rename column:', currentHeader);
if (newName && newName.trim() !== '') {
const headers = this.hot.getColHeaders();
headers[colIndex] = newName.trim();
this.hot.updateSettings({
colHeaders: headers
});
}
}
/** /**
* Setup keyboard shortcuts * Setup keyboard shortcuts
*/ */
setupKeyboardShortcuts() { setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => { 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 // Ctrl+D or Cmd+D: Delete selected rows/columns
if ((e.ctrlKey || e.metaKey) && e.key === 'd') { if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
e.preventDefault(); e.preventDefault();
@ -314,20 +367,24 @@ class VariantEditor {
// Process each column // Process each column
for (let col = 0; col < headers.length; col++) { for (let col = 0; col < headers.length; col++) {
const propertyName = headers[col]; const propertyName = headers[col];
if (!propertyName || propertyName.trim() === '') continue;
// Skip empty column names
if (!propertyName || propertyName.trim() === '' || propertyName.startsWith('Property ')) {
continue;
}
// Collect non-null values from this column // Collect non-null values from this column
const values = []; const values = [];
for (let row = 0; row < data.length; row++) { for (let row = 0; row < data.length; row++) {
const cellValue = data[row][col]; const cellValue = data[row][col];
if (cellValue !== null && cellValue !== undefined && cellValue !== '') { if (cellValue !== null && cellValue !== undefined && cellValue !== '') {
values.push(cellValue); values.push(String(cellValue).trim());
} }
} }
// Create property object // Create property object
const property = { const property = {
name: propertyName, name: propertyName.trim(),
values: values.length > 0 ? values : null // null means freeform values: values.length > 0 ? values : null // null means freeform
}; };
@ -336,7 +393,7 @@ class VariantEditor {
const jsonString = JSON.stringify(properties, null, 2); const jsonString = JSON.stringify(properties, null, 2);
// Update hidden input or textarea // Update hidden input
const hiddenInput = document.getElementById('PropertiesJson'); const hiddenInput = document.getElementById('PropertiesJson');
if (hiddenInput) { if (hiddenInput) {
hiddenInput.value = jsonString; hiddenInput.value = jsonString;