diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index 0db41494..8833b78a 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -335,6 +335,15 @@ $_lang['delete_field_confirm_message'] = 'Are you sure you want to delete field "{name}"?'; $_lang['field_deleted'] = 'Field deleted successfully'; $_lang['error_deleting_field'] = 'Error deleting field'; +$_lang['field_editable'] = 'Editable field'; +$_lang['editor_type'] = 'Editor type'; +$_lang['editor_type_text'] = 'Text'; +$_lang['editor_type_number'] = 'Number'; +$_lang['editor_type_select'] = 'Dropdown'; +$_lang['editor_options'] = 'Options for selection'; +$_lang['inline_edit_saved'] = 'Changes saved'; +$_lang['inline_edit_error'] = 'Save error'; +$_lang['inline_edit_hint'] = 'To enable inline editing in the category products table (double-click a cell), turn on «Editable field» in the column row below or in the column edit dialog.'; // Grid Fields Config - Add Field Dialog $_lang['add_field'] = 'Add Field'; diff --git a/core/components/minishop3/lexicon/ru/vue.inc.php b/core/components/minishop3/lexicon/ru/vue.inc.php index 2893e37e..994abcf4 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -295,6 +295,15 @@ $_lang['delete_field_confirm_message'] = 'Вы уверены, что хотите удалить поле "{name}"?'; $_lang['field_deleted'] = 'Поле успешно удалено'; $_lang['error_deleting_field'] = 'Ошибка при удалении поля'; +$_lang['field_editable'] = 'Редактируемое поле'; +$_lang['editor_type'] = 'Тип редактора'; +$_lang['editor_type_text'] = 'Текст'; +$_lang['editor_type_number'] = 'Число'; +$_lang['editor_type_select'] = 'Выпадающий список'; +$_lang['editor_options'] = 'Опции для выбора'; +$_lang['inline_edit_saved'] = 'Изменения сохранены'; +$_lang['inline_edit_error'] = 'Ошибка сохранения'; +$_lang['inline_edit_hint'] = 'Для быстрого редактирования в таблице товаров категории (двойной клик по ячейке) включите «Редактируемое поле» в колонке таблицы ниже или в диалоге редактирования колонки.'; // Customers Grid Widget $_lang['customers_title'] = 'Клиенты'; diff --git a/core/components/minishop3/src/Services/GridConfigService.php b/core/components/minishop3/src/Services/GridConfigService.php index c403b5b4..3197ef17 100644 --- a/core/components/minishop3/src/Services/GridConfigService.php +++ b/core/components/minishop3/src/Services/GridConfigService.php @@ -184,7 +184,9 @@ public function saveGridConfig(string $gridKey, array $fields): bool // price type 'decimals', 'currency', 'currency_position', 'thousands_separator', 'decimal_separator', // weight type - 'unit', 'unit_position' + 'unit', 'unit_position', + // inline edit (category-products). Add 'editor_options' when select editor is implemented in UI + 'editable', 'editor_type', ]; foreach ($configKeys as $key) { if (array_key_exists($key, $fieldData)) { diff --git a/core/components/minishop3/src/Services/Product/ProductDataService.php b/core/components/minishop3/src/Services/Product/ProductDataService.php index bcec0fab..d4f1d244 100644 --- a/core/components/minishop3/src/Services/Product/ProductDataService.php +++ b/core/components/minishop3/src/Services/Product/ProductDataService.php @@ -483,10 +483,73 @@ public function getProductData(int $productId): ?array } /** - * Update product data + * Allowed fields for inline / API update (msProductData) + */ + protected static array $allowedUpdateFields = [ + 'article', 'price', 'old_price', 'stock', 'weight', + 'vendor_id', 'made_in', 'new', 'popular', 'favorite', + ]; + + /** + * Resource (modResource) fields updatable via same API (e.g. published) + */ + protected static array $allowedResourceFields = ['published']; + + /** + * Apply published state to product resource and save. + * Invokes OnDocPublished / OnDocUnPublished for plugin compatibility. + * + * @param msProduct $product + * @param int $published 0 or 1 + * @return bool True if saved successfully + */ + protected function applyPublishedToResource(msProduct $product, int $published): bool + { + $product->set('published', $published); + if ($published) { + $product->set('publishedon', time()); + $product->set('publishedby', $this->modx->user->get('id')); + } else { + $product->set('publishedon', 0); + $product->set('publishedby', 0); + } + if (!$product->save()) { + return false; + } + $eventName = $published ? 'OnDocPublished' : 'OnDocUnPublished'; + $this->modx->invokeEvent($eventName, [ + 'id' => $product->get('id'), + 'resource' => $product, + ]); + return true; + } + + /** + * Validate productData update values (minimal server-side validation). * - * Loads product by ID, updates msProductData fields and saves - * Used in API controllers to update product data + * @param array $filtered Filtered allowed fields + * @return bool True if valid + */ + protected function validateProductDataUpdate(array $filtered): bool + { + if (isset($filtered['price']) && (float)$filtered['price'] < 0) { + return false; + } + if (isset($filtered['old_price']) && (float)$filtered['old_price'] < 0) { + return false; + } + if (isset($filtered['stock']) && (int)$filtered['stock'] != $filtered['stock']) { + return false; + } + if (isset($filtered['weight']) && (float)$filtered['weight'] < 0) { + return false; + } + return true; + } + + /** + * Update product data (msProductData and optionally resource fields like published). + * Saves productData first, then resource (published) to avoid race and desync on failure. * * @param int $productId Product ID * @param array $data Data to update @@ -494,26 +557,44 @@ public function getProductData(int $productId): ?array */ public function updateProductData(int $productId, array $data): ?array { + if (!$this->modx->hasPermission('save_document')) { + return null; + } + /** @var msProduct $product */ $product = $this->modx->getObject(msProduct::class, $productId); - - if (!$product) { + if (!$product || !$product->checkPolicy('save')) { return null; } /** @var msProductData $productData */ $productData = $product->loadData(); - if (!$productData) { return null; } - $productData->fromArray($data); + $filtered = array_intersect_key($data, array_flip(self::$allowedUpdateFields)); + if (!$this->validateProductDataUpdate($filtered)) { + return null; + } + + $productData->fromArray($filtered); + if (!$productData->save()) { + return null; + } - if ($productData->save()) { - return $productData->toArray(); + $resourceData = array_intersect_key($data, array_flip(self::$allowedResourceFields)); + if (isset($resourceData['published'])) { + $published = $resourceData['published'] ? 1 : 0; + if (!$this->applyPublishedToResource($product, $published)) { + return null; + } } - return null; + $result = $productData->toArray(); + if (isset($resourceData['published'])) { + $result['published'] = (bool)$product->get('published'); + } + return $result; } } diff --git a/vueManager/src/components/CategoryProductsGrid.vue b/vueManager/src/components/CategoryProductsGrid.vue index f17472e8..e76139a2 100644 --- a/vueManager/src/components/CategoryProductsGrid.vue +++ b/vueManager/src/components/CategoryProductsGrid.vue @@ -4,13 +4,14 @@ import Button from 'primevue/button' import Card from 'primevue/card' import Checkbox from 'primevue/checkbox' import ConfirmDialog from 'primevue/confirmdialog' +import InputNumber from 'primevue/inputnumber' import InputText from 'primevue/inputtext' import Select from 'primevue/select' import Tag from 'primevue/tag' import Toast from 'primevue/toast' import { useConfirm } from 'primevue/useconfirm' import { useToast } from 'primevue/usetoast' -import { computed, defineProps, onMounted, ref, watch } from 'vue' +import { computed, defineProps, nextTick, onMounted, ref, watch } from 'vue' import draggable from 'vuedraggable' import { useSelection } from '../composables/useSelection.js' @@ -65,6 +66,15 @@ const sortField = ref('menuindex') const sortOrder = ref(1) const selectAll = ref(false) +/** Inline edit: { productId, columnName } when a cell is being edited */ +const editingCell = ref(null) +/** Current value in the inline edit input */ +const inlineEditValue = ref('') +/** True while inline edit save request is in progress */ +const inlineEditSaving = ref(false) +/** Ref to the current inline-edit input (one of Checkbox/InputText/InputNumber) for focus */ +const inlineEditInputRef = ref(null) + // Default thumbnail from system settings const defaultThumb = @@ -343,6 +353,106 @@ function renderField(data, column) { return data[column.name] ?? '' } +/** + * Check if a cell is in edit mode + */ +function isEditingCell(product, column) { + return ( + editingCell.value && + editingCell.value.productId === product.id && + editingCell.value.columnName === column.name + ) +} + +/** + * Start inline edit on double-click + */ +function startInlineEdit(product, column) { + if (!column.editable) return + editingCell.value = { productId: product.id, columnName: column.name } + const raw = product[column.name] + inlineEditValue.value = raw === null || raw === undefined ? '' : raw + // autofocus doesn't work on dynamically inserted elements; focus via ref after DOM update + nextTick(() => { + const comp = inlineEditInputRef.value + if (!comp) return + const el = comp.$el?.querySelector?.('input') ?? comp.$el ?? comp + if (el?.focus) el.focus() + }) +} + +function isBooleanColumn(column) { + return column.type === 'boolean' || column.editor_type === 'boolean' +} + +function normalizeValueForSave(rawValue, column) { + if (isBooleanColumn(column)) return rawValue ? 1 : 0 + const editorType = column.editor_type || 'text' + if (editorType === 'number') { + if (rawValue === '' || rawValue === null) return null + const num = Number(rawValue) + return Number.isNaN(num) ? null : num + } + return rawValue +} + +function isInlineValueUnchanged(original, value, column) { + if (isBooleanColumn(column)) { + return (original ? 1 : 0) === value + } + const editorType = column.editor_type || 'text' + if (editorType === 'number') { + const norm = (v) => + v === null || v === undefined || v === '' || Number.isNaN(Number(v)) ? null : Number(v) + return norm(original) === norm(value) + } + const origStr = original === null || original === undefined ? '' : String(original) + const valStr = value === null || value === undefined ? '' : String(value) + return origStr === valStr +} + +function clearInlineEditState() { + editingCell.value = null + inlineEditValue.value = '' +} + +/** + * Save inline edit (blur or Enter). No API call or toast if value unchanged. + * Uses isSaving flag to prevent double invocation (Enter triggers blur). + */ +async function saveInlineEdit(product, column) { + if (!editingCell.value || editingCell.value.productId !== product.id || editingCell.value.columnName !== column.name) { + return + } + if (inlineEditSaving.value) return + const value = normalizeValueForSave(inlineEditValue.value, column) + if (isInlineValueUnchanged(product[column.name], value, column)) { + clearInlineEditState() + return + } + inlineEditSaving.value = true + try { + const res = await request.put(`/api/mgr/product-data/${product.id}`, { [column.name]: value }) + if (res?.data) Object.assign(product, res.data) + else product[column.name] = value + toast.add({ severity: 'success', summary: _('success'), detail: _('inline_edit_saved'), life: 2000 }) + } catch (error) { + console.error('[CategoryProductsGrid] Inline edit save failed:', error) + toast.add({ severity: 'error', summary: _('error'), detail: error.message || _('inline_edit_error'), life: 5000 }) + return + } finally { + inlineEditSaving.value = false + } + clearInlineEditState() +} + +/** + * Cancel inline edit (Escape) + */ +function cancelInlineEdit() { + clearInlineEditState() +} + /** * Load filters configuration */ @@ -916,6 +1026,45 @@ onMounted(async () => { /> + + + + + + + { - + { - + {{ formatPrice(product[column.name]) }} - + {{ formatWeight(product[column.name]) }} @@ -947,6 +1111,8 @@ onMounted(async () => {
@@ -956,7 +1122,12 @@ onMounted(async () => { - + {{ product[column.name] }} @@ -1018,6 +1189,23 @@ onMounted(async () => { padding: 0.625rem; } +.editable-cell { + cursor: text; +} + +.inline-edit-cell :deep(input) { + width: 100%; + min-width: 0; +} + +.inline-edit-saving { + opacity: 0.8; +} + +.inline-edit-saving :deep(input) { + cursor: wait; +} + .grid-header { display: flex; justify-content: space-between; diff --git a/vueManager/src/components/GridFieldsConfig.vue b/vueManager/src/components/GridFieldsConfig.vue index 2e1b24cb..2c759e68 100644 --- a/vueManager/src/components/GridFieldsConfig.vue +++ b/vueManager/src/components/GridFieldsConfig.vue @@ -49,11 +49,12 @@ const newField = ref({ }, actions: [], displayConfig: '', - // Badge config badge: { source_field: '', color_field: '', }, + editable: false, + editor_type: 'text', }, }) @@ -94,6 +95,19 @@ const fieldTypeOptions = computed(() => [ */ const displayConfigTypes = ['datetime', 'price', 'weight'] +/** + * Inline edit: only for category-products grid + */ +const isCategoryProductsGrid = computed(() => selectedGrid.value === 'category-products') + +/** + * Editor type options for editable columns (text, number; select later) + */ +const editorTypeOptions = computed(() => [ + { label: _('editor_type_text'), value: 'text' }, + { label: _('editor_type_number'), value: 'number' }, +]) + /** * Get config hint for display type */ @@ -175,6 +189,8 @@ async function loadFields() { decimal_separator: col.decimal_separator || '', unit: col.unit || '', unit_position: col.unit_position || '', + editable: col.editable === true, + editor_type: col.editor_type || '', })) } else { console.error('[GridFieldsConfig] Invalid response:', response) @@ -232,6 +248,11 @@ async function saveConfig() { if (field.unit) data.unit = field.unit if (field.unit_position) data.unit_position = field.unit_position + if (selectedGrid.value === 'category-products') { + data.editable = field.editable === true + if (field.editor_type) data.editor_type = field.editor_type + } + return data }) @@ -370,6 +391,8 @@ function openAddDialog() { source_field: '', color_field: '', }, + editable: false, + editor_type: 'text', }, } showAddDialog.value = true @@ -457,6 +480,11 @@ async function addField() { break } + if (selectedGrid.value === 'category-products') { + data.config.editable = newField.value.config.editable === true + data.config.editor_type = newField.value.config.editor_type || 'text' + } + const result = await request.post(`/api/mgr/grid-config/${selectedGrid.value}/field`, data) if (result.field) { @@ -499,6 +527,8 @@ async function addField() { decimal_separator: config.decimal_separator || '', unit: config.unit || '', unit_position: config.unit_position || '', + editable: config.editable === true, + editor_type: config.editor_type || '', }) } @@ -599,6 +629,8 @@ function openEditDialog(field, index) { ], displayConfig: displayConfig, badge: badgeConfig, + editable: field.editable === true, + editor_type: field.editor_type || 'text', }, } @@ -686,6 +718,11 @@ async function saveEdit() { break } + if (selectedGrid.value === 'category-products') { + data.config.editable = editingField.value.config.editable === true + data.config.editor_type = editingField.value.config.editor_type || 'text' + } + const result = await request.put( `/api/mgr/grid-config/${selectedGrid.value}/field/${editingField.value.field_name}`, data @@ -731,6 +768,8 @@ async function saveEdit() { decimal_separator: config.decimal_separator || '', unit: config.unit || '', unit_position: config.unit_position || '', + editable: config.editable === true, + editor_type: config.editor_type || '', } } @@ -781,6 +820,10 @@ onMounted(() => {
+

+ {{ _('inline_edit_hint') }} +

+