feat(options): full Vue migration of options management + PR #200 integration#205
feat(options): full Vue migration of options management + PR #200 integration#205
Conversation
…ductOptionsTab
Replaces ExtJS mount in ProductTabs options tab (Ext.create(modx-vtabs) + ms3.utils.getExtField)
with pure Vue components:
- ProductOptionField.vue — renders a single option field by msOption.type
(textfield, numberfield, textarea, checkbox, comboBoolean, combobox,
comboMultiple, comboColors, comboOptions, datefield). Hidden inputs
propagate values into the MODX form POST as options-{key}[.options-{key}[]]
so the existing Product/Create and Product/Update processors work unchanged.
- ProductOptionsTab.vue — vertical navigation grouping options by msOption.modcategory_id
(category_name as title). Flat list if only one group. Non-scoped .vueApp CSS to
avoid Vite scoped-hash mismatch across chunks.
- ProductTabs.vue — swap type: 'extjs-options' to type: 'vue', drop mountOptionsTab()
and related mount/destroy/CSS. No more reliance on Ext.create or ms3.utils.getExtField
for the options tab.
Adds msOptionType::getSchema($field): array as a parallel to getField() (which returns ExtJS-specific JS string). Vue renderer consumes schema to build PrimeVue components without touching Ext.*. Default implementation derives type key from class short name (ComboBoolean → comboBoolean) and passes msOption.properties through as-is. Individual Types/*.php can override for richer schema (e.g. normalized values list). Also: - msOption::getSchema() delegates to type handler (mirrors getManagerField()). - OptionLoaderService::getFieldsForProduct() attaches schema to each option_fields row alongside existing ext_field, so Vue consumers can use schema while ExtJS form (if still present anywhere) keeps working via ext_field. getField() is intentionally kept — will be removed in the cleanup stage of this refactor.
…onsController Replaces legacy Processors/Settings/Option/* and Processors/Category/Option/* with two REST controllers that delegate business logic to existing services (OptionService, OptionCategoryService, OptionSyncService). Processors stay in place for now — will be deleted in the cleanup stage. OptionsController (Settings → Options): - getList, get, create, update, delete - bulkDelete (mass deletion) - bulkAssign (assign options[] to categories[]) - getTypes (discover available option types + lexicon captions) - getTree (MODX resource tree with checkbox state for option's categories) - getModcategories (flat modCategory list for grouping dropdown) CategoryOptionsController (Category → Options tab): - getList (options linked to category with global caption/description) - create, update (partial: value/active/required/position), delete - sort (reorder positions atomically) - bulk (activate/deactivate/require/unrequire/remove for many) - duplicate (copy all links from another category, skip existing) Per-link caption/description override is intentionally out of scope here (lands after PR #203 which adds columns to ms3_category_options).
/api/mgr/options:
GET /types list available option types
GET /tree MODX resource tree (for category selection)
GET /modcategories flat modCategory list (for group dropdown)
POST /bulk/assign assign options[] to categories[]
DELETE /bulk mass delete
GET '' list (query, modcategory_id, category_id, categories[])
POST '' create
GET /{id} detail + categories map
PUT /{id} update
DELETE /{id} delete
/api/mgr/categories/{category_id}/options:
POST /sort reorder positions
POST /bulk activate/deactivate/require/unrequire/remove
POST /duplicate copy from another category
GET '' list for category
POST '' add option to category
PUT /{option_id} update link
DELETE /{option_id} remove link
Permissions: mssetting_save for options CRUD, mscategory_save for per-category links.
Static routes (/types, /tree, /bulk/...) precede /{id} to avoid path shadowing.
Replaces ms3-tree-option-categories + ms3-grid-option ExtJS combo with a single Vue app mounted at #ms3-vue-options. Uses the REST endpoints introduced earlier in this branch. New components: - OptionCategoryTree.vue — MODX resource tree with PrimeVue Tree (checkbox selection with hierarchical partial-check, lazy loading, filter, context menu: refresh, expand/collapse branch, select all/clear all in subtree). - OptionValuesEditor.vue — inline editor for option.properties.values, two variants: 'simple' (plain list with drag-drop via vuedraggable) for combobox/comboMultiple, 'colors' (label+hex pairs with color swatch) for comboColors. - OptionsGrid.vue — main screen: category tree (left) + DataTable (right) + toolbar (create, modcategory filter, search, bulk assign, bulk delete) + Dialog for create/edit with OptionCategoryTree inside for link assignment + bulk-assign Dialog. Wiring: - vite.config.js — new 'options' entry - entries/options.js — mount point with standard tab-visibility observer pattern - settings.panel.js — replaces the two ExtJS xtypes with a single #ms3-vue-options div - settings.class.php — drops option/grid.js, option/window.js, option/tree.js and per-type JS scripts from the page bundle; adds options.min.js/css Legacy ExtJS option files (option/grid.js, option/window.js, option/tree.js, option/types/*) are no longer loaded. They will be deleted in the final cleanup commit of this branch.
…ptionsTab
Adds CategoryOptionsTab.vue for the category-edit page (msCategory form, Options group).
Uses the REST /api/mgr/categories/{id}/options endpoints introduced earlier.
Feature parity with legacy ExtJS ms3-grid-category-option + ms3-window-option-add +
ms3-window-copy-category:
- DataTable with key, caption, type, value (inline cell edit), active/required indicators
- Row reorder via PrimeVue rowReorder (drag handle column) → POST /sort with option_ids
- Bulk actions toolbar on selection: activate / deactivate / require / unrequire / remove
(mass confirm for remove)
- Single-row remove action with confirm
- Add option Dialog: Select of options not yet linked to this category, default value,
active/required checkboxes
- Copy from category Dialog: Select of other categories (flat list from tree endpoint),
backend handles skip-duplicates via /duplicate endpoint
Wiring:
- vite.config.js — new 'category-options' entry
- entries/category-options.js — mounts at #ms3-vue-category-options reading
data-category-id from the container
- category/update.js addOptions() — replaces ms3-grid-category-option xtype with a
<div id="ms3-vue-category-options" data-category-id="{id}"> container
- category/update.class.php — drops option.grid.js, option.windows.js; adds
category-options Vue bundle
Legacy ExtJS category option files will be deleted in the final cleanup commit.
Delete unused ExtJS files and legacy MODX processors that are fully replaced by the
Vue UI + REST controllers in this branch:
Assets:
- assets/.../js/mgr/settings/option/{grid,window,tree}.js — replaced by OptionsGrid.vue
- assets/.../js/mgr/settings/option/types/{combobox,combobox-colors}.grid.js —
replaced by OptionValuesEditor.vue (variant='simple' | 'colors')
- assets/.../js/mgr/category/option.{grid,windows}.js — replaced by CategoryOptionsTab.vue
- assets/.../css/mgr/main.css — drop selectors for deleted #ms3-window-option-* trees
Processors (all functionality moved to OptionsController + CategoryOptionsController):
- src/Processors/Settings/Option/*.php (11 files)
- src/Processors/Category/Option/*.php (11 files)
Kept intentionally:
- src/Controllers/Options/Types/*.php + msOption::getManagerField() — getSchema() and
getValue()/getRowValue() are still used by OptionLoaderService. getField() (ExtJS
string form) stays for backward-compat in CategoryOptionService::getOptionsForCategory
until that service is retired in a follow-up.
- assets/.../js/mgr/misc/ms3.combo.js — still hosts shared combos (User, Customer,
Category, etc.) used outside options.
- assets/.../js/mgr/misc/ms3.utils.js — utility grab-bag still used across the codebase.
getTree() now mirrors legacy Processors\Category\GetNodes behavior: - WHERE modResource.class_key LIKE '%msCategory' AND deleted = 0 — excludes regular pages, sections, trash. User reported regular resources appearing in the picker. - leaf flag derived from a LEFT JOIN childrenCount (vs. the isfolder heuristic) — only truly childless msCategory nodes render without an expand caret. User reported caret appearing only on the first node because isfolder doesn't reflect current child state for a just-emptied category.
PrimeVue Tree with selection-mode=checkbox defaults to hierarchical propagation: checking a parent selects all descendants; unchecking any child unchecks the parent. This prevents two legitimate cases pointed out by the user: 1. Assign an option only to the parent category (without children). 2. Assign one set of options to a parent and a different set to its children. Disable propagation via :propagate-selection-up/:propagate-selection-down = false so each node is independently checkable (matches legacy ExtJS tree behavior).
…tion The earlier attempt with :propagate-selection-up/down props had no effect: in PrimeVue 4.3.1, the Tree's checkbox mode hard-codes parent↔child propagation in its private propagateDown/propagateUp methods with no escape hatch. Drop selection-mode=checkbox entirely and render a plain <Checkbox> inside the node slot, backed by a Set<categoryId> state we own. Ticking a parent no longer cascades; unticking a child no longer resets the parent. bulkToggleChecks (context-menu 'select all' / 'clear all' over a subtree) still does recursive fill/clear, but as an explicit user action through the menu, not as implicit checkbox coupling.
Second search field (in the grid toolbar) was redundant and confusing — the tree filter on the left already searches categories, and filtering options by key/caption can be done via the modcategory dropdown or by picking specific categories in the tree. The grid-level search was also non-live (only fired on Enter), which added to the confusion. Leaves a cleaner toolbar: Create / modcategory filter / bulk actions.
…ee built-in OptionCategoryTree had two filter inputs side-by-side: - a custom IconField wrapper wiring filterValue into Tree's :filter-value - PrimeVue Tree's own built-in filter input (auto-rendered by :filter=true) Remove the custom one (and the filterValue ref, IconField/InputIcon/InputText imports) and use Tree's native :filter-placeholder so the single built-in input shows the localized 'Поиск' label. Same UX, one input instead of two.
…f mount root Dialog markup is teleported to <body> by default, so selectors rooted at .vueApp .options-grid-app don't match the dialog contents. Two fixes: - Put a 'vueApp' class on each Dialog (class='ms3-option-dialog vueApp') so the child components (OptionCategoryTree, OptionValuesEditor) whose styles are scoped under .vueApp start working inside the dialog again. - Move dialog-specific styles (.dialog-layout, .dialog-form, .dialog-tree, .form-row, .form-field, .dialog-hint) out from under .options-grid-app and scope them by .ms3-option-dialog instead. Also flip .form-row to a column flex by default with a .two-cols modifier for side-by-side fields — restores the comfortable spacing between labels and inputs that got collapsed when all rows were rendered as row-flex.
ms3_options.modcategory_id is NOT NULL in the schema (default 0), so the Vue dialog clearing the 'Group' Select sent null and the save failed. Coerce null/'' to 0 for modcategory_id in applyWritableFields — other fields stay as-is. Users can now leave Group empty when creating an option.
…boColors editor Clicking the color swatch now opens a native PrimeVue ColorPicker palette (matching the prior ExtJS behavior). The adjacent hex input is kept for manual typing — both stay in sync and share the same properties.values entry. Storage contract unchanged: option.properties.values[i].name keeps the leading '#' (e.g. '#FF0000'). ColorPicker uses hex without '#' internally, so hexWithoutHash/hexWithHash adapt between the two formats on each update.
For comboMultiple, comboColors and comboOptions on the product form, the Vue
renderer used to emit one hidden <input name='options-{key}[]'> per selected value.
ExtJS BasicForm.getValues() on save would then find multiple DOM nodes sharing the
same name and silently keep only the last one — user reported this as 'saves only
one of the selected values'.
Fix:
- ProductOptionField.vue renders a single <input name='options-{key}'> whose value
is JSON.stringify(multiArrayValue) for all three multi-pick types.
- Utils::decodeOptionValue() helper parses that JSON array back into a PHP array
on POST (leaves scalars and native arrays untouched).
- Product\Create::beforeSet and Product\Update::beforeSet call
decodeOptionValue() alongside Utils::extractOptionKey so OptionSyncService
receives an array for multi-value keys, as prepareOptionValues() already expects.
convertPreloadedValue() treated only 'ComboMultiple' (strict CamelCase string match) as multi-value; msOption.type is actually stored lowerCamelCase so the branch never ran and the function returned values[0] — dropping all but the first pick when the product form was reloaded. User reported 'сохранение не подтверждается' after saving multi-value options. DB persistence via syncMultipleValues was fine all along; it was the read path on the next page load that truncated the array. Fix: case-insensitive match against [comboMultiple, comboColors, comboOptions] so all three multi-pick types reload with the full value set. Also drop the debug logging that was added to diagnose the issue.
The comboOptions type (List with autocomplete) must allow users to type ANY value on the product form and get suggestions built from values already saved by other products for the same option key. The MultiSelect used earlier only permitted picking from the already-selected set, so typing new values was impossible — user reported it was unusable. Changes: - ProductOptionField.vue: switch comboOptions from MultiSelect to PrimeVue AutoComplete (multiple mode, typeahead=false). Freeform text is accepted on Enter; suggestions list is populated on @complete via a small request. Hidden input still ships JSON.stringify(value) for the existing server path. - OptionsController::getSuggestions() — new GET /api/mgr/options/suggestions endpoint. Returns DISTINCT msProductOption.value rows for a given key, optionally filtered by ?query= substring. Used as the autocomplete source.
Removing :typeahead=false (which suppresses the dropdown AND the @complete event) and setting :min-length=1 with :delay=200 so the suggestions request fires as the user types, matching standard PrimeVue AutoComplete behavior.
PrimeVue AutoComplete multiple mode only commits a suggestion token on Enter when the typed text matches an item from the suggestions list. For the free-form comboOptions type the user needs to be able to add anything — including words that have never been saved before — so an @keydown handler picks up Enter on unmatched input, pushes the text into v-model and clears the input. Selecting from the suggestion list keeps working as before.
…-form input The AutoComplete multiple mode kept swallowing Enter without pushing the typed value into v-model, even with a custom keydown handler — user kept saving an empty value. Chips is the straightforward fit for this case: typing + Enter (or comma) adds a new token, no suggestions panel required. Suggestions endpoint stays in the backend for possible future re-use but we no longer fetch them on the product form. Storage contract unchanged: hidden input still carries JSON.stringify(values) and the Product processors decode via Utils::decodeOptionValue().
Switching from deprecated Chips to InputChips (PrimeVue v4 name), plus :add-on-blur=true so typing a value and clicking outside the field automatically adds it as a token — the previous Enter/comma-only behavior was non-obvious, user kept losing typed values by moving focus away. Placeholder updated to spell out the three ways to commit a chip (Enter, comma, blur).
Release notes for the full Vue migration of product options management.
…e into Vue refactor PR #203 (#200) adds per-link caption/description override on msCategoryOption: non-empty override wins over msOption.caption/description, empty inherits. That PR predates the ExtJS→Vue options refactor in this branch and targets processors/ExtJS that no longer exist here, so we cherry-pick the schema + service + overlay changes and re-implement the UI portion on top of the new REST + Vue stack. Backend (from PR #203, applied as-is): - Migration 20260417120000_add_caption_description_to_category_options.php — adds ms3_category_options.caption (varchar 191, null) and .description (text, null). - schema/minishop3.mysql.schema.xml + src/Model/msCategoryOption.php + src/Model/mysql/msCategoryOption.php — new fields in xPDO meta. - OptionCategoryService::addToCategory() and OptionService::addOptionToCategory() accept optional caption/description override. - OptionLoaderService — caption/description overlay for loadForProduct, getFieldsForProduct, loadForProducts (batched, no N+1 per product). Non-empty override wins; tie-breaking across multiple product categories: parent category first, then lowest position, then lowest category_id. - ru/en lexicons: ms3_category_option_caption_override, *_description_override, ms3_global_caption, ms3_effective_caption. Frontend (new work on top of PR #203): - CategoryOptionsController formats rows with global_caption / global_description / category_caption / category_description and the effective caption; list search now also matches the per-link override columns. create() accepts caption/description; update() allows partial edit of both (empty string → null, so clearing the field restores inheritance). - CategoryOptionsTab.vue — new columns 'Глобально' (global_caption, read-only) and 'Название (для категории)' (category_caption, inline-editable). Inline cell save maps category_caption → caption on the wire. Add-option dialog gained caption + description override inputs. Kept the case-insensitive convertPreloadedValue fix from earlier in this branch (multiTypes includes comboMultiple, comboColors, comboOptions).
…ption on hydration After PR #203 added caption/description columns to ms3_category_options, the getFieldsForProduct() query selects those columns alongside msOption.caption / description. xPDO hydration maps columns by name — the second caption/description overwrote the global one, so every option came out with an empty label (user saw option keys instead of captions). Exclude caption/description from the msCategoryOption select. The per-category override is already layered on top by the overlay logic below — that path still queries msCategoryOption directly for its values.
- OR-group wrapping in CategoryOptionsController::getList — previously the OR: search conditions were appended to the same array as category_id, making category_id match an OR term instead of being ANDed. Now wrapped in a nested array for proper grouping. - Exact class_key match in OptionsController::getTree (modResource.class_key = 'MiniShop3\Model\msCategory' instead of LIKE '%msCategory') — avoids accidental matches on third-party classes with a msCategory-suffixed name. - i18n for product option form: booleanOptions label uses lexicon yes/no; InputChips placeholder for comboOptions uses new ms3_combo_options_chips_placeholder key with ru/en translations.
|
Review фикс:
Остальные пункты (3, 4, 7, 8, 9, 10, 11, 12) — оставляем как есть: либо осознанный trade-off, либо nit. Коммит: 342c996 |
#1 TZ shift: DatePicker value is a local Date; toISOString() returned UTC YYYY-MM-DD, which is a day earlier for TZ east of UTC. Introduce formatDateForPost() that uses getFullYear/getMonth/getDate. #4 Class literal: replace the string 'MiniShop3\Model\msCategory' in OptionsController::getTree() with msCategory::class — IDE-refactor safe and consistent with modResource::class nearby. #5 Formatter consistency: CategoryOptionsController::formatRow() now delegates caption merging to OptionLoaderService::mergeCaptionDescription() — same trim semantics as the storefront overlay, so a ' ' override no longer shows in the admin grid while the site still falls back to global.
|
Round 2 фикс:
По #2 suggestions: оставляю InputChips (UX согласован с автором — AutoComplete в multi-freeform ломался ранее). Endpoint /api/mgr/options/suggestions оставлен публичным для custom-расширений — уберу упоминание из описания PR. Остальные пункты (#3, #6–#10) — осознанные trade-off / nit, по ним отдельные issue при необходимости. Коммит: c309411 |
The 'autocomplete' part of comboOptions was missing — user pointed out the type name promises suggestions but the field rendered pure InputChips. Keep InputChips for reliable free-form entry (the earlier attempt with AutoComplete multiple mode was unreliable with Enter on unmatched text) and add a suggestions strip below the field. - Suggestions pulled lazily on the first keystroke from /api/mgr/options/suggestions (distinct msProductOption.value rows for the current key from all other products). - Clicking a pill adds it to the chips and removes it from the strip. - The strip filters as the user types and hides already-picked values. - Two new lexicon keys: ms3_combo_options_suggestions (ru/en).
Code review (архитектура, чистый код, продакшн)Спасибо за масштабный и хорошо задокументированный PR. Ниже — сжатый ревью по изменениям и обсуждению в #205. Что сделано сильно
Замечания и риски (без блокера к мержу, если вы уже их приняли)
Clean Code (краткий чеклист по PR)
Итог: с учётом уже внесённых правок по ревью и явных trade-off в треде PR выглядит готовым к мержу с точки зрения качества и сопровождаемости. Спасибо команде за аккуратную миграцию и прозрачную историю исправлений. — Code review (чистый код / продакшн-фокус) |
Summary
Полный перевод управления опциями товара с ExtJS на Vue + REST API. Удалено ~2600 строк legacy-кода (7 ExtJS файлов и 22 PHP-процессора). Также перенесена суть #203 (Issue #200) — per-category caption/description override — поверх новой Vue-архитектуры.
Что поменялось
Backend
OptionsControllerиCategoryOptionsController(+ роуты/api/mgr/options/*и/api/mgr/categories/{id}/options/*) вместо 22 MODX-процессоров.msOptionType::getSchema()— type handlers отдают array вместо JS-строк.caption/descriptionнаms3_category_options+ overlay-логика вOptionLoaderService(из PR feat(options): per-category caption and description for category-option links #203).convertPreloadedValue: case-insensitive match + 3 multi-type (comboMultiple/Colors/Options) — раньше толькоComboMultipleстрого матчился, reload терял все значения кроме первого.Utils::decodeOptionValue()) — обход ограничения ExtJSBasicForm.getValues(), который читал только последний DOM-input при нескольких с одинаковым name.Frontend
OptionsGrid.vue+OptionCategoryTree.vue(дерево категорий MODX с независимыми чекбоксами и контекстным меню: обновить/развернуть/свернуть ветку/выделить все вложенные/снять все) +OptionValuesEditor.vue(редактор значений с drag-drop и PrimeVue ColorPicker).CategoryOptionsTab.vueс inline-редактором, drag-drop сортировкой, массовыми действиями, диалогами «Добавить опцию» и «Копировать опции из другой категории». КолонкиГлобально/Название (для категории)для override из PR feat(options): per-category caption and description for category-option links #203.ProductOptionField.vue+ProductOptionsTab.vueдля всех 10 типов опций.comboOptions→ PrimeVueInputChips(Enter / запятая / blur = коммит тега).Удалено (2600 строк)
assets/.../js/mgr/settings/option/*(grid, window, tree, types/*)assets/.../js/mgr/category/option.grid.js,option.windows.jssrc/Processors/Settings/Option/*.php(11 файлов)src/Processors/Category/Option/*.php(11 файлов)Test plan
Связанные issues/PRs
🤖 Generated with Claude Code