From 78ea57db75bcb96c5e5ccfaeea3d39c5fb44ce75 Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" <283985454+n8n-cat-bot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:48:27 +0000 Subject: [PATCH] fix: Show custom role scope tooltips next to the hovered scope (#38284) Co-authored-by: n8n-cat-bot[bot] Co-authored-by: Claude Opus 5 --- .../components/ScopeGroupSelector.test.ts | 63 +++++++++++++++++++ .../components/ScopeGroupSelector.vue | 17 ++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/frontend/editor-ui/src/features/roles/instance/components/ScopeGroupSelector.test.ts b/packages/frontend/editor-ui/src/features/roles/instance/components/ScopeGroupSelector.test.ts index 365ea25e497..ee576b14429 100644 --- a/packages/frontend/editor-ui/src/features/roles/instance/components/ScopeGroupSelector.test.ts +++ b/packages/frontend/editor-ui/src/features/roles/instance/components/ScopeGroupSelector.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { renderComponent } from '@/__tests__/render'; import userEvent from '@testing-library/user-event'; import { waitFor } from '@testing-library/vue'; @@ -258,6 +259,68 @@ describe('ScopeGroupSelector', () => { }); }); + describe('option tooltip anchoring', () => { + // jsdom has no layout engine, so the misplacement cannot be measured in + // pixels. What jsdom does resolve is the cascade that causes it: a tooltip + // positions against its trigger element, and that trigger is a flex item of + // the option list. While the list stretches its items (the flex default), + // the trigger is as wide as the whole row, so `placement="right"` puts the + // tooltip at the right edge of the card instead of beside the hovered + // option. + const SFC_PATH = 'src/features/roles/instance/components/ScopeGroupSelector.vue'; + + /** + * Give jsdom the component's own CSS module block. `classNameStrategy: + * 'non-scoped'` keeps the rendered class names equal to the source ones, so + * these selectors match the rendered tree. + */ + function applyComponentStyles(): () => void { + const css = /]*module>([\s\S]*?)<\/style>/.exec(readFileSync(SFC_PATH, 'utf8'))?.[1]; + if (!css) throw new Error(`Found no CSS module block in ${SFC_PATH}`); + const style = document.createElement('style'); + style.textContent = css; + document.head.append(style); + return () => style.remove(); + } + + const SHRINK_WRAPPING_WIDTHS = ['fit-content', 'min-content', 'max-content']; + const STRETCHING_ALIGNMENTS = ['', 'auto', 'normal', 'stretch']; + + /** Width of the box a tooltip anchors to: its own content, or the full row. */ + function anchorWidth(anchor: HTMLElement, row: HTMLElement): 'content' | 'full-row' { + const anchorStyles = getComputedStyle(anchor); + if (SHRINK_WRAPPING_WIDTHS.includes(anchorStyles.width)) return 'content'; + const isFlexItem = ['flex', 'inline-flex'].includes(getComputedStyle(row).display); + const alignment = + anchorStyles.alignSelf || (isFlexItem ? getComputedStyle(row).alignItems : ''); + return STRETCHING_ALIGNMENTS.includes(alignment) ? 'full-row' : 'content'; + } + + it('anchors the tooltip on the option, not on the full width of the option row', () => { + const removeStyles = applyComponentStyles(); + try { + const { getByTestId } = renderComponent(ScopeGroupSelector, { props: { modelValue: [] } }); + + // "Tags: Manage" carries a description, so hovering it shows a tooltip. + const option = getByTestId('scope-option-tag-manage'); + const row = option.closest('.optionList'); + const anchor = [...(row?.children ?? [])].find((child): child is HTMLElement => + child.contains(option), + ); + + // The tooltip trigger is the option's flex item in the row. + expect(anchor?.getAttribute('data-state')).toBe('closed'); + + expect( + anchorWidth(anchor!, row!), + 'the tooltip trigger stretches across the option row, so the tooltip opens at the right edge of the card instead of beside the hovered option', + ).toBe('content'); + } finally { + removeStyles(); + } + }); + }); + describe('mandatory "Users: View" option', () => { // The caller (InstanceRoleView's `withMandatoryInstanceScopes`) is what // guarantees these scopes are always in `modelValue` — the selector itself diff --git a/packages/frontend/editor-ui/src/features/roles/instance/components/ScopeGroupSelector.vue b/packages/frontend/editor-ui/src/features/roles/instance/components/ScopeGroupSelector.vue index 17d51d8d260..04a4835a76d 100644 --- a/packages/frontend/editor-ui/src/features/roles/instance/components/ScopeGroupSelector.vue +++ b/packages/frontend/editor-ui/src/features/roles/instance/components/ScopeGroupSelector.vue @@ -82,7 +82,12 @@ function onToggle(option: InstanceScopeOption, groupOptions: InstanceScopeOption {{ i18n.baseText(group.labelKey) }}
- +