feat: scope hint overhaul — popover UX, EAV form coverage, product grid pills, extension API - #46
feat: scope hint overhaul — popover UX, EAV form coverage, product grid pills, extension API#46adexandros wants to merge 22 commits into
Conversation
- Require PHP 7.4 / 8.1 / 8.2 / 8.3 explicitly - Bump magento/framework to ~103.0 (M2.4.x) - Declare previously-undeclared module deps (store, backend, catalog, ui) - Add Magento_Catalog and Magento_Ui to module.xml sequence (used by upcoming category and product-grid plugins) - Add phpstan level 5 baseline - Add GitHub Actions CI matrix (phpstan + unit tests across PHP versions) - Update README requirements
… service Splits the detection logic out of ConfigFieldPlugin into a dedicated, mockable service. This is the foundation for upcoming work on field-type coverage, category form support, and the new grouped popover UX. New surface (annotated @api): - AvS\ScopeHint\Api\Data\OverrideResultInterface — collection of overrides - AvS\ScopeHint\Api\Data\ScopeOverrideInterface — single override entry - AvS\ScopeHint\Service\ScopeOverrideDetectorInterface — detection contract Implementation: - AvS\ScopeHint\Service\ScopeOverrideDetector — replaces ConfigFieldPlugin's inline iteration + comparison logic - AvS\ScopeHint\Service\OptionLabelResolver — pulled out of the plugin ConfigFieldPlugin is slimmed to delegate to the detector. Tooltip output format is preserved 1:1 (still <br/>-joined "Website code: \"value\"" lines). Array values now surface as "(complex value)" instead of being silently dropped — sets the stage for the formatter pool in PR 4. Adds a phpunit unit test suite covering: empty results, website overrides, store overrides, scope-aware filtering, array handling, grouping by website.
When a config field has no inline <options> but specifies a source_model, OptionLabelResolver now instantiates the source model via ObjectManager, calls toOptionArray(), and matches the raw value to a human-readable label. Options are cached per source-model FQCN for the duration of the request. Instantiation/toOptionArray failures are caught, logged at debug level, and fall back to returning the raw value — no bad source model can crash the tooltip. Closes #1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduce ValueFormatterInterface + Pool (DI-injected array) replacing the inline formatDisplayValue() string cast in ScopeOverrideDetector. Formatters added: - DefaultFormatter — label-resolved + escaped (fallback) - DatetimeFormatter — TZ-aware via TimezoneInterface::formatDate() - MaskedFormatter — fixed bullet string ●●●●●● for password/obscure - FileFormatter — basename() only for file/image/imagefile - EditorFormatter — StripTags + 80-char truncation + "(HTML content differs)" - SerializedArrayFormatter — row-count from array / JSON / PHP serialize Wire-up: etc/adminhtml/di.xml registers all six formatters on Pool. Null-field path in detector preserved (arrays → "(complex value)"). Unit tests added for all six formatters and Pool.
Replace the <br/>-joined override list with a click-to-open popover that groups overrides by website. Uses RequireJS+jQuery (no Alpine), data-mage-init, and native <details> as the no-JS fallback. Add a feature flag dev/debug/scopehint_legacy_tooltip (default 0) for a one-release-cycle rollback path. Files added: - Block/Adminhtml/System/Config/Popover.php - view/adminhtml/templates/system/config/popover.phtml - view/adminhtml/web/js/popover.js - view/adminhtml/requirejs-config.js Files modified: - Plugin/ConfigFieldPlugin.php — branch on legacy flag; inject LayoutInterface - etc/adminhtml/system.xml — new scopehint_legacy_tooltip select field - etc/config.xml — default value 0 (new popover active) - view/adminhtml/web/css/source/_module.less — scopehint-popover styles
Plugs Magento\Catalog\Model\Category\DataProvider::afterGetMeta to inject store-view override tooltips for non-global category EAV attributes, mirroring the existing product-form behaviour. New files: - Service/EavScopeOverrideDetector — loads category per store view via CategoryRepository, memoises per request, resolves select option labels, enforces configurable store-count kill-switch (dev/debug/scopehint_max_stores). - Plugin/CategoryDataProviderPlugin — afterGetMeta plugin that iterates non-global attributes and injects tooltip.description via ArrayManager. - Test/Unit/Service/EavScopeOverrideDetectorTest — fully-mocked unit tests covering no-override, single override, skip-current-store, kill-switch, missing category (NoSuchEntityException), and select-label resolution. Modified files: - etc/adminhtml/di.xml — registers the new plugin. - etc/adminhtml/system.xml — adds scopehint_max_stores field (group debug). - etc/config.xml — sets default value of 50 for scopehint_max_stores. - readme.md — documents the performance kill-switch. Supersedes avstudnitz#27 — thanks @thedotwriter for the original approach. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an opt-in "Overrides" column to the product grid that shows how many distinct store views have overrides on key attributes (name, status, visibility, price, special_price). Column is hidden by default so the aggregation SQL only runs when an admin enables it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Value() Field metadata class has no getValue() method; only the form-element rendering side has it. supports() now relies solely on the backend-model FQCN check, which was already present and is the authoritative signal for serialized config values.
Real Magento behaviour found in browser smoke test: - Field::getBackendModel() can return an instantiated backend object instead of a FQCN string (e.g. Directory's WeightUnit backend). The (string) cast exploded for objects without __toString. - ScopeConfig values can be non-scalar (objects, arrays). Several formatters did unsafe (string) casts. Changes: - SerializedArrayFormatter::isSerializedBackend() now uses instanceof for object backends and is_subclass_of for FQCN strings, falling back to the existing strpos heuristic for namespace-fragment matches. - EditorFormatter::supports() handles object frontend models the same way. - DefaultFormatter, FileFormatter, DatetimeFormatter guard against non-scalar values and stringifiable objects. Adds tests for the object-backend, unrelated-object-backend, and null-backend cases that the previous mock-string-only tests missed.
Magento Field::getBackendModel() and getFrontendModel() do unchecked array access; in PHP 8 with display_errors=on this raises 'Undefined array key' warnings that bubble up as exceptions. getData($key) is null-safe. Updated tests stub getData() with the matching key instead. Added a test case that returns null from getData() to lock in the safe path.
Same Magento bug as backend_model/frontend_model: Field::getSourceModel()
hits undefined-array-key warnings when no source model is set. Replaced
with getData('source_model') and is_string guard.
Replaces hover-only tooltip in product/category UI forms with the same interactive popover used by system config. Renders via the field's `additionalInfo` meta slot, bound late by a new JS bootstrap module (KO html= binding does not auto-process data-mage-init). Adds an OverrideSourceInterface + DI pool so third-party modules can plug in detection for synthetic fields backed by custom storage (e.g. Hyva SeoSuite's hyva_seo_metadata). Default sources cover the real EAV product + category attribute case 1:1. BREAKING: ProductEavDataProviderPlugin and CategoryDataProviderPlugin constructors changed. Private plugin classes; no deprecated alias. Legacy <br>-list tooltip preserved behind the existing dev/debug/scopehint_legacy_tooltip flag. Closes per the upstream-contribution plan PR 5b.
Replace the single aggregate "Overrides" column with one column per non-admin store view. Each cell shows the count of EAV attributes whose store-scope value differs from the admin-scope value (NULL-safe). Blank when zero. Multi-store admins opt in via the column picker per store; single-store installs see no extra columns. Hidden by default to avoid grid blowout. Drops the previous 5-attribute allow-list (missed meta_title, meta_description, etc.) and the presence-only counting (false positives from store rows whose value matches admin). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…vised PR 7)" This reverts commit d3d4b13.
…7 revised) For each product attribute column with store-scope overrides, append a small pill button next to the default value. Click → popover lists the overridden values per store. Same popover JS/CSS as the EAV form (PR 5b). Skips image/media/gallery attributes and global-scope attributes entirely. Skips per-cell when store value equals admin value (NULL-safe inequality on the EAV self-join). Trade-off: enriched cells contain HTML, breaking native grid sort/filter on the value string for rows with overrides. Plain-text rows (no overrides) sort/filter as before. Replaces the previous "single Overrides column" approach (reverted). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The afterGetMeta hook on ProductDataProvider runs BEFORE EAV columns are added by Catalog\Ui\Component\Listing\Columns::prepare(), so the meta walker found no EAV columns to flip. Cells rendered as escaped text. Move the bodyTmpl flip to a plugin on Magento\Catalog\Ui\Component\ ColumnFactory::create — invoked once per EAV column at construction time, the right hook for per-column config tweaks. Drops the afterGetMeta method + tests from ProductGridDataProviderPlugin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pill click previously triggered Magento's grid row-navigation. Stop propagation on click inside the popover root so the row handler doesn't fire. preventDefault on trigger + close buttons to avoid form submits when the popover lives inside a form. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Magento product grid wraps cell content in a div with overflow:hidden. The popover panel rendered correctly (position absolute, z-index 9000) but was visually clipped. On open, append the panel to document.body with position:fixed + computed viewport coords from the trigger; on close, restore to original parent. Outside-click handler updated to treat the portaled panel as inside. Only applies when the popover root carries .scopehint-popover--cell (grid-cell context). Form-field popovers (PR 5b) keep absolute positioning since their ancestors do not clip.
Cell popover now mirrors the system-config popover markup: overrides grouped per website inside <details><summary>, reuses the dot-rail and store-meta layout. Adds .scopehint-panel--cell class so cell-specific panel rules survive the body portal (descendant selectors under .scopehint-popover--cell no longer reach the panel post-portal). Visual consistency with admin config — same caret summary, same indentation, same store name + code chip + quoted value layout.
avstudnitz
left a comment
There was a problem hiding this comment.
Thanks a lot! That's a huge rewrite which makes the module genuinely better. A few things popped up, but all in all, quality is really good.
| - Test/* | ||
| treatPhpDocTypesAsCertain: false | ||
| ignoreErrors: | ||
| - '#Magento\\Config\\Model\\Config\\Structure\\AbstractElement::getData\(\) invoked with 1 parameter#' |
There was a problem hiding this comment.
Ignoring this is dangerous - are you really sure it works with a parameter? Perhaps we replace it with a more generic version which doesn't use a parameter, see next comments.
|
|
||
| private function isSerializedBackend(Field $field): bool | ||
| { | ||
| $backendModel = $field->getData('backend_model'); |
There was a problem hiding this comment.
Config\Model\Config\Structure\AbstractElement::getData()
takes no arguments and returns the whole $_data array. The extra argument is silently
ignored, so $backendModel is an array: it fails both is_object() and is_string(), and
isSerializedBackend() always returns false. The "N rows configured" formatter therefore
never runs in production — serialized fields fall through to DefaultFormatter and show raw
JSON. The unit test passes only because it mocks getData($key) with a per-key callback
(Test/Unit/.../SerializedArrayFormatterTest.php:35), i.e. it asserts the intended contract
rather than the real one.
$backendModel = $field->getData()['backend_model'] ?? null;| return true; | ||
| } | ||
|
|
||
| $frontendModel = $field->getData('frontend_model'); |
There was a problem hiding this comment.
See comment in SerializedArrayFormatter.php: This is an array as $field->getData() accepts no parameters.
$frontendModel = $field->getData()['frontend_model'] ?? null;| return $this->matchOption($field->getOptions(), $value); | ||
| } | ||
|
|
||
| $sourceModel = $field->getData('source_model'); |
There was a problem hiding this comment.
See comment in ValueFormatter/SerializedArrayFormatter.php: This is an array as $field->getData() accepts no parameters.
$sourceModel = $field->getData()['source_model'] ?? null;| $productResource = $product->getResource(); | ||
| $attribute = $productResource->getAttribute($attributeCode); | ||
| if ($attribute && $attribute->usesSource()) { | ||
| $optionText = $attribute->getSource()->getOptionText($valueAsString); |
There was a problem hiding this comment.
Multiselect values render as the literal string Array.
Eav\...\Source\Table::getOptionText() returns an array of labels when the value contains
a comma, and line 218 does (string) $optionText. In production you get Array; in developer
mode the converted warning is swallowed by the \Throwable catch and you get raw IDs. The old
code only resolved labels for dataType == 'select', so this is a regression for multiselects.
Same at Service/OverrideSource/EavCategoryOverrideSource.php:222.
if (is_array($optionText)) {
$valueAsString = implode(', ', $optionText);
} elseif ($optionText !== false && $optionText !== null) {
$valueAsString = (string) $optionText;
}| * Format a raw EAV value to a display string. | ||
| * Datetime values are cast to string; all others are cast directly. | ||
| */ | ||
| private function formatValue($value, AbstractAttribute $attribute): string |
There was a problem hiding this comment.
Grid pills show raw option IDs while the form popover shows labels —
formatValue($value, AbstractAttribute $attribute) never uses $attribute and just returns
(string) $value. A status override renders as 2 instead of Disabled, which contradicts
the PR's "human-readable labels" goal for the grid. Either resolve labels here (with the
is_array() guard from the multiselect finding) or drop the unused parameter so the gap is
explicit.
| . ' AND admin.store_id = 0', | ||
| [] | ||
| ) | ||
| ->where('entity.' . $identifierField . ' IN (?)', $productIds) |
There was a problem hiding this comment.
Can we limit the loaded products to those actually used in the grid? The way it is, we load all attributes including the big text fields which is a lot of unnecessary data.
| 'entity.' . $linkField . ' = t.' . $linkField, | ||
| ['entity_id' => $identifierField] | ||
| ) | ||
| ->joinInner( |
There was a problem hiding this comment.
This missesattributes
that have a store row but no default row, so the grid shows no pill while the form popover does.
joinLeft plus the existing NULL-safe NOT (t.value <=> admin.value) handles it correctly.
| } | ||
|
|
||
| try { | ||
| $sourceModel = $this->objectManager->create($sourceModelClass); |
There was a problem hiding this comment.
Can we use Magento\Config\Model\Config\SourceFactory instead of the object manager?
| $storeId, | ||
| (string) $website->getCode(), | ||
| $storeValue, | ||
| (string) $storeValue |
There was a problem hiding this comment.
Add escaping her in order to prevent XSS:
$this->escaper->escapeHtml((string) $storeValue)
Overview
This branch consolidates work across 7 areas:
ScopeOverrideDetectorreplacing per-fieldscopeConfig->getValue()calls1/0values (closes Support select field in configuration #1)<br>-list mode preserved behinddev/debug/scopehint_legacy_tooltipflagoverflow:hiddenclippingExtension API (
Api/OverrideSourceInterface)Third-party modules can register custom override sources without forking this module:
See
docs/extending.mdfor a worked example.What changed
?bubble with<br>-joined store list1/0source_modeltooltip.descriptionhover bubbleadditionalInfoslot popover, MutationObserver-bound after KO renderscopeConfig->getValue()loopScopeOverrideDetectorwith memoisationOverrideSourceInterfacepool for custom sourcesTests
Closes