mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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:
parent
90176902f3
commit
78ea57db75
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user