Skip to content

feat: scope hint overhaul — popover UX, EAV form coverage, product grid pills, extension API - #46

Open
adexandros wants to merge 22 commits into
avstudnitz:masterfrom
adexandros:local/integration
Open

feat: scope hint overhaul — popover UX, EAV form coverage, product grid pills, extension API#46
adexandros wants to merge 22 commits into
avstudnitz:masterfrom
adexandros:local/integration

Conversation

@adexandros

@adexandros adexandros commented Jul 13, 2026

Copy link
Copy Markdown

Overview

This branch consolidates work across 7 areas:

  1. CI baseline — GitHub Actions matrix (PHP 7.4/8.1/8.2/8.3), PHPStan level 5, updated composer constraints
  2. Override detection service — extracted ScopeOverrideDetector replacing per-field scopeConfig->getValue() calls
  3. Select label resolution — shows human-readable labels (Yes/No, Enabled/Disabled) instead of raw 1/0 values (closes Support select field in configuration #1)
  4. Broader field-type coverage — formatter pool handles datetime, password/encrypted, file/image, WYSIWYG, serialised arrays, multiselect
  5. Popover UX — replaces hover tooltip with click-triggered, website-grouped expandable panel; legacy <br>-list mode preserved behind dev/debug/scopehint_legacy_tooltip flag
  6. Category edit form — scope hints on category EAV fields (closes Support For Categories #26, supersedes Add scopehint on the category edit form #27 — thanks @thedotwriter for the original approach)
  7. Product grid pills — per-attribute columns show store-override values inline as click-to-inspect pills; portaled panel avoids grid cell overflow:hidden clipping

Extension API (Api/OverrideSourceInterface)

Third-party modules can register custom override sources without forking this module:

<type name="AvS\ScopeHint\Service\OverrideSourcePool">
  <arguments>
    <argument name="sources" xsi:type="array">
      <item name="my_custom" xsi:type="object" sortOrder="50">Vendor\Module\ScopeHint\MyOverrideSource</item>
    </argument>
  </arguments>
</type>

See docs/extending.md for a worked example.

What changed

Area Before After
System config tooltip Hover ? bubble with <br>-joined store list Click-triggered popover, website-grouped, collapsible, searchable
Select field values Raw 1 / 0 Resolved option labels via source_model
Field type coverage Basic text only datetime, password, file, WYSIWYG, serialised arrays
Product EAV form tooltip.description hover bubble additionalInfo slot popover, MutationObserver-bound after KO render
Category EAV form No hints Same popover, detects overrides per store
Product grid No indicator Per-attribute pills showing overridden value per store/website
Override detection Per-field scopeConfig->getValue() loop Centralised ScopeOverrideDetector with memoisation
Extension surface None OverrideSourceInterface pool for custom sources

Tests

  • 143 unit tests, 227 assertions — all passing
  • Playwright admin E2E (8 scenarios) covering pill render, click→open, no row navigation, outside-click close, portal-to-body, panel restore

Closes

andrea durso and others added 22 commits July 13, 2026 10:47
- 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>
…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
avstudnitz self-requested a review July 31, 2026 08:06

@avstudnitz avstudnitz left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread phpstan.neon
- Test/*
treatPhpDocTypesAsCertain: false
ignoreErrors:
- '#Magento\\Config\\Model\\Config\\Structure\\AbstractElement::getData\(\) invoked with 1 parameter#'

@avstudnitz avstudnitz Jul 31, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use Magento\Config\Model\Config\SourceFactory instead of the object manager?

Comment thread docs/extending.md
$storeId,
(string) $website->getCode(),
$storeValue,
(string) $storeValue

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add escaping her in order to prevent XSS:

$this->escaper->escapeHtml((string) $storeValue)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support For Categories Support select field in configuration

2 participants