fix: Show custom role scope tooltips next to the hovered scope (#38284)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-10 10:48:27 +00:00 committed by GitHub
parent 90176902f3
commit 78ea57db75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 79 additions and 1 deletions

View File

@ -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 = /<style[^>]*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<HTMLElement>('.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

View File

@ -82,7 +82,12 @@ function onToggle(option: InstanceScopeOption, groupOptions: InstanceScopeOption
{{ i18n.baseText(group.labelKey) }}
</div>
<div :class="$style.optionList">
<N8nLoading v-if="loading" :rows="group.options.length" :shrink-last="false" />
<N8nLoading
v-if="loading"
:class="$style.loading"
:rows="group.options.length"
:shrink-last="false"
/>
<template v-else>
<N8nTooltip
v-for="option in group.options"
@ -165,13 +170,23 @@ function onToggle(option: InstanceScopeOption, groupOptions: InstanceScopeOption
flex-direction: column;
gap: var(--spacing--2xs);
flex: 1;
/* Keep each tooltip trigger as wide as its option, so the tooltip opens
beside the hovered option and not at the right edge of the card. */
align-items: flex-start;
}
.checkbox {
margin-bottom: 0;
}
/* Also opts out: the skeleton rows size themselves in percent of the card. */
.loading {
align-self: stretch;
}
.warning {
margin-top: var(--spacing--2xs);
/* Opt out of the option alignment above: the callout spans the whole card. */
align-self: stretch;
}
</style>