From e54131b732537b070d4698103b06627e62d07964 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Mon, 16 Mar 2026 16:40:38 +0600 Subject: [PATCH] feat(grid): add select/combo editor types for category products inline edit - Add editor_type: select with editor_options for static options (e.g. made_in) - Add editor_type: combo with editor_combo_endpoint for API-loaded options (e.g. vendor_id) - Validate editor_combo_endpoint when combo type is selected - Add showClear for combo Select to allow clearing nullable fields - Toast notification on combo options load failure - Lexicons: editor_type_select, editor_type_combo, editor_options, editor_combo_endpoint, editor_combo_endpoint_required, combo_options_load_failed Refs #155 --- .../minishop3/lexicon/en/vue.inc.php | 8 + .../minishop3/lexicon/ru/vue.inc.php | 8 + .../src/Services/GridConfigService.php | 4 +- .../src/components/CategoryProductsGrid.vue | 96 +++++++++++- .../src/components/GridFieldsConfig.vue | 137 +++++++++++++++++- 5 files changed, 245 insertions(+), 8 deletions(-) diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index 80468f0..674c1a4 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -339,6 +339,14 @@ $_lang['editor_type'] = 'Editor type'; $_lang['editor_type_text'] = 'Text'; $_lang['editor_type_number'] = 'Number'; +$_lang['editor_type_select'] = 'Select'; +$_lang['editor_type_combo'] = 'Combo (API)'; +$_lang['editor_options'] = 'Editor options'; +$_lang['editor_options_hint'] = 'JSON array: [{ "label": "Russia", "value": "RU" }, ...]'; +$_lang['editor_combo_endpoint'] = 'API endpoint for options'; +$_lang['editor_combo_endpoint_placeholder'] = '/api/mgr/references/vendors'; +$_lang['editor_combo_endpoint_required'] = 'API endpoint is required for combo editor type'; +$_lang['combo_options_load_failed'] = 'Failed to load combo options'; $_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.'; diff --git a/core/components/minishop3/lexicon/ru/vue.inc.php b/core/components/minishop3/lexicon/ru/vue.inc.php index 11e8380..9061a4c 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -299,6 +299,14 @@ $_lang['editor_type'] = 'Тип редактора'; $_lang['editor_type_text'] = 'Текст'; $_lang['editor_type_number'] = 'Число'; +$_lang['editor_type_select'] = 'Выпадающий список'; +$_lang['editor_type_combo'] = 'Комбо (API)'; +$_lang['editor_options'] = 'Опции редактора'; +$_lang['editor_options_hint'] = 'JSON-массив: [{ "label": "Россия", "value": "RU" }, ...]'; +$_lang['editor_combo_endpoint'] = 'API endpoint для опций'; +$_lang['editor_combo_endpoint_placeholder'] = '/api/mgr/references/vendors'; +$_lang['editor_combo_endpoint_required'] = 'Для типа редактора «Комбо» обязателен API endpoint'; +$_lang['combo_options_load_failed'] = 'Не удалось загрузить опции комбо'; $_lang['inline_edit_saved'] = 'Изменения сохранены'; $_lang['inline_edit_error'] = 'Ошибка сохранения'; $_lang['inline_edit_hint'] = 'Для быстрого редактирования в таблице товаров категории (двойной клик по ячейке) включите «Редактируемое поле» в колонке таблицы ниже или в диалоге редактирования колонки.'; diff --git a/core/components/minishop3/src/Services/GridConfigService.php b/core/components/minishop3/src/Services/GridConfigService.php index 8fa1413..590eb1b 100644 --- a/core/components/minishop3/src/Services/GridConfigService.php +++ b/core/components/minishop3/src/Services/GridConfigService.php @@ -185,8 +185,8 @@ public function saveGridConfig(string $gridKey, array $fields): bool 'decimals', 'currency', 'currency_position', 'thousands_separator', 'decimal_separator', // weight type 'unit', 'unit_position', - // inline edit (category-products). Add 'editor_options' when select editor is implemented in UI - 'editable', 'editor_type', + // inline edit (category-products) + 'editable', 'editor_type', 'editor_options', 'editor_combo_endpoint', ]; foreach ($configKeys as $key) { if (array_key_exists($key, $fieldData)) { diff --git a/vueManager/src/components/CategoryProductsGrid.vue b/vueManager/src/components/CategoryProductsGrid.vue index 2e2b213..91158e9 100644 --- a/vueManager/src/components/CategoryProductsGrid.vue +++ b/vueManager/src/components/CategoryProductsGrid.vue @@ -72,8 +72,10 @@ const editingCell = ref(null) 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 */ +/** Ref to the current inline-edit input (one of Checkbox/InputText/InputNumber/Select) for focus */ const inlineEditInputRef = ref(null) +/** Options for combo editor (loaded from API when cell is opened) */ +const comboOptionsRef = ref([]) // Default thumbnail from system settings @@ -367,23 +369,26 @@ function isEditingCell(product, column) { /** * Start inline edit on double-click. * Blocks if another cell is currently saving to avoid race condition. + * For combo editor, loads options from API before focusing. */ -function startInlineEdit(product, column) { +async function startInlineEdit(product, column) { if (!column.editable) return if (inlineEditSaving.value) 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 + if ((column.editor_type || 'text') === 'combo') { + await loadComboOptions(column) + } nextTick(() => { const comp = inlineEditInputRef.value if (!comp) return - const el = comp.$el?.querySelector?.('input') ?? comp.$el ?? comp + const el = comp.$el?.querySelector?.('input') ?? comp.$el?.querySelector?.('.p-dropdown') ?? comp.$el ?? comp if (el?.focus) el.focus() }) } -/** Boolean columns (e.g. published) use type, not editor_type (select not in UI yet) */ +/** Boolean columns (e.g. published) use type, not editor_type */ function isBooleanColumn(column) { return column.type === 'boolean' } @@ -396,6 +401,9 @@ function normalizeValueForSave(rawValue, column) { const num = Number(rawValue) return Number.isNaN(num) ? null : num } + if (editorType === 'select' || editorType === 'combo') { + return rawValue + } return rawValue } @@ -409,6 +417,11 @@ function isInlineValueUnchanged(original, value, column) { v === null || v === undefined || v === '' || Number.isNaN(Number(v)) ? null : Number(v) return norm(original) === norm(value) } + if (editorType === 'select' || editorType === 'combo') { + const o = original === null || original === undefined ? null : original + const v = value === null || value === undefined ? null : value + return o === v || String(o) === String(v) + } const origStr = original === null || original === undefined ? '' : String(original) const valStr = value === null || value === undefined ? '' : String(value) return origStr === valStr @@ -417,6 +430,44 @@ function isInlineValueUnchanged(original, value, column) { function clearInlineEditState() { editingCell.value = null inlineEditValue.value = '' + comboOptionsRef.value = [] +} + +/** + * Normalize combo API response to [{ id, label }]. Supports vendors: [{id, name}], options: [{id, label}], data: [{id, name}]. + */ +function normalizeComboResponse(response) { + if (!response || typeof response !== 'object') return [] + const list = response.vendors ?? response.options ?? response.data ?? [] + if (!Array.isArray(list)) return [] + return list.map((item) => ({ + id: item.id ?? item.value, + label: item.name ?? item.label ?? String(item.id ?? item.value ?? ''), + })) +} + +/** + * Load combo options from column's editor_combo_endpoint + */ +async function loadComboOptions(column) { + const endpoint = column.editor_combo_endpoint + if (!endpoint || typeof endpoint !== 'string') { + comboOptionsRef.value = [] + return + } + try { + const response = await request.get(endpoint) + comboOptionsRef.value = normalizeComboResponse(response) + } catch (err) { + console.error('[CategoryProductsGrid] Combo options load failed:', err) + comboOptionsRef.value = [] + toast.add({ + severity: 'warn', + summary: _('error'), + detail: _('combo_options_load_failed') || err.message, + life: 3000, + }) + } } /** @@ -1060,6 +1111,41 @@ onMounted(async () => { @keydown.enter.prevent="$event.target.blur()" @keydown.escape="cancelInlineEdit" /> +
+ +
selectedGrid.value === 'category-products') /** - * Editor type options for editable columns (text, number; select later) + * Parse editor_options from array or JSON string. Returns array or null if invalid. + */ +function parseEditorOptions(value) { + if (Array.isArray(value)) return value + if (typeof value !== 'string' || !value.trim()) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : null + } catch { + return null + } +} + +/** + * Serialize editor_options for textarea display + */ +function editorOptionsToJson(editorOptions) { + const arr = Array.isArray(editorOptions) ? editorOptions : [] + try { + return JSON.stringify(arr, null, 2) + } catch { + return '[]' + } +} + +/** + * Editor type options for editable columns (text, number, select, combo) */ const editorTypeOptions = computed(() => [ { label: _('editor_type_text'), value: 'text' }, { label: _('editor_type_number'), value: 'number' }, + { label: _('editor_type_select'), value: 'select' }, + { label: _('editor_type_combo'), value: 'combo' }, ]) /** @@ -191,6 +224,8 @@ async function loadFields() { unit_position: col.unit_position || '', editable: col.editable === true, editor_type: col.editor_type || '', + editor_options: Array.isArray(col.editor_options) ? col.editor_options : [], + editor_combo_endpoint: col.editor_combo_endpoint || '', })) } else { console.error('[GridFieldsConfig] Invalid response:', response) @@ -251,6 +286,8 @@ async function saveConfig() { if (selectedGrid.value === 'category-products') { data.editable = field.editable === true if (field.editor_type) data.editor_type = field.editor_type + if (Array.isArray(field.editor_options)) data.editor_options = field.editor_options + if (field.editor_combo_endpoint) data.editor_combo_endpoint = field.editor_combo_endpoint } return data @@ -393,8 +430,11 @@ function openAddDialog() { }, editable: false, editor_type: 'text', + editor_options: [], + editor_combo_endpoint: '', }, } + newFieldEditorOptionsJson.value = editorOptionsToJson(newField.value.config.editor_options) showAddDialog.value = true } @@ -483,6 +523,31 @@ async function addField() { if (selectedGrid.value === 'category-products') { data.config.editable = newField.value.config.editable === true data.config.editor_type = newField.value.config.editor_type || 'text' + if (newField.value.config.editor_type === 'select') { + const opts = parseEditorOptions(newFieldEditorOptionsJson.value) + if (opts === null) { + toast.add({ + severity: 'error', + summary: _('error'), + detail: _('invalid_json_config'), + life: 5000, + }) + return + } + data.config.editor_options = opts + } else if (newField.value.config.editor_type === 'combo') { + const endpoint = (newField.value.config.editor_combo_endpoint || '').trim() + if (!endpoint) { + toast.add({ + severity: 'error', + summary: _('error'), + detail: _('editor_combo_endpoint_required'), + life: 5000, + }) + return + } + data.config.editor_combo_endpoint = endpoint + } } const result = await request.post(`/api/mgr/grid-config/${selectedGrid.value}/field`, data) @@ -529,6 +594,8 @@ async function addField() { unit_position: config.unit_position || '', editable: config.editable === true, editor_type: config.editor_type || '', + editor_options: Array.isArray(config.editor_options) ? config.editor_options : [], + editor_combo_endpoint: config.editor_combo_endpoint || '', }) } @@ -631,9 +698,12 @@ function openEditDialog(field, index) { badge: badgeConfig, editable: field.editable === true, editor_type: field.editor_type || 'text', + editor_options: Array.isArray(field.editor_options) ? field.editor_options : [], + editor_combo_endpoint: field.editor_combo_endpoint || '', }, } + editingFieldEditorOptionsJson.value = editorOptionsToJson(editingField.value.config.editor_options) showEditDialog.value = true } @@ -721,6 +791,31 @@ async function saveEdit() { if (selectedGrid.value === 'category-products') { data.config.editable = editingField.value.config.editable === true data.config.editor_type = editingField.value.config.editor_type || 'text' + if (editingField.value.config.editor_type === 'select') { + const opts = parseEditorOptions(editingFieldEditorOptionsJson.value) + if (opts === null) { + toast.add({ + severity: 'error', + summary: _('error'), + detail: _('invalid_json_config'), + life: 5000, + }) + return + } + data.config.editor_options = opts + } else if (editingField.value.config.editor_type === 'combo') { + const endpoint = (editingField.value.config.editor_combo_endpoint || '').trim() + if (!endpoint) { + toast.add({ + severity: 'error', + summary: _('error'), + detail: _('editor_combo_endpoint_required'), + life: 5000, + }) + return + } + data.config.editor_combo_endpoint = endpoint + } } const result = await request.put( @@ -770,6 +865,8 @@ async function saveEdit() { unit_position: config.unit_position || '', editable: config.editable === true, editor_type: config.editor_type || '', + editor_options: Array.isArray(config.editor_options) ? config.editor_options : [], + editor_combo_endpoint: config.editor_combo_endpoint || '', } } @@ -1107,6 +1204,25 @@ onMounted(() => { option-value="value" class="w-full mt-1" /> +
+ +