diff --git a/CHANGELOG.md b/CHANGELOG.md index 9105724..03e5ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ #### ♻️ Рефакторинг +- **Inline-edit select/combo в гриде товаров категории (#155, #157):** единый контракт опций `{ value, label }` на фронте; `GET references/vendors` дополняет ответ массивом `options` (поле `vendors` сохранено для совместимости); `GridEditorReferenceRegistry` и валидация combo при сохранении конфига; в конфиге колонок — `editor_reference` и опциональный allowlisted `editor_combo_endpoint`; `GridFieldsConfig` — выбор справочника и override URL; composable `useCategoryProductsInlineEdit` и утилиты `gridEditorOptions.js` вместо логики внутри `CategoryProductsGrid` - **Экран заказа — provide/inject вместо props-цепочки (#196):** `provide(ORDER_CONTEXT_KEY)` в `OrderView`, composables `useOrderFormatters`, `useOrderFieldHelpers`, `useOrderLogFormatters`; вкладки получают только данные вкладки через props; безопасный `inject` до деструктуризации - **OrderView разбит на подкомпоненты (#176):** монолитный `OrderView.vue` разделён на `OrderInfoTab`, `OrderProductsTab`, `OrderAddressTab`, `OrderHistoryTab` + вынесен `orderFieldsLayout.css` - **Опции товара:** Map по `modcategory_id` для вкладок, именованный page size комбобокса под `ms3.grid`, документирован GROUP BY diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index 59131aa..eb06cda 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -339,6 +339,21 @@ $_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['editor_reference'] = 'Reference source'; +$_lang['editor_reference_none'] = '— not selected —'; +$_lang['editor_reference_hint'] = 'Built-in list (e.g. vendors). Optional if you use an allowlisted override URL below.'; +$_lang['editor_combo_endpoint_override'] = 'Override URL (optional)'; +$_lang['editor_combo_endpoint_override_hint'] = 'Only allowlisted paths (e.g. under /api/mgr/references/). If set, it overrides the reference URL when loading options.'; +$_lang['editor_combo_ref_or_endpoint_required'] = 'For combo editor, choose a reference or enter an allowlisted API URL.'; +$_lang['editor_combo_endpoint_not_allowlisted'] = 'Combo override URL is not on the allowlist.'; +$_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 a9df0b0..bdd0866 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -299,6 +299,21 @@ $_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['editor_reference'] = 'Источник справочника'; +$_lang['editor_reference_none'] = '— не выбрано —'; +$_lang['editor_reference_hint'] = 'Встроенный список (например vendors). Необязателен, если ниже указан допустимый URL переопределения.'; +$_lang['editor_combo_endpoint_override'] = 'URL переопределения (необязательно)'; +$_lang['editor_combo_endpoint_override_hint'] = 'Только пути из белого списка (например под /api/mgr/references/). Если задан, при загрузке опций заменяет URL из справочника.'; +$_lang['editor_combo_ref_or_endpoint_required'] = 'Для комбо выберите справочник или укажите допустимый API URL.'; +$_lang['editor_combo_endpoint_not_allowlisted'] = 'URL переопределения комбо не входит в белый список.'; +$_lang['combo_options_load_failed'] = 'Не удалось загрузить опции комбо'; $_lang['inline_edit_saved'] = 'Изменения сохранены'; $_lang['inline_edit_error'] = 'Ошибка сохранения'; $_lang['inline_edit_hint'] = 'Для быстрого редактирования в таблице товаров категории (двойной клик по ячейке) включите «Редактируемое поле» в колонке таблицы ниже или в диалоге редактирования колонки.'; diff --git a/core/components/minishop3/src/Controllers/Api/Manager/GridConfigController.php b/core/components/minishop3/src/Controllers/Api/Manager/GridConfigController.php index d241d42..6ea8cae 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/GridConfigController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/GridConfigController.php @@ -2,8 +2,9 @@ namespace MiniShop3\Controllers\Api\Manager; -use MiniShop3\Services\GridConfigService; use MiniShop3\Router\Response; +use MiniShop3\Services\GridConfigService; +use MiniShop3\Services\GridEditorReferenceRegistry; use MODX\Revolution\modX; /** @@ -48,7 +49,12 @@ public function getConfig(array $params): array $includeHidden = !empty($params['include_hidden']); $config = $this->service->getGridConfig($gridKey, $includeHidden); - return Response::success(['columns' => $config])->getData(); + $payload = ['columns' => $config]; + if ($gridKey === 'category-products') { + $payload['editor_references'] = GridEditorReferenceRegistry::listForClient(); + } + + return Response::success($payload)->getData(); } /** diff --git a/core/components/minishop3/src/Controllers/Api/ReferencesController.php b/core/components/minishop3/src/Controllers/Api/ReferencesController.php index 2c9368f..7453a81 100644 --- a/core/components/minishop3/src/Controllers/Api/ReferencesController.php +++ b/core/components/minishop3/src/Controllers/Api/ReferencesController.php @@ -43,9 +43,18 @@ public function getVendors(array $params): Response ]; } + $options = []; + foreach ($vendors as $row) { + $options[] = [ + 'value' => $row['id'], + 'label' => (string)$row['name'], + ]; + } + return Response::success([ 'vendors' => $vendors, - 'total' => count($vendors) + 'options' => $options, + 'total' => count($vendors), ]); } catch (\Exception $e) { $this->modx->log(\MODX\Revolution\modX::LOG_LEVEL_ERROR, '[ReferencesController] ' . $e->getMessage()); diff --git a/core/components/minishop3/src/Services/GridColumnEditorType.php b/core/components/minishop3/src/Services/GridColumnEditorType.php new file mode 100644 index 0000000..23f0971 --- /dev/null +++ b/core/components/minishop3/src/Services/GridColumnEditorType.php @@ -0,0 +1,19 @@ +modx->log(modX::LOG_LEVEL_ERROR, + '[GridConfigService] Combo editor validation failed for ' . $gridKey . '.' . $fieldName . ': ' . ($comboCheck['message'] ?? '')); + + return false; + } + $field->set('config', json_encode($config, JSON_UNESCAPED_UNICODE)); if (!$field->save()) { @@ -354,6 +366,15 @@ public function addField(string $gridKey, array $data): array // Add type to config $config['type'] = $type; + if (($config['editor_type'] ?? '') !== GridColumnEditorType::COMBO) { + unset($config['editor_reference'], $config['editor_combo_endpoint']); + } + + $comboCheck = GridEditorReferenceRegistry::validateComboEditorConfig($config); + if (!$comboCheck['success']) { + return ['success' => false, 'message' => $comboCheck['message'] ?? 'Invalid combo editor configuration']; + } + // Get maximum sort_order $maxSortOrder = 0; $query = $this->modx->newQuery(msGridField::class); @@ -471,6 +492,15 @@ public function updateField(string $gridKey, string $fieldName, array $data): ar // Add type to config $config['type'] = $type; + if (($config['editor_type'] ?? '') !== GridColumnEditorType::COMBO) { + unset($config['editor_reference'], $config['editor_combo_endpoint']); + } + + $comboCheck = GridEditorReferenceRegistry::validateComboEditorConfig($config); + if (!$comboCheck['success']) { + return ['success' => false, 'message' => $comboCheck['message'] ?? 'Invalid combo editor configuration']; + } + // Update field if (isset($data['label'])) { $field->set('label', $data['label']); diff --git a/core/components/minishop3/src/Services/GridEditorReferenceRegistry.php b/core/components/minishop3/src/Services/GridEditorReferenceRegistry.php new file mode 100644 index 0000000..1ebc6e8 --- /dev/null +++ b/core/components/minishop3/src/Services/GridEditorReferenceRegistry.php @@ -0,0 +1,89 @@ + */ + private const REFERENCES = [ + 'vendors' => '/api/mgr/references/vendors', + ]; + + private const ALLOWED_PATH_PREFIX = '/api/mgr/references/'; + + /** + * @return list + */ + public static function listForClient(): array + { + $out = []; + foreach (self::REFERENCES as $key => $path) { + $out[] = ['key' => $key, 'path' => $path]; + } + + return $out; + } + + public static function pathFor(string $referenceKey): ?string + { + $k = trim($referenceKey); + + return self::REFERENCES[$k] ?? null; + } + + public static function isAllowlistedEndpoint(string $url): bool + { + $trim = trim($url); + if ($trim === '') { + return false; + } + if ($trim[0] !== '/') { + return false; + } + $path = parse_url($trim, PHP_URL_PATH); + if (!is_string($path) || $path === '') { + $path = $trim; + } + + return str_starts_with($path, self::ALLOWED_PATH_PREFIX); + } + + /** + * Validate combo editor configuration before persisting. + * + * @param array $config Field JSON config (may include editor_*) + * @return array{success: bool, message?: string} + */ + public static function validateComboEditorConfig(array $config): array + { + if (($config['editor_type'] ?? '') !== GridColumnEditorType::COMBO) { + return ['success' => true]; + } + + $ref = trim((string)($config['editor_reference'] ?? '')); + $endpoint = trim((string)($config['editor_combo_endpoint'] ?? '')); + + $refPath = $ref !== '' ? self::pathFor($ref) : null; + if ($ref !== '' && $refPath === null) { + return ['success' => false, 'message' => 'Unknown editor_reference']; + } + if ($endpoint !== '' && !self::isAllowlistedEndpoint($endpoint)) { + return [ + 'success' => false, + 'message' => 'editor_combo_endpoint must start with path ' . self::ALLOWED_PATH_PREFIX, + ]; + } + if ($refPath === null && $endpoint === '') { + return [ + 'success' => false, + 'message' => 'Combo editor requires a known editor_reference or an allowlisted editor_combo_endpoint', + ]; + } + + return ['success' => true]; + } +} diff --git a/vueManager/src/components/CategoryProductsGrid.vue b/vueManager/src/components/CategoryProductsGrid.vue index 1077fc8..32218d2 100644 --- a/vueManager/src/components/CategoryProductsGrid.vue +++ b/vueManager/src/components/CategoryProductsGrid.vue @@ -11,10 +11,16 @@ import Tag from 'primevue/tag' import Toast from 'primevue/toast' import { useConfirm } from 'primevue/useconfirm' import { useToast } from 'primevue/usetoast' -import { computed, defineProps, nextTick, onMounted, ref, watch } from 'vue' +import { computed, defineProps, onMounted, ref, watch } from 'vue' import draggable from 'vuedraggable' +import { useCategoryProductsInlineEdit } from '../composables/useCategoryProductsInlineEdit.js' import { useSelection } from '../composables/useSelection.js' +import { + GridColumnEditorType, + isSelectLikeEditorType, + normalizeGridColumnEditorType, +} from '../constants/gridColumnEditorTypes.js' import request from '../request.js' import ActionsColumn from './ActionsColumn.vue' @@ -66,14 +72,26 @@ 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) +const referencePathsByKey = ref({}) + +const { + inlineEditValue, + inlineEditSaving, + inlineEditInputRef, + isBooleanColumn, + isEditingCell, + startInlineEdit, + saveInlineEdit, + cancelInlineEdit, + selectOptionsForColumn, + selectUsesClear, +} = useCategoryProductsInlineEdit({ + products, + referencePathsByKey, + request, + toast, + _, +}) // Default thumbnail from system settings @@ -353,129 +371,6 @@ 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. - * Blocks if another cell is currently saving to avoid race condition. - */ -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 - nextTick(() => { - const comp = inlineEditInputRef.value - if (!comp) return - const el = comp.$el?.querySelector?.('input') ?? comp.$el ?? comp - if (el?.focus) el.focus() - }) -} - -/** Boolean columns (e.g. published) use type, not editor_type (select not in UI yet) */ -function isBooleanColumn(column) { - return column.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 }) - const idx = products.value.findIndex(p => p.id === product.id) - if (idx >= 0) { - if (res && typeof res === 'object') { - products.value[idx] = { ...products.value[idx], ...res } - } else { - products.value[idx] = { ...products.value[idx], [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 */ @@ -532,9 +427,17 @@ async function loadGridConfig() { try { const response = await request.get('/api/mgr/grid-config/category-products') columns.value = response.columns || [] + if (Array.isArray(response.editor_references)) { + referencePathsByKey.value = Object.fromEntries( + response.editor_references.map(r => [r.key, r.path]) + ) + } else { + referencePathsByKey.value = {} + } } catch (error) { console.error('[CategoryProductsGrid] Failed to load grid config:', error) columns.value = getDefaultColumns() + referencePathsByKey.value = {} } } @@ -1065,7 +968,7 @@ onMounted(async () => { @change="saveInlineEdit(product, column)" /> { @keydown.enter.prevent="$event.target.blur()" @keydown.escape="cancelInlineEdit" /> +
+