mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(editor): Explain a restricted model provider with the restriction popover (#40705)
This commit is contained in:
+23
@@ -168,6 +168,29 @@ describe('N8nAiModelSelectorDropdown', () => {
|
||||
expect(getByTestId('ai-model-selector-restricted-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the item-restricted slot in place of the lock', async () => {
|
||||
const { getByTestId, queryByTestId } = render(N8nAiModelSelectorDropdown, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items: [
|
||||
...baseItems,
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
disabled: true,
|
||||
data: { restrictedLabel: 'Restricted on this instance' },
|
||||
},
|
||||
],
|
||||
},
|
||||
slots: { 'item-restricted': '<span data-test-id="custom-restricted-marker" />' },
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('ai-model-selector'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('custom-restricted-marker')).toBeInTheDocument());
|
||||
expect(queryByTestId('ai-model-selector-restricted-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits select and search events', async () => {
|
||||
const { getByTestId, getByText, emitted } = render(N8nAiModelSelectorDropdown, {
|
||||
props: {
|
||||
|
||||
+10
-6
@@ -79,6 +79,7 @@ const emit = defineEmits<{
|
||||
defineSlots<{
|
||||
'trigger-leading'?: (props: { ui: { class: string } }) => void;
|
||||
'item-leading'?: (props: { item: AiModelSelectorMenuItem<TData>; ui: { class: string } }) => void;
|
||||
'item-restricted'?: (props: { item: AiModelSelectorMenuItem<TData> }) => void;
|
||||
}>();
|
||||
|
||||
const dropdownRef = useTemplateRef('dropdownRef');
|
||||
@@ -106,6 +107,7 @@ defineExpose({
|
||||
open: () => {
|
||||
if (!disabled) dropdownRef.value?.open();
|
||||
},
|
||||
close: () => dropdownRef.value?.close(),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -233,12 +235,14 @@ defineExpose({
|
||||
<N8nText size="xsmall" color="text-light" data-test-id="ai-model-selector-restriction">
|
||||
{{ item.data.restrictedLabel }}
|
||||
</N8nText>
|
||||
<N8nIcon
|
||||
icon="lock"
|
||||
size="xsmall"
|
||||
color="text-light"
|
||||
data-test-id="ai-model-selector-restricted-icon"
|
||||
/>
|
||||
<slot name="item-restricted" :item="item">
|
||||
<N8nIcon
|
||||
icon="lock"
|
||||
size="xsmall"
|
||||
color="text-light"
|
||||
data-test-id="ai-model-selector-restricted-icon"
|
||||
/>
|
||||
</slot>
|
||||
</span>
|
||||
<N8nTooltip
|
||||
v-else-if="item.data?.description"
|
||||
|
||||
@@ -2542,9 +2542,13 @@
|
||||
"typeAvailabilityPolicies.restrictedNode.agentTool.instance": "{nodeType} is restricted on this instance. The agent can't use it.",
|
||||
"typeAvailabilityPolicies.restrictedNode.agentTool.project": "{nodeType} is restricted in this project. The agent can't use it.",
|
||||
"typeAvailabilityPolicies.restrictedNode.agentTool.generic": "{nodeType} is restricted. The agent can't use it.",
|
||||
"typeAvailabilityPolicies.restrictedCredentialType.title": "This credential type is restricted",
|
||||
"typeAvailabilityPolicies.restrictedCredentialType.popover.description": "An administrator blocked this credential type. Workflows can't use credentials of this type until an instance administrator grants access.",
|
||||
"typeAvailabilityPolicies.contactAdmin.title": "Instance admins",
|
||||
"typeAvailabilityPolicies.contactAdmin.description": "Instance admins manage which nodes are available. Contact one of them to request access to '{nodeType}'.",
|
||||
"typeAvailabilityPolicies.contactAdmin.mailSubject": "Access request for the {nodeType} node",
|
||||
"typeAvailabilityPolicies.contactAdmin.credentialType.description": "Instance admins manage which credential types are available. Contact one of them to request access to {nodeType}.",
|
||||
"typeAvailabilityPolicies.contactAdmin.credentialType.mailSubject": "Access request for {nodeType}",
|
||||
"typeAvailabilityPolicies.contactAdmin.empty": "No instance admin is listed here. Ask the person who manages this n8n instance.",
|
||||
"typeAvailabilityPolicies.violations.nodeType": "{name} node",
|
||||
"typeAvailabilityPolicies.violations.credentialType": "{name} credential",
|
||||
@@ -5761,6 +5765,7 @@
|
||||
"agents.modelSelector.models": "Models",
|
||||
"agents.modelSelector.credentialsMissing": "Credentials missing",
|
||||
"agents.modelSelector.restricted": "Restricted",
|
||||
"agents.modelSelector.restrictedCredentials": "{provider} credentials",
|
||||
"agents.modelSelector.freeCredits.label": "Use free OpenAI credits",
|
||||
"agents.modelSelector.freeCredits.badge": "free credits",
|
||||
"agents.modelSelector.freeCredits.description": "Get {credits} free OpenAI API credits. Try it with gpt-5-mini.",
|
||||
|
||||
+44
-1
@@ -51,6 +51,7 @@ const restrictedCredentialTypes = vi.hoisted(() => ({
|
||||
value: {} as Record<string, 'instance' | 'project'>,
|
||||
}));
|
||||
const openNewCredential = vi.hoisted(() => vi.fn());
|
||||
const closeMenu = vi.hoisted(() => vi.fn());
|
||||
const openModalWithData = vi.hoisted(() => vi.fn());
|
||||
const aiGatewayState = vi.hoisted(() => ({
|
||||
isEnabled: { value: false },
|
||||
@@ -85,6 +86,7 @@ const baseText = vi.hoisted(() =>
|
||||
'Get {credits} free OpenAI API credits. Try it with gpt-5-mini.',
|
||||
'generic.loadingEllipsis': 'Loading...',
|
||||
'agents.modelSelector.restricted': 'Restricted',
|
||||
'agents.modelSelector.restrictedCredentials': '{provider} credentials',
|
||||
'typeAvailabilityPolicies.restrictedNode.scope.instance': 'Restricted on this instance',
|
||||
'typeAvailabilityPolicies.restrictedNode.scope.project': 'Restricted in this project',
|
||||
}[key] ?? key;
|
||||
@@ -110,6 +112,22 @@ vi.mock('@n8n/frontend-module-type-availability-policies', async (importOriginal
|
||||
return scope ? { name, available: false, scope } : { name, available: true };
|
||||
},
|
||||
}),
|
||||
ContactInstanceAdminModal: {
|
||||
name: 'ContactInstanceAdminModal',
|
||||
props: ['open', 'nodeTypeName', 'kind'],
|
||||
template:
|
||||
'<div v-if="open" data-testid="contact-instance-admin-modal">{{ nodeTypeName }}</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/model-selector/ModelSelectorRestrictedMarker.vue', () => ({
|
||||
default: {
|
||||
name: 'ModelSelectorRestrictedMarker',
|
||||
props: ['name', 'scope'],
|
||||
emits: ['contactAdmin'],
|
||||
template:
|
||||
'<button data-testid="restricted-marker" @click="$emit(\'contactAdmin\')">{{ name }}</button>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/permissions', async (importOriginal) => ({
|
||||
@@ -134,7 +152,12 @@ vi.mock('@n8n/design-system', async () => {
|
||||
'dataTestId',
|
||||
'credentialDataTestId',
|
||||
],
|
||||
template: '<div data-testid="ai-model-selector-dropdown" />',
|
||||
methods: { open: vi.fn(), close: closeMenu },
|
||||
template: `<div data-testid="ai-model-selector-dropdown">
|
||||
<template v-for="item in items" :key="item.id">
|
||||
<slot v-if="item.data?.restrictedLabel" name="item-restricted" :item="item" />
|
||||
</template>
|
||||
</div>`,
|
||||
},
|
||||
N8nIcon: { template: '<span />', props: ['icon', 'size'] },
|
||||
useDropdownSearch: () => ({
|
||||
@@ -285,6 +308,7 @@ describe('AgentModelSelector', () => {
|
||||
freeAiCreditsState.claimCreditsAndGetCredential.mockReset();
|
||||
canCreateCredentials.value = true;
|
||||
restrictedCredentialTypes.value = {};
|
||||
closeMenu.mockReset();
|
||||
aiGatewayState.isEnabled.value = false;
|
||||
aiGatewayState.supportedTypes = new Set<string>();
|
||||
aiGatewayState.balance.value = undefined;
|
||||
@@ -380,6 +404,25 @@ describe('AgentModelSelector', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('explains a restricted provider on its row and opens the contact-admin dialog from it', async () => {
|
||||
restrictedCredentialTypes.value = { openAiApi: 'project' };
|
||||
|
||||
const wrapper = await mountSelector({ anthropic: null });
|
||||
const marker = wrapper.findComponent({ name: 'ModelSelectorRestrictedMarker' });
|
||||
|
||||
expect(marker.props()).toEqual({ name: 'OpenAI credentials', scope: 'project' });
|
||||
expect(wrapper.findComponent({ name: 'ContactInstanceAdminModal' }).props('open')).toBe(false);
|
||||
|
||||
await marker.find('button').trigger('click');
|
||||
|
||||
expect(closeMenu).toHaveBeenCalledOnce();
|
||||
expect(wrapper.findComponent({ name: 'ContactInstanceAdminModal' }).props()).toEqual({
|
||||
open: true,
|
||||
kind: 'credential',
|
||||
nodeTypeName: 'OpenAI credentials',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a multi-credential-type provider usable while one type is still allowed', async () => {
|
||||
restrictedCredentialTypes.value = { azureOpenAiApi: 'instance' };
|
||||
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ModelSelectorRestrictedMarker from '../components/model-selector/ModelSelectorRestrictedMarker.vue';
|
||||
|
||||
vi.mock('@n8n/frontend-module-type-availability-policies', () => ({
|
||||
RestrictedNodePopover: {
|
||||
name: 'RestrictedNodePopover',
|
||||
props: ['nodeTypeName', 'scope', 'kind', 'anchor', 'side', 'markerSize'],
|
||||
emits: ['contactAdmin'],
|
||||
template: '<button @click="$emit(\'contactAdmin\')" />',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ModelSelectorRestrictedMarker', () => {
|
||||
it('anchors the popover to its menu row and forwards the contact-admin request', async () => {
|
||||
const onContactAdmin = vi.fn();
|
||||
const Row = defineComponent({
|
||||
render: () =>
|
||||
h('div', { role: 'menuitem', 'data-test-id': 'row' }, [
|
||||
h(ModelSelectorRestrictedMarker, {
|
||||
name: 'Anthropic credentials',
|
||||
scope: 'instance',
|
||||
onContactAdmin,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const wrapper = mount(Row, { attachTo: document.body });
|
||||
await nextTick();
|
||||
|
||||
const popover = wrapper.findComponent({ name: 'RestrictedNodePopover' });
|
||||
expect(popover.props()).toMatchObject({
|
||||
kind: 'credential',
|
||||
side: 'right',
|
||||
markerSize: 'xsmall',
|
||||
nodeTypeName: 'Anthropic credentials',
|
||||
scope: 'instance',
|
||||
anchor: wrapper.find('[data-test-id="row"]').element,
|
||||
});
|
||||
|
||||
await popover.find('button').trigger('click');
|
||||
|
||||
expect(onContactAdmin).toHaveBeenCalledOnce();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,13 @@ import { useFreeAiCredits } from '@/app/composables/useFreeAiCredits';
|
||||
import { useAiGateway } from '@/app/composables/useAiGateway';
|
||||
import { AI_GATEWAY_MANAGED_TAG, type CredentialTypeAvailability } from '@n8n/api-types';
|
||||
import {
|
||||
ContactInstanceAdminModal,
|
||||
SCOPE_LABEL_KEY,
|
||||
useTypeAvailabilityPoliciesStore,
|
||||
} from '@n8n/frontend-module-type-availability-policies';
|
||||
import ModelSelectorTriggerIcon from './model-selector/ModelSelectorTriggerIcon.vue';
|
||||
import ModelSelectorItemLeadingIcon from './model-selector/ModelSelectorItemLeadingIcon.vue';
|
||||
import ModelSelectorRestrictedMarker from './model-selector/ModelSelectorRestrictedMarker.vue';
|
||||
import { buildMenuItemId, parseMenuItemId } from './model-selector/menuItemId';
|
||||
import { useModelCatalog } from '../composables/useModelCatalog';
|
||||
import {
|
||||
@@ -42,6 +44,7 @@ const FREE_OPENAI_CREDITS_MODEL = 'gpt-5-mini';
|
||||
|
||||
type MenuItemData = AiModelSelectorMenuItemData & {
|
||||
provider?: AgentModelProvider;
|
||||
restriction?: CredentialTypeAvailability;
|
||||
};
|
||||
|
||||
type MenuItem = AiModelSelectorMenuItem<MenuItemData>;
|
||||
@@ -98,6 +101,7 @@ const pendingDefaultCredential = ref<{
|
||||
provider: AgentModelProvider;
|
||||
credentialId: string;
|
||||
} | null>(null);
|
||||
const contactAdminSubject = ref<string | null>(null);
|
||||
const forceModelOptionsDisabled = ref(false);
|
||||
const isResolvingDefaultModel = computed(
|
||||
() => pendingDefaultCredential.value !== null || forceModelOptionsDisabled.value,
|
||||
@@ -232,6 +236,17 @@ const restrictedLabel = computed(() =>
|
||||
: undefined,
|
||||
);
|
||||
|
||||
function restrictedCredentialsName(item: MenuItem): string {
|
||||
return i18n.baseText('agents.modelSelector.restrictedCredentials', {
|
||||
interpolate: { provider: item.label },
|
||||
});
|
||||
}
|
||||
|
||||
function requestContactAdmin(subject: string) {
|
||||
dropdownRef.value?.close();
|
||||
contactAdminSubject.value = subject;
|
||||
}
|
||||
|
||||
function providerToMenuItem(provider: AgentModelProvider): MenuItem {
|
||||
const definition = AGENT_MODEL_PROVIDER_DEFINITIONS[provider];
|
||||
const credentialTypes = getProviderCredentialTypes(provider);
|
||||
@@ -245,6 +260,7 @@ function providerToMenuItem(provider: AgentModelProvider): MenuItem {
|
||||
provider,
|
||||
credentialType: credentialTypes[0],
|
||||
restrictedLabel: i18n.baseText(SCOPE_LABEL_KEY[restriction.scope ?? 'instance']),
|
||||
restriction,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -690,30 +706,53 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nAiModelSelectorDropdown
|
||||
ref="dropdownRef"
|
||||
:items="filteredMenu"
|
||||
:is-loading="isLoading || isResolvingDefaultModel"
|
||||
:selected-label="selectedLabel"
|
||||
:selected-credential-name="selectedCredentialName"
|
||||
:credentials-missing="isCredentialsMissing"
|
||||
:restricted-label="restrictedLabel"
|
||||
:no-match-label="i18n.baseText('agents.modelSelector.noMatch')"
|
||||
:disabled="disabled"
|
||||
data-test-id="agent-model-selector"
|
||||
credential-data-test-id="agent-model-selector-credential"
|
||||
@search="handleSearch"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template #trigger-leading="{ ui }">
|
||||
<ModelSelectorTriggerIcon
|
||||
:credential-type-name="triggerCredentialTypeName"
|
||||
:class="ui.class"
|
||||
/>
|
||||
</template>
|
||||
<div :class="$style.root">
|
||||
<N8nAiModelSelectorDropdown
|
||||
ref="dropdownRef"
|
||||
:items="filteredMenu"
|
||||
:is-loading="isLoading || isResolvingDefaultModel"
|
||||
:selected-label="selectedLabel"
|
||||
:selected-credential-name="selectedCredentialName"
|
||||
:credentials-missing="isCredentialsMissing"
|
||||
:restricted-label="restrictedLabel"
|
||||
:no-match-label="i18n.baseText('agents.modelSelector.noMatch')"
|
||||
:disabled="disabled"
|
||||
data-test-id="agent-model-selector"
|
||||
credential-data-test-id="agent-model-selector-credential"
|
||||
@search="handleSearch"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template #trigger-leading="{ ui }">
|
||||
<ModelSelectorTriggerIcon
|
||||
:credential-type-name="triggerCredentialTypeName"
|
||||
:class="ui.class"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-leading="{ item, ui }">
|
||||
<ModelSelectorItemLeadingIcon :item="item" :class="ui.class" />
|
||||
</template>
|
||||
</N8nAiModelSelectorDropdown>
|
||||
<template #item-leading="{ item, ui }">
|
||||
<ModelSelectorItemLeadingIcon :item="item" :class="ui.class" />
|
||||
</template>
|
||||
|
||||
<template #item-restricted="{ item }">
|
||||
<ModelSelectorRestrictedMarker
|
||||
:name="restrictedCredentialsName(item)"
|
||||
:scope="item.data?.restriction?.scope"
|
||||
@contact-admin="requestContactAdmin(restrictedCredentialsName(item))"
|
||||
/>
|
||||
</template>
|
||||
</N8nAiModelSelectorDropdown>
|
||||
<ContactInstanceAdminModal
|
||||
kind="credential"
|
||||
:node-type-name="contactAdminSubject ?? ''"
|
||||
:open="contactAdminSubject !== null"
|
||||
@update:open="(isOpen) => !isOpen && (contactAdminSubject = null)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
// The dialog is a sibling of the dropdown; the wrapper must not change the host layout.
|
||||
.root {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { CredentialTypeAvailabilityScope } from '@n8n/api-types';
|
||||
import { RestrictedNodePopover } from '@n8n/frontend-module-type-availability-policies';
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
name: string;
|
||||
scope?: CredentialTypeAvailabilityScope;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ contactAdmin: [] }>();
|
||||
|
||||
const rootRef = useTemplateRef<HTMLElement>('rootRef');
|
||||
// The popover opens on hover of the whole menu row, which the dropdown slot does not hand over.
|
||||
const row = computed(() => rootRef.value?.closest<HTMLElement>('[role="menuitem"]') ?? undefined);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="rootRef" :class="$style.root">
|
||||
<RestrictedNodePopover
|
||||
kind="credential"
|
||||
side="right"
|
||||
marker-size="xsmall"
|
||||
:node-type-name="name"
|
||||
:scope="scope"
|
||||
:anchor="row"
|
||||
@contact-admin="emit('contactAdmin')"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
+15
@@ -61,6 +61,21 @@ describe('ContactInstanceAdminModal', () => {
|
||||
expect(getByText(/request access to 'Slack'/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds the credential type copy', async () => {
|
||||
setup([owner]);
|
||||
const { findByTestId, getByText } = renderComponent({
|
||||
props: { open: true, nodeTypeName: 'Anthropic credentials', kind: 'credential' },
|
||||
});
|
||||
|
||||
await findByTestId('contact-instance-admin-list');
|
||||
|
||||
expect(getByText('[email protected]').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'mailto:[email protected]?subject=Access%20request%20for%20Anthropic%20credentials',
|
||||
);
|
||||
expect(getByText(/request access to Anthropic credentials/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps loading until the latest owner lookup resolves after a reopen', async () => {
|
||||
const lookups: Array<() => void> = [];
|
||||
setup(
|
||||
|
||||
+9
-3
@@ -5,8 +5,14 @@ import { useI18n } from '@n8n/i18n';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const { nodeTypeName } = defineProps<{
|
||||
import {
|
||||
RESTRICTED_TYPE_COPY,
|
||||
type RestrictedTypeKind,
|
||||
} from '../type-availability-policies.constants';
|
||||
|
||||
const { nodeTypeName, kind = 'node' } = defineProps<{
|
||||
nodeTypeName: string;
|
||||
kind?: RestrictedTypeKind;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
@@ -18,12 +24,12 @@ const isLoading = ref(false);
|
||||
let lookupId = 0;
|
||||
|
||||
const description = computed(() =>
|
||||
i18n.baseText('typeAvailabilityPolicies.contactAdmin.description', {
|
||||
i18n.baseText(RESTRICTED_TYPE_COPY[kind].contactDescription, {
|
||||
interpolate: { nodeType: nodeTypeName },
|
||||
}),
|
||||
);
|
||||
const mailSubject = computed(() =>
|
||||
i18n.baseText('typeAvailabilityPolicies.contactAdmin.mailSubject', {
|
||||
i18n.baseText(RESTRICTED_TYPE_COPY[kind].mailSubject, {
|
||||
interpolate: { nodeType: nodeTypeName },
|
||||
}),
|
||||
);
|
||||
|
||||
+36
-1
@@ -1,7 +1,7 @@
|
||||
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
||||
import { screen, waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import RestrictedNodePopover from './RestrictedNodePopover.vue';
|
||||
|
||||
@@ -194,4 +194,39 @@ describe('RestrictedNodePopover', () => {
|
||||
|
||||
expect(screen.getByTestId('contact-instance-admin-modal')).toHaveTextContent('Gmail');
|
||||
});
|
||||
|
||||
it('uses the credential type copy', async () => {
|
||||
renderPopover({ active: true, kind: 'credential', scope: undefined });
|
||||
|
||||
expect(await screen.findByText('This credential type is restricted')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"An administrator blocked this credential type. Workflows can't use credentials of this type until an instance administrator grants access.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the contact-admin request to a listening parent instead of opening its own dialog', async () => {
|
||||
const onContactAdmin = vi.fn();
|
||||
renderComponent({ props: { active: true }, attrs: { onContactAdmin } });
|
||||
|
||||
await userEvent.click(await screen.findByTestId('node-restricted-contact-admin'));
|
||||
|
||||
expect(onContactAdmin).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByTestId('contact-instance-admin-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a pointer-down inside the popover from reaching the document', async () => {
|
||||
const onDocumentPointerDown = vi.fn();
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||
renderPopover({ active: true });
|
||||
|
||||
await userEvent.pointer({
|
||||
keys: '[MouseLeft>]',
|
||||
target: await screen.findByTestId('node-restricted-contact-admin'),
|
||||
});
|
||||
|
||||
expect(onDocumentPointerDown).not.toHaveBeenCalled();
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
});
|
||||
});
|
||||
|
||||
+48
-14
@@ -3,16 +3,21 @@ import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
|
||||
import { N8nButton, N8nIcon, N8nPopover, N8nText } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { unrefElement, useActiveElement, useElementHover, type MaybeElement } from '@vueuse/core';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, getCurrentInstance, ref, watch } from 'vue';
|
||||
|
||||
import { useExclusiveOpen } from '../composables/useExclusiveOpen';
|
||||
import { SCOPE_LABEL_KEY } from '../type-availability-policies.constants';
|
||||
import {
|
||||
RESTRICTED_TYPE_COPY,
|
||||
SCOPE_LABEL_KEY,
|
||||
type RestrictedTypeKind,
|
||||
} from '../type-availability-policies.constants';
|
||||
import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
nodeTypeName: string;
|
||||
scope?: NodeTypeAvailabilityScope;
|
||||
kind?: RestrictedTypeKind;
|
||||
/** The list row the popover explains. It opens beside this element, not beside the lock. */
|
||||
anchor?: MaybeElement;
|
||||
/** Keyboard-active without DOM focus, such as a virtual list selection. */
|
||||
@@ -20,10 +25,24 @@ const props = withDefaults(
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
align?: 'start' | 'center' | 'end';
|
||||
sideOffset?: number;
|
||||
markerSize?: 'small' | 'xsmall';
|
||||
}>(),
|
||||
{ scope: undefined, anchor: undefined, side: 'left', align: 'center', sideOffset: 24 },
|
||||
{
|
||||
kind: 'node',
|
||||
scope: undefined,
|
||||
anchor: undefined,
|
||||
active: false,
|
||||
side: 'left',
|
||||
align: 'center',
|
||||
sideOffset: 24,
|
||||
markerSize: 'small',
|
||||
},
|
||||
);
|
||||
|
||||
/** A parent that listens owns the contact-admin dialog, so it can live outside a menu that closes. */
|
||||
const emit = defineEmits<{ contactAdmin: [] }>();
|
||||
const parentHandlesContactAdmin = Boolean(getCurrentInstance()?.vnode.props?.onContactAdmin);
|
||||
|
||||
/** Leaving waits this long before closing, so the pointer can cross the gap to the popover. */
|
||||
const HOVER_GRACE_MS = 200;
|
||||
|
||||
@@ -55,11 +74,15 @@ const wantsOpen = computed(
|
||||
);
|
||||
const open = useExclusiveOpen(wantsOpen);
|
||||
|
||||
const copy = computed(() => RESTRICTED_TYPE_COPY[props.kind]);
|
||||
const scopeKey = computed<BaseTextKey>(
|
||||
() =>
|
||||
(props.scope && SCOPE_LABEL_KEY[props.scope]) ??
|
||||
'typeAvailabilityPolicies.restrictedNode.title',
|
||||
() => (props.scope && SCOPE_LABEL_KEY[props.scope]) ?? copy.value.title,
|
||||
);
|
||||
|
||||
function requestContactAdmin() {
|
||||
if (parentHandlesContactAdmin) emit('contactAdmin');
|
||||
else isContactAdminOpen.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -67,9 +90,9 @@ const scopeKey = computed<BaseTextKey>(
|
||||
<!-- The tool pickers render this inside a modal. -->
|
||||
<N8nPopover
|
||||
:open="open"
|
||||
:side="props.side"
|
||||
:align="props.align"
|
||||
:side-offset="props.sideOffset"
|
||||
:side="side"
|
||||
:align="align"
|
||||
:side-offset="sideOffset"
|
||||
:reference="anchorElement"
|
||||
:suppress-auto-focus="true"
|
||||
:content-class="$style.card"
|
||||
@@ -83,22 +106,28 @@ const scopeKey = computed<BaseTextKey>(
|
||||
role="img"
|
||||
:aria-label="i18n.baseText('typeAvailabilityPolicies.restrictedNode.title')"
|
||||
>
|
||||
<N8nIcon icon="lock" size="small" data-test-id="node-restricted-icon" />
|
||||
<N8nIcon icon="lock" :size="markerSize" data-test-id="node-restricted-icon" />
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div ref="contentRef" :class="$style.popover" data-test-id="node-restricted-popover">
|
||||
<!-- A menu around the anchor dismisses on any pointer-down it sees outside itself; keep ours from reaching it. -->
|
||||
<div
|
||||
ref="contentRef"
|
||||
:class="$style.popover"
|
||||
data-test-id="node-restricted-popover"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<N8nText tag="p" size="large" color="text-dark">{{ nodeTypeName }}</N8nText>
|
||||
<N8nText tag="p" size="small" color="text-light">{{ i18n.baseText(scopeKey) }}</N8nText>
|
||||
<N8nText tag="p" size="small" color="text-base" :class="$style.description">
|
||||
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.popover.description') }}
|
||||
{{ i18n.baseText(copy.popoverDescription) }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="small"
|
||||
:class="$style.action"
|
||||
data-test-id="node-restricted-contact-admin"
|
||||
@click="isContactAdminOpen = true"
|
||||
@click="requestContactAdmin"
|
||||
>
|
||||
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin') }}
|
||||
<N8nIcon icon="arrow-up-right" size="xsmall" />
|
||||
@@ -107,7 +136,12 @@ const scopeKey = computed<BaseTextKey>(
|
||||
</template>
|
||||
</N8nPopover>
|
||||
<!-- A sibling of the popover: its content unmounts on close and must not take the dialog with it. -->
|
||||
<ContactInstanceAdminModal v-model:open="isContactAdminOpen" :node-type-name="nodeTypeName" />
|
||||
<ContactInstanceAdminModal
|
||||
v-if="!parentHandlesContactAdmin"
|
||||
v-model:open="isContactAdminOpen"
|
||||
:node-type-name="nodeTypeName"
|
||||
:kind="kind"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { TypeAvailabilityPoliciesModule } from './type-availability-policies.module';
|
||||
export { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
|
||||
export { SCOPE_LABEL_KEY } from './type-availability-policies.constants';
|
||||
export { SCOPE_LABEL_KEY, type RestrictedTypeKind } from './type-availability-policies.constants';
|
||||
export {
|
||||
describeNodeTypeRestriction,
|
||||
getNodeTypeRestriction,
|
||||
@@ -11,5 +11,6 @@ export {
|
||||
export { default as RestrictedNodePopover } from './components/RestrictedNodePopover.vue';
|
||||
export { default as RestrictedNodePanel } from './components/RestrictedNodePanel.vue';
|
||||
export { default as RestrictedToolCallout } from './components/RestrictedToolCallout.vue';
|
||||
export { default as ContactInstanceAdminModal } from './components/ContactInstanceAdminModal.vue';
|
||||
export { getPolicyViolations } from './policy-violations/policyViolations';
|
||||
export { default as PolicyViolationList } from './policy-violations/PolicyViolationList.vue';
|
||||
|
||||
+25
@@ -14,3 +14,28 @@ export const SCOPE_LABEL_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
|
||||
export function isRestrictionScope(scope: string): scope is NodeTypeAvailabilityScope {
|
||||
return Object.hasOwn(SCOPE_LABEL_KEY, scope);
|
||||
}
|
||||
|
||||
export type RestrictedTypeKind = 'node' | 'credential';
|
||||
|
||||
export const RESTRICTED_TYPE_COPY: Record<
|
||||
RestrictedTypeKind,
|
||||
{
|
||||
title: BaseTextKey;
|
||||
popoverDescription: BaseTextKey;
|
||||
contactDescription: BaseTextKey;
|
||||
mailSubject: BaseTextKey;
|
||||
}
|
||||
> = {
|
||||
node: {
|
||||
title: 'typeAvailabilityPolicies.restrictedNode.title',
|
||||
popoverDescription: 'typeAvailabilityPolicies.restrictedNode.popover.description',
|
||||
contactDescription: 'typeAvailabilityPolicies.contactAdmin.description',
|
||||
mailSubject: 'typeAvailabilityPolicies.contactAdmin.mailSubject',
|
||||
},
|
||||
credential: {
|
||||
title: 'typeAvailabilityPolicies.restrictedCredentialType.title',
|
||||
popoverDescription: 'typeAvailabilityPolicies.restrictedCredentialType.popover.description',
|
||||
contactDescription: 'typeAvailabilityPolicies.contactAdmin.credentialType.description',
|
||||
mailSubject: 'typeAvailabilityPolicies.contactAdmin.credentialType.mailSubject',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user