mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 14:56:50 +02:00
feat(editor): Align the Microsoft Teams manual setup with the new design (no-changelog) (#39798)
Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
parent
f82fa0c1af
commit
efec7dc067
|
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.55644 1.09068H9.89611L5.39111 14.4385C5.3448 14.5757 5.25665 14.6949 5.13906 14.7793C5.02148 14.8637 4.88037 14.9092 4.73561 14.9092H1.35827C1.24859 14.9092 1.14048 14.8831 1.04289 14.833C0.945297 14.783 0.861028 14.7104 0.797051 14.6213C0.733075 14.5322 0.691232 14.4292 0.674982 14.3207C0.658732 14.2122 0.668544 14.1014 0.703606 13.9975L4.90077 1.56135C4.94705 1.42413 5.03522 1.30489 5.15284 1.22043C5.27047 1.13596 5.41163 1.09069 5.55644 1.09068Z" fill="url(#paint0_linear_301_1374)"/><path d="M11.8625 10.0435H4.98084C4.91686 10.0434 4.85434 10.0626 4.80143 10.0986C4.74852 10.1346 4.70767 10.1856 4.6842 10.2452C4.66072 10.3047 4.65572 10.3699 4.66984 10.4323C4.68395 10.4947 4.71654 10.5514 4.76334 10.595L9.18534 14.7224C9.31409 14.8424 9.48361 14.9092 9.65967 14.9092H13.5563L11.8625 10.0435Z" fill="#0078D4"/><path d="M5.55629 1.09064C5.4099 1.09008 5.26716 1.13634 5.14893 1.22267C5.0307 1.309 4.94317 1.43087 4.89912 1.57048L0.708622 13.9861C0.671202 14.0904 0.659454 14.2022 0.674372 14.312C0.68929 14.4218 0.730435 14.5264 0.794327 14.617C0.858218 14.7075 0.942977 14.7813 1.04143 14.8322C1.13989 14.883 1.24914 14.9094 1.35996 14.9091H4.82446C4.95349 14.8861 5.07409 14.8292 5.17394 14.7443C5.27379 14.6594 5.34931 14.5495 5.39279 14.4258L6.22846 11.963L9.21346 14.7471C9.33854 14.8506 9.49546 14.9078 9.65779 14.9091H13.54L11.8373 10.0435L6.87379 10.0446L9.91162 1.09064H5.55629Z" fill="url(#paint1_linear_301_1374)"/><path d="M11.0991 1.56064C11.0529 1.42364 10.9648 1.3046 10.8474 1.22028C10.7299 1.13597 10.589 1.09062 10.4444 1.09064H5.60791C5.75248 1.09065 5.89341 1.136 6.01086 1.22031C6.1283 1.30463 6.21634 1.42366 6.26258 1.56064L10.4599 13.9973C10.495 14.1013 10.5048 14.2121 10.4886 14.3206C10.4724 14.4291 10.4305 14.5321 10.3665 14.6213C10.3026 14.7104 10.2183 14.783 10.1207 14.8331C10.0231 14.8832 9.91495 14.9093 9.80524 14.9093H14.6419C14.7516 14.9093 14.8597 14.8831 14.9573 14.833C15.0549 14.7829 15.1391 14.7103 15.2031 14.6212C15.2671 14.5321 15.3089 14.429 15.3251 14.3205C15.3413 14.212 15.3315 14.1012 15.2964 13.9973L11.0991 1.56064Z" fill="url(#paint2_linear_301_1374)"/><defs><linearGradient id="paint0_linear_301_1374" x1="7.13811" y1="2.11468" x2="2.63127" y2="15.429" gradientUnits="userSpaceOnUse"><stop stop-color="#114A8B"/><stop offset="1" stop-color="#0669BC"/></linearGradient><linearGradient id="paint1_linear_301_1374" x1="8.54579" y1="8.31948" x2="7.50329" y2="8.67198" gradientUnits="userSpaceOnUse"><stop stop-opacity="0.3"/><stop offset="0.071" stop-opacity="0.2"/><stop offset="0.321" stop-opacity="0.1"/><stop offset="0.623" stop-opacity="0.05"/><stop offset="1" stop-opacity="0"/></linearGradient><linearGradient id="paint2_linear_301_1374" x1="7.97241" y1="1.7263" x2="12.9196" y2="14.9065" gradientUnits="userSpaceOnUse"><stop stop-color="#3CCBF4"/><stop offset="1" stop-color="#2892DF"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><g><path d="M3.37952 12.4729C3.72441 12.688 4.29774 12.9271 4.90396 12.9271C5.45596 12.9271 5.96885 12.7671 6.39374 12.4942C6.39374 12.4942 6.39463 12.4942 6.39552 12.4933L7.99996 11.4907V15.1111C7.74574 15.1111 7.48974 15.0418 7.26752 14.9031L3.37952 12.4729Z" fill="#225086"/><path d="M6.98044 1.33958L0.313775 8.85958C-0.200892 9.44092 -0.0666699 10.3191 0.600886 10.736C0.600886 10.736 3.06844 12.2782 3.37955 12.4729C3.72444 12.688 4.29777 12.9271 4.904 12.9271C5.456 12.9271 5.96889 12.7671 6.39377 12.4942C6.39377 12.4942 6.39466 12.4942 6.39555 12.4934L8 11.4907L4.12089 9.06581L8.00089 4.68892V0.888916C7.624 0.888916 7.24711 1.03914 6.98044 1.33958Z" fill="#66DDFF"/><path d="M4.12085 9.06579L4.16707 9.09424L7.99996 11.4907H8.00085V4.68979L7.99996 4.6889L4.12085 9.06579Z" fill="#CBF8FF"/><path d="M15.3991 10.736C16.0666 10.3191 16.2009 9.44091 15.6862 8.85958L11.312 3.92535C10.9591 3.76091 10.5635 3.66669 10.1449 3.66669C9.32263 3.66669 8.58752 4.02135 8.09863 4.57869L8.00174 4.68802L11.8809 9.06491L8.00085 11.4898V15.1102C8.25597 15.1102 8.51019 15.0409 8.73241 14.9022L15.3991 10.7351V10.736Z" fill="#074793"/><path d="M8.00089 0.888916V4.68892L8.09778 4.57958C8.58667 4.02225 9.32178 3.66758 10.144 3.66758C10.5636 3.66758 10.9582 3.76269 11.3111 3.92625L9.01867 1.34047C8.75289 1.04003 8.376 0.889805 8 0.889805L8.00089 0.888916Z" fill="#0294E4"/><path d="M11.88 9.06576L8.00085 4.68976V11.4898L11.88 9.06576Z" fill="#96BCC2"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 6.8 KiB |
|
|
@ -1,9 +1,11 @@
|
|||
import Anthropic from './custom/anthropic.svg';
|
||||
import Azure from './custom/azure.svg';
|
||||
import Binary from './custom/binary.svg';
|
||||
import BoltFilled from './custom/bolt-filled.svg';
|
||||
import Continue from './custom/continue.svg';
|
||||
import Discord from './custom/discord.svg';
|
||||
import EmptyOutput from './custom/empty-output.svg';
|
||||
import Entra from './custom/entra.svg';
|
||||
import FilledSquare from './custom/filled-square.svg';
|
||||
import Form from './custom/form.svg';
|
||||
import GripLinesVertical from './custom/grip-lines-vertical.svg';
|
||||
|
|
@ -539,6 +541,8 @@ export const updatedIconSet = {
|
|||
telegram: Telegram,
|
||||
discord: Discord,
|
||||
teams: Teams,
|
||||
azure: Azure,
|
||||
entra: Entra,
|
||||
spinner: Spinner,
|
||||
'node-dirty': NodeDirty,
|
||||
'node-ellipsis': NodeEllipsis,
|
||||
|
|
|
|||
|
|
@ -8888,44 +8888,41 @@
|
|||
"agents.channels.discord.setup.interactionsEndpoint.hint": "Save it as the Interactions Endpoint URL on the application's General Information page after the agent is connected and published.",
|
||||
"agents.channels.setup.publishNotice": "Publish this agent before adding the Interactions Endpoint URL in Discord.",
|
||||
"agents.channels.teams.messagingEndpointUrl.label": "Messaging endpoint URL",
|
||||
"agents.channels.teams.setup.createBot.title": "Create an Azure Bot",
|
||||
"agents.channels.teams.setup.createBot.description": "In the Azure portal, create a single-tenant Azure Bot with this endpoint, then turn on the Microsoft Teams channel. Already have a bot? Paste the endpoint into its Configuration page instead.",
|
||||
"agents.channels.teams.setup.createBot.title": "Deploy Azure Bot",
|
||||
"agents.channels.teams.setup.createBot.description": "Prefilled from your credential. You choose the subscription and resource group.",
|
||||
"agents.channels.teams.setup.createBot.button": "Deploy to Azure",
|
||||
"agents.channels.teams.setup.createBot.portalButton": "Open Azure portal",
|
||||
"agents.channels.teams.setup.createBot.hint": "The deployment fills in the endpoint and turns the Teams channel on. Paste your app registration's Application and Directory IDs into it, then pick a subscription and resource group.",
|
||||
"agents.channels.teams.setup.createBot.prerequisites": "You need a work or school account with an Azure subscription, and permission to register an app in Microsoft Entra. If either is missing, ask an administrator to create the bot and send you its Application and Directory IDs.",
|
||||
"agents.channels.teams.setup.connectBot.title": "Connect the bot",
|
||||
"agents.channels.teams.setup.connectBot.description": "In your bot's Azure page, open Test in Web Chat and send any message. We'll pick the bot up from it.",
|
||||
"agents.channels.teams.setup.connectBot.listening": "Listening for a message from your bot…",
|
||||
"agents.channels.teams.setup.connectBot.slow": "Taking long? Check the endpoint was saved and the Teams channel is on, or enter the values yourself.",
|
||||
"agents.channels.teams.setup.connectBot.manualLink": "enter the values yourself",
|
||||
"agents.channels.teams.setup.connectBot.expired": "We stopped listening. Start again when your bot is ready.",
|
||||
"agents.channels.teams.setup.connectBot.found": "Found your bot. Add its client secret to finish.",
|
||||
"agents.channels.teams.setup.connectBot.foundExisting": "Found your bot, and a credential that already has its details.",
|
||||
"agents.channels.teams.setup.connectBot.clientIdLabel": "Application (client) ID",
|
||||
"agents.channels.teams.setup.connectBot.tenantIdLabel": "Directory (tenant) ID",
|
||||
"agents.channels.teams.setup.connectBot.tenantMissing": "This message carried no tenant, which Web Chat does not send. Add your Directory (tenant) ID to the credential.",
|
||||
"agents.channels.teams.setup.availability.title": "Choose where it's available",
|
||||
"agents.channels.teams.setup.availability.description": "Set which conversations the agent can join. Changing these later means downloading and uploading the app again.",
|
||||
"agents.channels.teams.setup.createBot.hint": "You need an Azure subscription.",
|
||||
"agents.channels.teams.setup.availability.title": "Choose where it works",
|
||||
"agents.channels.teams.setup.availability.description": "Changing these later means downloading and uploading the app again.",
|
||||
"agents.channels.teams.setup.availability.directChat": "Direct chat",
|
||||
"agents.channels.teams.setup.availability.directChatHint": "Always on, so we can confirm the connection works.",
|
||||
"agents.channels.teams.setup.availability.directChatOnly": "Direct chat always on",
|
||||
"agents.channels.teams.setup.availability.teamChannels": "Team channels",
|
||||
"agents.channels.teams.setup.availability.teamChannelsHint": "Members can add and @mention the agent in a channel.",
|
||||
"agents.channels.teams.setup.availability.teamChannelsHint": "Members can add and {'@'}mention the agent in a channel",
|
||||
"agents.channels.teams.setup.availability.groupChats": "Group and meeting chats",
|
||||
"agents.channels.teams.setup.availability.groupChatsHint": "The agent can join multi-person and meeting conversations.",
|
||||
"agents.channels.teams.setup.availability.readingTitle": "What can it see?",
|
||||
"agents.channels.teams.setup.availability.groupChatsHint": "Anyone in the chat can add the agent and message it",
|
||||
"agents.channels.teams.setup.availability.readAllChannelMessages": "Read all channel messages",
|
||||
"agents.channels.teams.setup.availability.readAllChannelMessagesHint": "Otherwise it only sees messages that @mention it. Turn on team channels first.",
|
||||
"agents.channels.teams.setup.availability.readAllChannelMessagesHint": "Team owners approve this when they add the app",
|
||||
"agents.channels.teams.setup.availability.readAllGroupMessages": "Read all group-chat messages",
|
||||
"agents.channels.teams.setup.availability.readAllGroupMessagesHint": "Otherwise it only sees messages that @mention it. Turn on group and meeting chats first.",
|
||||
"agents.channels.teams.setup.availability.untested": "Team channels and group chats are not covered by tests yet. Direct chat is.",
|
||||
"agents.channels.teams.setup.install.title": "Install for me",
|
||||
"agents.channels.teams.setup.install.description": "Upload the app package in Teams: Apps, then Manage your apps, then Upload an app.",
|
||||
"agents.channels.teams.setup.availability.readAllGroupMessagesHint": "Approved by the person who adds the app to the chat",
|
||||
"agents.channels.teams.setup.registerApp.title": "Register application",
|
||||
"agents.channels.teams.setup.registerApp.description": "Go to Microsoft Entra and register a new application. Skip this step if you already have a credential.",
|
||||
"agents.channels.teams.setup.registerApp.hint": "Copy the Directory (tenant) ID and Application (client) ID. Then add a client secret and copy its value.",
|
||||
"agents.channels.teams.setup.registerApp.button": "Open Microsoft Entra",
|
||||
"agents.channels.teams.setup.availability.readsChannels": "Reads channels",
|
||||
"agents.channels.teams.setup.availability.readsGroupChats": "Reads group chats",
|
||||
"agents.channels.teams.setup.availability.summarySeparator": " · ",
|
||||
"agents.channels.teams.setup.install.identityTooltip": "Name and avatar come from the agent. Teams needs a description, so this default is used. To change any of it, edit the agent and download the package again.",
|
||||
"agents.channels.teams.setup.install.downloaded": "App package downloaded. Upload it in Teams under Apps › Manage your apps › Upload an app.",
|
||||
"agents.channels.teams.setup.createBot.needsCredential": "Pick a verified credential first. The deployment uses its Application and Directory IDs.",
|
||||
"agents.channels.teams.setup.createBot.needsFreeCredential": "Pick a credential that no other agent uses before you deploy.",
|
||||
"agents.channels.teams.setup.install.needsReady": "Pick a verified credential in step 2 first. The package uses its Application (client) ID.",
|
||||
"agents.channels.teams.setup.createBot.loadFailed": "Could not load the Teams setup for this agent.",
|
||||
"agents.channels.teams.setup.install.connectFailed": "The package downloaded, but the channel is not connected: {error}",
|
||||
"agents.channels.teams.setup.install.staleDownload": "The credential changed during the download, so that package is for the previous bot. Download it again.",
|
||||
"agents.channels.teams.setup.install.title": "Connect Microsoft Teams",
|
||||
"agents.channels.teams.setup.install.description": "Download the zip and upload it in Teams under Apps › Manage your apps › Upload an app.",
|
||||
"agents.channels.teams.setup.install.button": "Download app package",
|
||||
"agents.channels.teams.setup.install.needsBot": "Connect the bot first. The package needs its Application (client) ID.",
|
||||
"agents.channels.teams.setup.install.hint": "Upload the zip exactly as downloaded. If the option is missing, a Teams administrator must allow custom app uploads for your organisation.",
|
||||
"agents.channels.teams.setup.install.connectButton": "Connect",
|
||||
"agents.channels.teams.setup.install.needsCredential": "Add the credential in step 2 before connecting.",
|
||||
"agents.channels.teams.setup.install.hint": "No Upload an app option? Ask your Teams admin to allow custom app uploads.",
|
||||
"agents.channels.teams.setup.install.downloadFailed": "Could not download the app package. Try again.",
|
||||
"agents.channels.teams.setup.publishNotice": "Publish this agent before you message it in Teams.",
|
||||
"agents.channels.telegram.setup.credential.title": "Add credential",
|
||||
|
|
@ -9180,36 +9177,24 @@
|
|||
"promotions.bindings.editorClosed": "The editor closed. Open it again to continue.",
|
||||
"credentialEdit.typeUnavailable": "This credential type isn't available. Install it and try again.",
|
||||
"credentialEdit.savedLoadFailed": "The credential was saved, but its details couldn't be loaded. Retry to continue editing.",
|
||||
"agents.channels.teams.setup.connectBot.openBotHint": "Your bot is called {botName}. Open it, then choose Test in Web Chat.",
|
||||
"agents.channels.teams.setup.install.checking": "Checking the credential can reach Microsoft…",
|
||||
"agents.channels.teams.setup.install.verified": "The credential works. Microsoft issued a token for this bot.",
|
||||
"agents.channels.teams.setup.install.verified": "Credential verified with Microsoft Entra.",
|
||||
"agents.channels.teams.setup.install.failed.certificate": "This credential uses certificate authentication, which Teams channels cannot use. Switch it to Client Secret.",
|
||||
"agents.channels.teams.setup.install.failed.incomplete": "This credential is missing its tenant ID, client ID or client secret.",
|
||||
"agents.channels.teams.setup.install.failed.rejected": "Microsoft rejected these details. Check the client secret has not expired, and that the tenant and client IDs match the app registration.",
|
||||
"agents.channels.teams.setup.install.failed.unreachable": "Could not reach Microsoft to check the credential. Check this instance has outbound internet access, then try again.",
|
||||
"agents.channels.teams.setup.install.recheck": "Check again",
|
||||
"agents.channels.teams.setup.createBot.entraButton": "Register an app in Entra",
|
||||
"agents.channels.teams.setup.connectBot.openBotButton": "Open Azure Bot Services",
|
||||
"agents.channels.teams.setup.createCredential.title": "Register the app and add the credential",
|
||||
"agents.channels.teams.setup.createCredential.description": "Register an app in Microsoft Entra, add a client secret, then put its Directory (tenant) ID, Application (client) ID and secret into a credential here. Everything after this is built from it.",
|
||||
"agents.channels.teams.setup.createCredential.button": "Register an app in Microsoft Entra",
|
||||
"agents.channels.teams.setup.createCredential.prerequisites": "The three values come from a Microsoft Entra app registration. If you do not have one, create it first — you need a work or school account with permission to register apps, and an Azure subscription for the next step.",
|
||||
"agents.channels.teams.setup.createBot.needsCredential": "Add the credential first. The deployment needs its Application and Directory IDs.",
|
||||
"agents.channels.teams.setup.connectBot.mismatch": "A bot reached n8n, but it is not the one this credential describes. Check the endpoint is on the right bot.",
|
||||
"agents.channels.teams.setup.connectBot.skipped": "Check skipped. You can still finish, but nothing has confirmed the endpoint yet.",
|
||||
"agents.channels.teams.setup.createBot.existingBot": "Already have an Azure bot?",
|
||||
"agents.channels.teams.setup.createBot.existingBotHint": "Paste this into your bot's Configuration page as the messaging endpoint, and turn on the Microsoft Teams channel.",
|
||||
"agents.channels.teams.setup.install.openChat": "Open the chat in Teams",
|
||||
"agents.channels.teams.setup.createCredential.title": "Create credential",
|
||||
"agents.channels.teams.setup.createCredential.description": "Use the tenant ID, client ID and client secret.",
|
||||
"agents.channels.teams.setup.createBot.existingBot": "Use an existing Azure Bot",
|
||||
"agents.channels.teams.setup.createBot.existingBotHint": "Paste this into the bot’s Configuration page and turn on the Microsoft Teams channel.",
|
||||
"agents.channels.teams.setup.availability.whereTitle": "Where can people use the agent?",
|
||||
"agents.channels.teams.setup.availability.readingSummaryNone": "Only messages addressed to it",
|
||||
"agents.channels.teams.setup.availability.readingNote": "Team owners approve read access when they install the app.",
|
||||
"agents.channels.teams.settings.updateNotice": "Changes here update the app in Teams. Download the package again and upload it to apply them.",
|
||||
"agents.channels.teams.settings.displayName": "Agent name in Teams",
|
||||
"agents.channels.teams.settings.displayNamePlaceholder": "Uses the agent's name",
|
||||
"agents.channels.teams.settings.description": "Description",
|
||||
"agents.channels.teams.settings.descriptionPlaceholder": "What this agent helps with",
|
||||
"agents.channels.teams.settings.descriptionDefault": "Chat with {name}, an agent powered by n8n.",
|
||||
"agents.channels.teams.setup.createCredential.claimed": "{agent} already uses this credential for Teams. Microsoft allows one bot for each app registration, so pick a different credential or register another app in Entra.",
|
||||
"agents.channels.teams.setup.createBot.needsFreeCredential": "Pick a credential no other agent uses before deploying.",
|
||||
"agents.channels.teams.setup.createCredential.claimed": "Already connected to {agent}. Register a new app for this agent, or disconnect it there first.",
|
||||
"agents.channels.teams.setup.install.failed.cloud": "Teams channels only reach the global Microsoft cloud. Set the credential's Microsoft Graph API base URL back to https://graph.microsoft.com."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ vi.mock('../composables/useAgentTelemetry', () => ({
|
|||
vi.mock('../channels/registry', async () => {
|
||||
const { ref, defineComponent } = await import('vue');
|
||||
const platformView = {
|
||||
props: ['modelValue', 'mode', 'isPublished', 'runtime'],
|
||||
props: ['modelValue', 'mode', 'isPublished', 'runtime', 'ensureAgentPersisted'],
|
||||
emits: ['update:modelValue', 'connect', 'connected'],
|
||||
setup: () => {
|
||||
// Platforms that drive their own flow (Slack) report `connected` while
|
||||
|
|
@ -91,6 +91,7 @@ vi.mock('../channels/registry', async () => {
|
|||
>
|
||||
<button data-testid="select-credential" @click="$emit('update:modelValue', 'credential-new')" />
|
||||
<button data-testid="connect-channel" @click="$emit('connect')" />
|
||||
<button data-testid="persist-agent" @click="ensureAgentPersisted?.()" />
|
||||
<button data-testid="platform-own-flow" @click="startOwnFlow(); $emit('connected')" />
|
||||
</div>
|
||||
`,
|
||||
|
|
@ -397,6 +398,14 @@ describe('AgentChannelModal', () => {
|
|||
expect(wrapper.emitted('agent-changed')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('lets the platform view save the agent before it needs agent-scoped data', async () => {
|
||||
const wrapper = mountModal('example_setup');
|
||||
|
||||
await wrapper.get('[data-testid="persist-agent"]').trigger('click');
|
||||
|
||||
expect(mocks.ensureAgentPersisted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe('while agent persistence is pending', () => {
|
||||
function deferPersistence() {
|
||||
let release: () => void = () => {};
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
* manifest fields: changing one produces a new app package either way.
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import { N8nCollapsiblePanel, N8nIcon, N8nSwitch2, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nSettingsRow, N8nSettingsRowGroup, N8nSwitch2 } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
export interface TeamsAvailability {
|
||||
teamChannels: boolean;
|
||||
|
|
@ -24,10 +24,9 @@ const props = withDefaults(defineProps<{ startCollapsed?: boolean }>(), {
|
|||
|
||||
const i18n = useI18n();
|
||||
|
||||
// Local, because `startCollapsed` is only where the panels begin: bound
|
||||
// straight to the panel, the open state it emits has nothing to apply it.
|
||||
const whereOpen = ref(!props.startCollapsed);
|
||||
const readingOpen = ref(!props.startCollapsed);
|
||||
// Local, because `startCollapsed` is only where the panel begins: bound
|
||||
// straight to the row, the open state it emits has nothing to apply it.
|
||||
const open = ref(!props.startCollapsed);
|
||||
|
||||
function set(patch: Partial<TeamsAvailability>) {
|
||||
const next = { ...value.value, ...patch };
|
||||
|
|
@ -38,214 +37,109 @@ function set(patch: Partial<TeamsAvailability>) {
|
|||
value.value = next;
|
||||
}
|
||||
|
||||
/** Collapsed panels still have to say what they are set to. */
|
||||
const whereSummary = computed(() =>
|
||||
[
|
||||
i18n.baseText('agents.channels.teams.setup.availability.directChat'),
|
||||
...(value.value.teamChannels
|
||||
? [i18n.baseText('agents.channels.teams.setup.availability.teamChannels')]
|
||||
: []),
|
||||
...(value.value.groupChats
|
||||
? [i18n.baseText('agents.channels.teams.setup.availability.groupChats')]
|
||||
: []),
|
||||
].join(', '),
|
||||
);
|
||||
interface Row {
|
||||
key: keyof TeamsAvailability;
|
||||
title: BaseTextKey;
|
||||
hint: BaseTextKey;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
const readingSummary = computed(() => {
|
||||
const reads = [
|
||||
...(value.value.readAllChannelMessages
|
||||
? [i18n.baseText('agents.channels.teams.setup.availability.readAllChannelMessages')]
|
||||
: []),
|
||||
...(value.value.readAllGroupMessages
|
||||
? [i18n.baseText('agents.channels.teams.setup.availability.readAllGroupMessages')]
|
||||
: []),
|
||||
];
|
||||
return reads.length > 0
|
||||
? reads.join(', ')
|
||||
: i18n.baseText('agents.channels.teams.setup.availability.readingSummaryNone');
|
||||
const TEAM_CHANNELS: Row = {
|
||||
key: 'teamChannels',
|
||||
title: 'agents.channels.teams.setup.availability.teamChannels',
|
||||
hint: 'agents.channels.teams.setup.availability.teamChannelsHint',
|
||||
testId: 'teams-scope-channels',
|
||||
};
|
||||
const READ_CHANNELS: Row = {
|
||||
key: 'readAllChannelMessages',
|
||||
title: 'agents.channels.teams.setup.availability.readAllChannelMessages',
|
||||
hint: 'agents.channels.teams.setup.availability.readAllChannelMessagesHint',
|
||||
testId: 'teams-read-channels',
|
||||
};
|
||||
const GROUP_CHATS: Row = {
|
||||
key: 'groupChats',
|
||||
title: 'agents.channels.teams.setup.availability.groupChats',
|
||||
hint: 'agents.channels.teams.setup.availability.groupChatsHint',
|
||||
testId: 'teams-scope-groups',
|
||||
};
|
||||
const READ_GROUPS: Row = {
|
||||
key: 'readAllGroupMessages',
|
||||
title: 'agents.channels.teams.setup.availability.readAllGroupMessages',
|
||||
hint: 'agents.channels.teams.setup.availability.readAllGroupMessagesHint',
|
||||
testId: 'teams-read-groups',
|
||||
};
|
||||
|
||||
// A read permission is offered only while its surface is on, since it depends on it.
|
||||
const rows = computed(() => [
|
||||
TEAM_CHANNELS,
|
||||
...(value.value.teamChannels ? [READ_CHANNELS] : []),
|
||||
GROUP_CHATS,
|
||||
...(value.value.groupChats ? [READ_GROUPS] : []),
|
||||
]);
|
||||
|
||||
/** A collapsed panel still has to say what it is set to. */
|
||||
const summary = computed(() => {
|
||||
const { teamChannels, readAllChannelMessages, groupChats, readAllGroupMessages } = value.value;
|
||||
const on = [
|
||||
teamChannels && 'agents.channels.teams.setup.availability.teamChannels',
|
||||
teamChannels &&
|
||||
readAllChannelMessages &&
|
||||
'agents.channels.teams.setup.availability.readsChannels',
|
||||
groupChats && 'agents.channels.teams.setup.availability.groupChats',
|
||||
groupChats &&
|
||||
readAllGroupMessages &&
|
||||
'agents.channels.teams.setup.availability.readsGroupChats',
|
||||
].filter((key): key is BaseTextKey => Boolean(key));
|
||||
if (on.length === 0) {
|
||||
return i18n.baseText('agents.channels.teams.setup.availability.directChatOnly');
|
||||
}
|
||||
return [
|
||||
i18n.baseText('agents.channels.teams.setup.availability.directChat'),
|
||||
...on.map((key) => i18n.baseText(key)),
|
||||
].join(i18n.baseText('agents.channels.teams.setup.availability.summarySeparator'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.panels">
|
||||
<N8nCollapsiblePanel v-model="whereOpen" :class="$style.panel">
|
||||
<template #title>
|
||||
<span :class="$style.panelTitle">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.whereTitle') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint" data-testid="teams-where-summary">
|
||||
{{ whereSummary }}
|
||||
</N8nText>
|
||||
</span>
|
||||
<N8nSettingsRowGroup :class="$style.group">
|
||||
<N8nSettingsRow
|
||||
v-model="open"
|
||||
:title="i18n.baseText('agents.channels.teams.setup.availability.whereTitle')"
|
||||
:description="summary"
|
||||
expandable
|
||||
expand-label=""
|
||||
collapse-label=""
|
||||
:show-divider="false"
|
||||
data-testid="teams-availability"
|
||||
>
|
||||
<template #expanded>
|
||||
<N8nSettingsRow
|
||||
v-for="row in rows"
|
||||
:key="row.key"
|
||||
:title="i18n.baseText(row.title)"
|
||||
:description="i18n.baseText(row.hint)"
|
||||
>
|
||||
<template #action>
|
||||
<N8nSwitch2
|
||||
:model-value="value[row.key]"
|
||||
:aria-label="i18n.baseText(row.title)"
|
||||
:data-testid="row.testId"
|
||||
@update:model-value="set({ [row.key]: $event })"
|
||||
/>
|
||||
</template>
|
||||
</N8nSettingsRow>
|
||||
</template>
|
||||
|
||||
<div :class="$style.panelBody">
|
||||
<div :class="$style.row" data-testid="teams-scope-direct">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.directChat') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.directChatHint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<!-- Fixed, so a tick rather than a control that cannot move. -->
|
||||
<N8nIcon icon="check" size="small" :class="$style.fixed" />
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.teamChannels') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.teamChannelsHint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="value.teamChannels"
|
||||
:aria-label="i18n.baseText('agents.channels.teams.setup.availability.teamChannels')"
|
||||
data-testid="teams-scope-channels"
|
||||
@update:model-value="set({ teamChannels: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.groupChats') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.groupChatsHint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="value.groupChats"
|
||||
:aria-label="i18n.baseText('agents.channels.teams.setup.availability.groupChats')"
|
||||
data-testid="teams-scope-groups"
|
||||
@update:model-value="set({ groupChats: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</N8nCollapsiblePanel>
|
||||
|
||||
<N8nCollapsiblePanel v-model="readingOpen" :class="$style.panel">
|
||||
<template #title>
|
||||
<span :class="$style.panelTitle">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.readingTitle') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint" data-testid="teams-reading-summary">
|
||||
{{ readingSummary }}
|
||||
</N8nText>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div :class="$style.panelBody">
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.readingNote') }}
|
||||
</N8nText>
|
||||
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small" :class="{ [$style.hint]: !value.teamChannels }">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.readAllChannelMessages') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{
|
||||
i18n.baseText('agents.channels.teams.setup.availability.readAllChannelMessagesHint')
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="value.readAllChannelMessages"
|
||||
:disabled="!value.teamChannels"
|
||||
:aria-label="
|
||||
i18n.baseText('agents.channels.teams.setup.availability.readAllChannelMessages')
|
||||
"
|
||||
data-testid="teams-read-channels"
|
||||
@update:model-value="set({ readAllChannelMessages: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small" :class="{ [$style.hint]: !value.groupChats }">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.readAllGroupMessages') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{
|
||||
i18n.baseText('agents.channels.teams.setup.availability.readAllGroupMessagesHint')
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="value.readAllGroupMessages"
|
||||
:disabled="!value.groupChats"
|
||||
:aria-label="
|
||||
i18n.baseText('agents.channels.teams.setup.availability.readAllGroupMessages')
|
||||
"
|
||||
data-testid="teams-read-groups"
|
||||
@update:model-value="set({ readAllGroupMessages: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</N8nCollapsiblePanel>
|
||||
</div>
|
||||
</N8nSettingsRow>
|
||||
</N8nSettingsRowGroup>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
text-align: left;
|
||||
/* Air between the chevron and the heading. */
|
||||
padding-left: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
/* One left edge for every row and note, matching the header's own padding. */
|
||||
.panelBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--spacing--xs) var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--sm);
|
||||
padding: var(--spacing--2xs) 0;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-color--subtler);
|
||||
}
|
||||
|
||||
.fixed {
|
||||
color: var(--text-color--subtler);
|
||||
flex-shrink: 0;
|
||||
/*
|
||||
* The group paints the page surface, which in dark mode is darker than the
|
||||
* modal it sits in. Letting the modal show through keeps it flat in both themes.
|
||||
* The doubled class outranks the group's own single-class rule.
|
||||
*/
|
||||
.group.group {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { configure, fireEvent, waitFor } from '@testing-library/vue';
|
||||
import { configure, fireEvent, waitFor, within } from '@testing-library/vue';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
import AgentChannelTeamsSetup from './AgentChannelTeamsSetup.vue';
|
||||
import type { TeamsCredentialCheck } from '@n8n/api-types';
|
||||
|
|
@ -27,6 +29,11 @@ vi.mock('file-saver', () => ({
|
|||
saveAs: vi.fn(),
|
||||
}));
|
||||
|
||||
const { showMessage, showError } = vi.hoisted(() => ({ showMessage: vi.fn(), showError: vi.fn() }));
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showMessage, showError }),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
checkTeamsCredential: vi.fn(),
|
||||
getTeamsSetupState: vi.fn(),
|
||||
|
|
@ -48,6 +55,35 @@ const renderComponent = createComponentRenderer(AgentChannelTeamsSetup);
|
|||
// N8nSwitch2 is a Reka UI switch: a button with aria-checked, not an input.
|
||||
const checkedSwitch = (el: HTMLElement) => el.getAttribute('aria-checked') === 'true';
|
||||
|
||||
// A picked credential has a bot ID, which unlocks the last two steps once verified.
|
||||
const withBot = () =>
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
||||
const WHERE_TITLE = 'agents.channels.teams.setup.availability.whereTitle';
|
||||
const SUMMARY_SEPARATOR = 'agents.channels.teams.setup.availability.summarySeparator';
|
||||
|
||||
// The panel starts collapsed and mounts its rows only once opened. In setup it
|
||||
// also stays inert until the credential is verified.
|
||||
const openAvailability = async (getByTestId: (id: string) => HTMLElement) => {
|
||||
await waitFor(() => expect(getByTestId('teams-availability').closest('[inert]')).toBeNull());
|
||||
await fireEvent.click(
|
||||
within(getByTestId('teams-availability')).getByLabelText(`Toggle ${WHERE_TITLE}`),
|
||||
);
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible());
|
||||
};
|
||||
|
||||
const expectSummary = async (getByTestId: (id: string) => HTMLElement, summary: string) =>
|
||||
await waitFor(() =>
|
||||
expect(within(getByTestId('teams-availability')).getByText(summary)).toBeVisible(),
|
||||
);
|
||||
|
||||
const props = (overrides: Record<string, unknown> = {}) => ({
|
||||
mode: 'setup' as const,
|
||||
modelValue: '',
|
||||
|
|
@ -83,31 +119,63 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('step 1, the credential', () => {
|
||||
it('leads with the Entra registration the rest is built from', async () => {
|
||||
describe('step 1, register the app', () => {
|
||||
it('links to the Entra registration the rest is built from', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-entra-register-link')).toBeVisible());
|
||||
});
|
||||
|
||||
it('says up front what account and permissions are needed', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-create-bot-prerequisites')).toBeVisible());
|
||||
});
|
||||
|
||||
it('comes before the deployment, which cannot be filled in without it', async () => {
|
||||
it('comes before the credential, which is made from it', async () => {
|
||||
const { container } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(container.textContent).toContain('setup.createCredential.title'));
|
||||
await waitFor(() => expect(container.textContent).toContain('setup.registerApp.title'));
|
||||
const text = container.textContent ?? '';
|
||||
expect(text.indexOf('setup.registerApp.title')).toBeLessThan(
|
||||
text.indexOf('setup.createCredential.title'),
|
||||
);
|
||||
expect(text.indexOf('setup.createCredential.title')).toBeLessThan(
|
||||
text.indexOf('setup.createBot.title'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 2, deploy the bot', () => {
|
||||
describe('step 2, the credential', () => {
|
||||
it('confirms a credential that reaches Microsoft', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-verified')).toBeVisible());
|
||||
});
|
||||
|
||||
it('says nothing about a credential until one is picked', async () => {
|
||||
const { queryByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getTeamsSetupState).toHaveBeenCalled());
|
||||
expect(queryByTestId('teams-credential-verified')).toBeNull();
|
||||
expect(queryByTestId('teams-credential-problem')).toBeNull();
|
||||
});
|
||||
|
||||
it('explains a rejected credential and offers a retry', async () => {
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({
|
||||
status: 'failed',
|
||||
reason: 'unreachable',
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('teams-credential-problem')).toHaveTextContent(
|
||||
'setup.install.failed.unreachable',
|
||||
),
|
||||
);
|
||||
|
||||
vi.mocked(checkTeamsCredential).mockClear();
|
||||
await fireEvent.click(getByTestId('teams-credential-recheck'));
|
||||
|
||||
await waitFor(() => expect(checkTeamsCredential).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 3, deploy the bot', () => {
|
||||
it('keeps the endpoint URL out of the way, since the deployment sets it', async () => {
|
||||
const { getByTestId, container } = renderComponent({ props: props() });
|
||||
|
||||
|
|
@ -126,7 +194,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('withholds the deployment until a credential supplies the Entra IDs', async () => {
|
||||
it('disables the deployment until a credential supplies the Entra IDs', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: null,
|
||||
|
|
@ -136,13 +204,13 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent({ props: props() });
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-deploy-blocked')).toBeVisible());
|
||||
expect(queryByTestId('teams-deploy-to-azure')).toBeNull();
|
||||
await waitFor(() => expect(getByTestId('teams-deploy-to-azure')).toBeDisabled());
|
||||
expect(getByTestId('teams-deploy-blocked')).toHaveTextContent('createBot.needsCredential');
|
||||
});
|
||||
|
||||
it('offers the deployment once the credential is picked', async () => {
|
||||
it('offers the deployment once the credential checks out', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -150,6 +218,17 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('withholds the deployment while the credential fails its check', async () => {
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({ status: 'failed', reason: 'rejected' });
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-problem')).toBeVisible());
|
||||
expect(getByTestId('teams-deploy-to-azure')).toBeDisabled();
|
||||
expect(getByTestId('teams-deploy-to-azure')).not.toHaveAttribute('href');
|
||||
expect(getByTestId('teams-deploy-blocked')).toHaveTextContent('createBot.needsCredential');
|
||||
});
|
||||
|
||||
it('rebuilds the deployment when the credential changes, since it is baked in', async () => {
|
||||
const { rerender } = renderComponent({ props: props() });
|
||||
await waitFor(() => expect(getTeamsSetupState).toHaveBeenCalled());
|
||||
|
|
@ -163,106 +242,212 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('step 3, availability', () => {
|
||||
it('shows direct chat as fixed, with no control to change it', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
describe('step 4, availability', () => {
|
||||
it('stays locked until the credential checks out', async () => {
|
||||
withBot();
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({ status: 'failed', reason: 'rejected' });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-direct')).toBeVisible());
|
||||
expect(getByTestId('teams-scope-direct').querySelector('[role="switch"]')).toBeNull();
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-problem')).toBeVisible());
|
||||
// Inert, so keyboard focus cannot reach the panel either.
|
||||
expect(getByTestId('teams-availability-step')).toHaveAttribute('inert');
|
||||
});
|
||||
|
||||
it('starts with everything else off', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
it('starts collapsed, summarised, with everything but direct chat off', async () => {
|
||||
withBot();
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible());
|
||||
await expectSummary(getByTestId, 'agents.channels.teams.setup.availability.directChatOnly');
|
||||
expect(queryByTestId('teams-scope-channels')).toBeNull();
|
||||
|
||||
await openAvailability(getByTestId);
|
||||
expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(false);
|
||||
expect(checkedSwitch(getByTestId('teams-scope-groups'))).toBe(false);
|
||||
});
|
||||
|
||||
it('summarises each panel, so a collapsed one still says what it is set to', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-where-summary')).toBeVisible());
|
||||
expect(getByTestId('teams-where-summary').textContent).toContain(
|
||||
'setup.availability.directChat',
|
||||
);
|
||||
expect(getByTestId('teams-reading-summary').textContent).toContain(
|
||||
'setup.availability.readingSummaryNone',
|
||||
);
|
||||
it('shows a read permission only while its surface is on', async () => {
|
||||
withBot();
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
await openAvailability(getByTestId);
|
||||
|
||||
expect(queryByTestId('teams-read-channels')).toBeNull();
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('teams-where-summary').textContent).toContain(
|
||||
'setup.availability.teamChannels',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses an open panel when its header is clicked', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-where-summary'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).not.toBeVisible());
|
||||
});
|
||||
|
||||
it('keeps a read permission locked until its surface is on', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-read-channels')).toBeVisible());
|
||||
expect(getByTestId('teams-read-channels')).toBeDisabled();
|
||||
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-read-channels')).not.toBeDisabled());
|
||||
expect(queryByTestId('teams-read-groups')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a read permission when its surface is turned back off', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
it('summarises each choice, so the collapsed panel says what it is set to', async () => {
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await openAvailability(getByTestId);
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
await waitFor(() => expect(getByTestId('teams-read-channels')).not.toBeDisabled());
|
||||
await waitFor(() => expect(getByTestId('teams-read-channels')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-read-channels'));
|
||||
await waitFor(() => expect(checkedSwitch(getByTestId('teams-read-channels'))).toBe(true));
|
||||
|
||||
await expectSummary(
|
||||
getByTestId,
|
||||
[
|
||||
'agents.channels.teams.setup.availability.directChat',
|
||||
'agents.channels.teams.setup.availability.teamChannels',
|
||||
'agents.channels.teams.setup.availability.readsChannels',
|
||||
].join(SUMMARY_SEPARATOR),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears a read permission when its surface goes off, so turning it back on starts cleared', async () => {
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await openAvailability(getByTestId);
|
||||
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
await waitFor(() => expect(getByTestId('teams-read-channels')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-read-channels'));
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
|
||||
await waitFor(() => expect(checkedSwitch(getByTestId('teams-read-channels'))).toBe(false));
|
||||
});
|
||||
|
||||
it('restores saved settings', async () => {
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({
|
||||
props: props({ savedSettings: { teamChannels: true, readAllChannelMessages: true } }),
|
||||
props: props({
|
||||
modelValue: 'cred-1',
|
||||
savedSettings: { teamChannels: true, readAllChannelMessages: true },
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(true));
|
||||
await openAvailability(getByTestId);
|
||||
expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(true);
|
||||
expect(checkedSwitch(getByTestId('teams-read-channels'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 4, install', () => {
|
||||
it('withholds the package until a credential is picked', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({ props: props() });
|
||||
describe('step 5, connect', () => {
|
||||
it('shows the identity Teams will display', async () => {
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-package-blocked')).toBeVisible());
|
||||
expect(queryByTestId('teams-download-package')).toBeNull();
|
||||
await waitFor(() => expect(getByTestId('teams-identity')).toHaveTextContent(DEFAULT_NAME));
|
||||
expect(getByTestId('teams-identity')).toHaveTextContent(DEFAULT_DESCRIPTION);
|
||||
});
|
||||
|
||||
it('stays locked until the credential checks out', async () => {
|
||||
withBot();
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({ status: 'failed', reason: 'rejected' });
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-problem')).toBeVisible());
|
||||
expect(getByTestId('teams-download-package')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('stays locked while no credential is picked, and says why', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeDisabled());
|
||||
expect(getByTestId('teams-package-blocked')).toBeVisible();
|
||||
});
|
||||
|
||||
const connectAndFail = async () => {
|
||||
withBot();
|
||||
const utils = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(utils.getByTestId('teams-download-package')).toBeEnabled());
|
||||
await fireEvent.click(utils.getByTestId('teams-download-package'));
|
||||
await waitFor(() => expect(utils.emitted().connect).toHaveLength(1));
|
||||
await utils.rerender(props({ modelValue: 'cred-1', errorMessage: 'Bot rejected' }));
|
||||
return utils;
|
||||
};
|
||||
|
||||
it('shows a failed connect next to the button that started it, and only there', async () => {
|
||||
const { getByTestId, queryByText } = await connectAndFail();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('teams-connect-error')).toHaveTextContent('Bot rejected'),
|
||||
);
|
||||
expect(queryByText('Bot rejected')).toBeNull();
|
||||
});
|
||||
|
||||
it('retries a failed connect without downloading the package again', async () => {
|
||||
const { getByTestId, emitted } = await connectAndFail();
|
||||
await waitFor(() => expect(getByTestId('teams-connect-retry')).toBeVisible());
|
||||
|
||||
await fireEvent.click(getByTestId('teams-connect-retry'));
|
||||
|
||||
expect(emitted().connect).toHaveLength(2);
|
||||
expect(fetchTeamsAppPackage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a conflict in step 2, since it is about the credential', async () => {
|
||||
withBot();
|
||||
const { getByText, queryByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1', errorMessage: 'Bot in use', errorIsConflict: true }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByText('Bot in use')).toBeVisible());
|
||||
expect(queryByTestId('teams-connect-error')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not connect a credential picked while the package downloads', async () => {
|
||||
withBot();
|
||||
let release: ((blob: Blob) => void) | undefined;
|
||||
vi.mocked(fetchTeamsAppPackage).mockImplementation(
|
||||
async () => await new Promise((resolve) => (release = resolve)),
|
||||
);
|
||||
const { getByTestId, emitted, rerender } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
await waitFor(() => expect(fetchTeamsAppPackage).toHaveBeenCalled());
|
||||
|
||||
await rerender(props({ modelValue: 'cred-2' }));
|
||||
release?.(new Blob(['zip']));
|
||||
await flushPromises();
|
||||
|
||||
expect(emitted().connect).toBeFalsy();
|
||||
expect(showMessage).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(getByTestId('teams-stale-download')).toBeVisible());
|
||||
});
|
||||
|
||||
it('offers the package on a connected channel even when the check does not pass', async () => {
|
||||
withBot();
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({
|
||||
status: 'failed',
|
||||
reason: 'unreachable',
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1', connected: true }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
});
|
||||
|
||||
it('does not connect again when the channel is already connected', async () => {
|
||||
withBot();
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1', connected: true }),
|
||||
});
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
await waitFor(() => expect(saveAs).toHaveBeenCalled());
|
||||
expect(emitted().connect).toBeFalsy();
|
||||
});
|
||||
|
||||
it('fetches the package rather than linking to it, so the session survives', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ connected: true }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible());
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
expect(getByTestId('teams-download-package')).not.toHaveAttribute('href');
|
||||
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
|
@ -271,66 +456,51 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
expect(vi.mocked(fetchTeamsAppPackage).mock.calls[0]?.slice(1, 3)).toEqual(['p', 'a']);
|
||||
});
|
||||
|
||||
it('reports a failed download instead of silently doing nothing', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
it('connects once the package is saved, since the modal closes on connect', async () => {
|
||||
withBot();
|
||||
const { getByTestId, emitted } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
await waitFor(() => expect(emitted().connect).toBeTruthy());
|
||||
expect(saveAs).toHaveBeenCalled();
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'agents.channels.teams.setup.install.downloaded' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing more once the view is gone before the download finishes', async () => {
|
||||
withBot();
|
||||
let release: ((blob: Blob) => void) | undefined;
|
||||
vi.mocked(fetchTeamsAppPackage).mockImplementation(
|
||||
async () => await new Promise((resolve) => (release = resolve)),
|
||||
);
|
||||
const { getByTestId, emitted, unmount } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
await waitFor(() => expect(fetchTeamsAppPackage).toHaveBeenCalled());
|
||||
|
||||
unmount();
|
||||
release?.(new Blob(['zip']));
|
||||
await flushPromises();
|
||||
|
||||
expect(showMessage).not.toHaveBeenCalled();
|
||||
expect(emitted().connect).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reports a failed download and does not connect', async () => {
|
||||
withBot();
|
||||
vi.mocked(fetchTeamsAppPackage).mockRejectedValue(new Error('401'));
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ connected: true }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible());
|
||||
const { getByTestId, emitted } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-error')).toBeVisible());
|
||||
});
|
||||
|
||||
it('keeps connecting locked until the credential reaches Microsoft', async () => {
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({ status: 'failed', reason: 'rejected' });
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-problem')).toBeVisible());
|
||||
expect(getByTestId('teams-connect')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('unlocks saving once the credential checks out, without announcing it', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).not.toBeDisabled());
|
||||
// An enabled button says it already; a success line would only repeat it.
|
||||
expect(queryByTestId('teams-credential-problem')).toBeNull();
|
||||
expect(queryByTestId('teams-connect-blocked')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers a retry when the check failed', async () => {
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({
|
||||
status: 'failed',
|
||||
reason: 'unreachable',
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(getByTestId('teams-credential-recheck')).toBeVisible());
|
||||
|
||||
vi.mocked(checkTeamsCredential).mockClear();
|
||||
await fireEvent.click(getByTestId('teams-credential-recheck'));
|
||||
|
||||
await waitFor(() => expect(checkTeamsCredential).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('keeps connecting for last, because the modal closes on it', async () => {
|
||||
const { getByTestId, emitted } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-connect'));
|
||||
|
||||
expect(emitted().connect).toBeTruthy();
|
||||
expect(emitted().connect).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -351,23 +521,18 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
expect(getByTestId('teams-description')).toBeVisible();
|
||||
});
|
||||
|
||||
it('starts the availability panels collapsed, summarised', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
it('starts the availability panel collapsed, summarised', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-where-summary')).toBeVisible());
|
||||
// Rendered but folded away, so settings opens on the summary rather than
|
||||
// on five controls.
|
||||
expect(getByTestId('teams-scope-channels')).not.toBeVisible();
|
||||
expect(getByTestId('teams-reading-summary')).toBeVisible();
|
||||
// Settings opens on the summary rather than on every control.
|
||||
await expectSummary(getByTestId, 'agents.channels.teams.setup.availability.directChatOnly');
|
||||
expect(queryByTestId('teams-scope-channels')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens a collapsed panel when its header is clicked', async () => {
|
||||
it('opens a collapsed panel with its chevron', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-where-summary')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-where-summary'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible());
|
||||
await openAvailability(getByTestId);
|
||||
});
|
||||
|
||||
it('shows what the manifest would use as a placeholder, not as a value', async () => {
|
||||
|
|
@ -414,19 +579,17 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
expect(getByTestId('teams-description').querySelector('input')).toHaveValue(
|
||||
'Answers questions',
|
||||
);
|
||||
// Mounted while collapsed, so its state is readable without expanding.
|
||||
expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(true);
|
||||
await expectSummary(
|
||||
getByTestId,
|
||||
[
|
||||
'agents.channels.teams.setup.availability.directChat',
|
||||
'agents.channels.teams.setup.availability.teamChannels',
|
||||
].join(SUMMARY_SEPARATOR),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the package again, so a change can be applied', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
withBot();
|
||||
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
|
|
@ -462,18 +625,11 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
});
|
||||
|
||||
it('downloads the package with the availability chosen in the stepper, not the stored one', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
withBot();
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible());
|
||||
await openAvailability(getByTestId);
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
|
|
@ -511,7 +667,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('offers neither the deployment nor the package', async () => {
|
||||
it('offers neither the deployment nor the package, though the credential works', async () => {
|
||||
claimed();
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
|
|
@ -519,17 +675,86 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-claimed')).toBeVisible());
|
||||
expect(queryByTestId('teams-deploy-to-azure')).toBeNull();
|
||||
expect(queryByTestId('teams-download-package')).toBeNull();
|
||||
expect(queryByTestId('teams-credential-verified')).toBeNull();
|
||||
expect(getByTestId('teams-deploy-to-azure')).toBeDisabled();
|
||||
expect(getByTestId('teams-download-package')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an agent that is not saved yet', () => {
|
||||
it('saves the agent before asking for the setup state of a picked credential', async () => {
|
||||
const ensureAgentPersisted = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderComponent({ props: props({ modelValue: 'cred-1', ensureAgentPersisted }) });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getTeamsSetupState).toHaveBeenCalledWith(expect.anything(), 'p', 'a', 'cred-1'),
|
||||
);
|
||||
expect(ensureAgentPersisted.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(getTeamsSetupState).mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps connect disabled even though the credential itself works', async () => {
|
||||
claimed();
|
||||
it('does not save the agent while no credential is picked', async () => {
|
||||
const ensureAgentPersisted = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
renderComponent({ props: props({ ensureAgentPersisted }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-claimed')).toBeVisible());
|
||||
expect(getByTestId('teams-connect')).toBeDisabled();
|
||||
await waitFor(() => expect(getTeamsSetupState).toHaveBeenCalled());
|
||||
expect(ensureAgentPersisted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the last steps locked when saving the agent fails', async () => {
|
||||
withBot();
|
||||
const ensureAgentPersisted = vi.fn().mockRejectedValue(new Error('offline'));
|
||||
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1', ensureAgentPersisted }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-verified')).toBeVisible());
|
||||
expect(getTeamsSetupState).not.toHaveBeenCalledWith(expect.anything(), 'p', 'a', 'cred-1');
|
||||
expect(getByTestId('teams-download-package')).toBeDisabled();
|
||||
expect(emitted().connect).toBeFalsy();
|
||||
expect(showError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
'agents.channels.modal.saveChannelError',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a retry that saves the agent again', async () => {
|
||||
withBot();
|
||||
const ensureAgentPersisted = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1', ensureAgentPersisted }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-setup-load-failed')).toBeVisible());
|
||||
// The credential is verified, so a hint pointing back at it would be wrong.
|
||||
expect(queryByTestId('teams-package-blocked')).toBeNull();
|
||||
|
||||
await fireEvent.click(getByTestId('teams-setup-retry'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
expect(ensureAgentPersisted).toHaveBeenCalledTimes(2);
|
||||
expect(queryByTestId('teams-setup-load-failed')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not report a save failure when only the setup state fails to load', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockRejectedValue(new Error('offline'));
|
||||
const ensureAgentPersisted = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderComponent({ props: props({ modelValue: 'cred-1', ensureAgentPersisted }) });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getTeamsSetupState).toHaveBeenCalledWith(expect.anything(), 'p', 'a', 'cred-1'),
|
||||
);
|
||||
await flushPromises();
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -540,15 +765,16 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
.mockResolvedValueOnce({ status: 'ok' })
|
||||
.mockImplementationOnce(async () => await new Promise((resolve) => (release = resolve)));
|
||||
|
||||
withBot();
|
||||
const { getByTestId, rerender } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).toBeEnabled());
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
|
||||
await rerender(props({ modelValue: 'cred-2' }));
|
||||
|
||||
// The second check has not answered yet, so nothing is verified.
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).toBeDisabled());
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeDisabled());
|
||||
release?.({ status: 'ok' });
|
||||
});
|
||||
|
||||
|
|
@ -560,7 +786,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
)
|
||||
.mockResolvedValueOnce({ status: 'failed', reason: 'rejected' });
|
||||
|
||||
const { getByTestId, rerender } = renderComponent({
|
||||
const { getByTestId, queryByTestId, rerender } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
await rerender(props({ modelValue: 'cred-2' }));
|
||||
|
|
@ -568,16 +794,17 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
|
||||
// The first credential's answer lands last and must be discarded.
|
||||
releaseFirst?.({ status: 'ok' });
|
||||
await flushPromises();
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).toBeDisabled());
|
||||
expect(queryByTestId('teams-credential-verified')).toBeNull();
|
||||
expect(getByTestId('teams-credential-problem')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('adopts saved settings that arrive after the view is rendered', async () => {
|
||||
const { getByTestId, rerender } = renderComponent({ props: props({ mode: 'edit' }) });
|
||||
|
||||
// The panels start collapsed here, so the switch is present but not shown.
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeInTheDocument());
|
||||
await openAvailability(getByTestId);
|
||||
expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(false);
|
||||
|
||||
await rerender(props({ mode: 'edit', savedSettings: { teamChannels: true } }));
|
||||
|
|
@ -588,7 +815,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
it('keeps an edit when saved settings arrive afterwards', async () => {
|
||||
const { getByTestId, rerender } = renderComponent({ props: props({ mode: 'edit' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-groups')).toBeInTheDocument());
|
||||
await openAvailability(getByTestId);
|
||||
await fireEvent.click(getByTestId('teams-scope-groups'));
|
||||
|
||||
await rerender(props({ mode: 'edit', savedSettings: { teamChannels: true } }));
|
||||
|
|
@ -602,6 +829,7 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
|
||||
const name = () => getByTestId('teams-display-name').querySelector('input');
|
||||
await waitFor(() => expect(name()).toBeInTheDocument());
|
||||
await openAvailability(getByTestId);
|
||||
await fireEvent.update(name() as HTMLInputElement, 'My own name');
|
||||
|
||||
await rerender(
|
||||
|
|
@ -639,16 +867,6 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
});
|
||||
|
||||
describe('telemetry', () => {
|
||||
const withBot = () =>
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
||||
it('tracks a credential that checks out', async () => {
|
||||
renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
|
|
@ -738,13 +956,14 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
)
|
||||
.mockResolvedValueOnce({ status: 'failed', reason: 'rejected' });
|
||||
|
||||
const { getByTestId, rerender } = renderComponent({
|
||||
const { getByTestId, queryByTestId, rerender } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
await rerender(props({ modelValue: 'cred-2' }));
|
||||
await waitFor(() => expect(getByTestId('teams-credential-problem')).toBeVisible());
|
||||
releaseFirst?.({ status: 'ok' });
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).toBeDisabled());
|
||||
await flushPromises();
|
||||
expect(queryByTestId('teams-credential-verified')).toBeNull();
|
||||
|
||||
const checks = trackMock.mock.calls.filter(
|
||||
([event]) => event === TELEMETRY_EVENT.AGENTS.USER_CHECKED_TEAMS_CHANNEL_CREDENTIAL,
|
||||
|
|
@ -755,8 +974,8 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
|
||||
it('tracks a successful package download', async () => {
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({ props: props({ connected: true }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible());
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
|
|
@ -771,8 +990,8 @@ describe('AgentChannelTeamsSetup', () => {
|
|||
it('tracks a failed package download', async () => {
|
||||
withBot();
|
||||
vi.mocked(fetchTeamsAppPackage).mockRejectedValue(new Error('401'));
|
||||
const { getByTestId } = renderComponent({ props: props({ connected: true }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible());
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { N8nButton, N8nCopyInput, N8nInput, N8nStepper, N8nText } from '@n8n/design-system';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nCopyInput,
|
||||
N8nIcon,
|
||||
N8nInput,
|
||||
N8nStepper,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
} from '@n8n/design-system';
|
||||
import { TEAMS_DESCRIPTION_MAX, TEAMS_DISPLAY_NAME_MAX } from '@n8n/api-types';
|
||||
import type {
|
||||
AgentJsonConfig,
|
||||
AgentTeamsIntegrationSettings,
|
||||
ChatIntegrationDescriptor,
|
||||
TeamsAgentSetupState,
|
||||
TeamsCredentialCheck,
|
||||
} from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import type { PermissionsRecord } from '@n8n/permissions';
|
||||
import AgentPersonalisationIcon from '../../components/AgentPersonalisationIcon.vue';
|
||||
import AgentIntegrationCredentialConnection from '../../components/AgentIntegrationCredentialConnection.vue';
|
||||
import type { AgentCredentialOption } from '../../components/AgentCredentialSelect.vue';
|
||||
import AgentChannelTeamsAvailability, {
|
||||
|
|
@ -38,6 +49,8 @@ const props = withDefaults(
|
|||
agentId: string;
|
||||
forceNewCredential?: boolean;
|
||||
savedSettings?: AgentTeamsIntegrationSettings;
|
||||
personalisation?: AgentJsonConfig['personalisation'] | null;
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
}>(),
|
||||
{
|
||||
credentialsLoading: false,
|
||||
|
|
@ -48,6 +61,8 @@ const props = withDefaults(
|
|||
errorIsConflict: false,
|
||||
forceNewCredential: false,
|
||||
savedSettings: undefined,
|
||||
personalisation: null,
|
||||
ensureAgentPersisted: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -59,12 +74,15 @@ const emit = defineEmits<{
|
|||
|
||||
const i18n = useI18n();
|
||||
const rootStore = useRootStore();
|
||||
const toast = useToast();
|
||||
const agentTelemetry = useAgentTelemetry();
|
||||
|
||||
const ENTRA_APP_REGISTRATION_URL =
|
||||
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade';
|
||||
|
||||
const setupState = ref<TeamsAgentSetupState | null>(null);
|
||||
// Only a picked credential makes the setup state worth retrying.
|
||||
const setupLoadFailed = ref(false);
|
||||
const showEndpoint = ref(false);
|
||||
|
||||
const availability = ref<TeamsAvailability>({
|
||||
|
|
@ -152,6 +170,19 @@ const credentialProblem = computed(() =>
|
|||
credentialCheck.value?.status === 'failed' ? credentialCheck.value.reason : null,
|
||||
);
|
||||
|
||||
// The manifest needs the bot ID, and a package for a credential that fails
|
||||
// its check would install a bot that cannot answer. A connected channel passed
|
||||
// that check when it connected, and setup no longer shows the check.
|
||||
const ready = computed(
|
||||
() => canDownloadPackage.value && (props.connected || credentialVerified.value),
|
||||
);
|
||||
|
||||
// A deployment with IDs that fail the check binds the bot to the wrong app
|
||||
// registration, and Microsoft allows one bot for each registration.
|
||||
const deployToAzureUrl = computed(() =>
|
||||
props.connected || credentialVerified.value ? (setupState.value?.deployToAzureUrl ?? null) : null,
|
||||
);
|
||||
|
||||
async function runCredentialCheck(trigger: 'auto' | 'recheck') {
|
||||
const request = ++latestCheck;
|
||||
const id = credentialId.value;
|
||||
|
|
@ -192,7 +223,7 @@ async function runCredentialCheck(trigger: 'auto' | 'recheck') {
|
|||
});
|
||||
}
|
||||
|
||||
async function downloadPackage() {
|
||||
async function downloadPackage(): Promise<boolean> {
|
||||
downloading.value = true;
|
||||
downloadError.value = '';
|
||||
try {
|
||||
|
|
@ -205,17 +236,64 @@ async function downloadPackage() {
|
|||
);
|
||||
saveAs(blob, 'n8n-agent-teams-app.zip');
|
||||
agentTelemetry.trackDownloadedTeamsAppPackage({ agentId: props.agentId, status: 'success' });
|
||||
return true;
|
||||
} catch {
|
||||
downloadError.value = i18n.baseText('agents.channels.teams.setup.install.downloadFailed');
|
||||
agentTelemetry.trackDownloadedTeamsAppPackage({ agentId: props.agentId, status: 'error' });
|
||||
return false;
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let unmounted = false;
|
||||
onBeforeUnmount(() => (unmounted = true));
|
||||
|
||||
// Set when the credential changed during a download, so the saved zip is for the old bot.
|
||||
const staleDownload = ref(false);
|
||||
|
||||
// Connecting closes the modal, so it waits for the package to be saved.
|
||||
async function downloadAndConnect() {
|
||||
const id = credentialId.value;
|
||||
staleDownload.value = false;
|
||||
if (!(await downloadPackage()) || unmounted) return;
|
||||
// The package carries the bot ID of the credential it was built for, so a
|
||||
// switch during the download must not connect the new one.
|
||||
if (credentialId.value !== id || !ready.value) {
|
||||
staleDownload.value = true;
|
||||
return;
|
||||
}
|
||||
toast.showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('agents.channels.teams.setup.install.downloaded'),
|
||||
});
|
||||
if (!props.connected) emit('connect');
|
||||
}
|
||||
|
||||
// The modal clears the error when the credential changes. A conflict is about
|
||||
// the credential itself, so it stays in step 2.
|
||||
const showConnectError = computed(
|
||||
() => Boolean(props.errorMessage) && !props.errorIsConflict && !props.connected,
|
||||
);
|
||||
|
||||
async function loadSetupState() {
|
||||
const request = ++latestSetupState;
|
||||
setupLoadFailed.value = false;
|
||||
if (!props.projectId || !props.agentId) return;
|
||||
// A new agent has no row yet, and every agent-scoped Teams request needs
|
||||
// one. Picking a credential is the first step that asks for that data.
|
||||
if (props.mode === 'setup' && credentialId.value) {
|
||||
try {
|
||||
await props.ensureAgentPersisted?.();
|
||||
} catch (error) {
|
||||
if (request !== latestSetupState) return;
|
||||
setupState.value = null;
|
||||
setupLoadFailed.value = true;
|
||||
// The inline retry says the setup failed; the toast says why.
|
||||
toast.showError(error, i18n.baseText('agents.channels.modal.saveChannelError'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const state = await getTeamsSetupState(
|
||||
rootStore.restApiContext,
|
||||
|
|
@ -225,7 +303,9 @@ async function loadSetupState() {
|
|||
);
|
||||
if (request === latestSetupState) setupState.value = state;
|
||||
} catch {
|
||||
if (request === latestSetupState) setupState.value = null;
|
||||
if (request !== latestSetupState) return;
|
||||
setupState.value = null;
|
||||
setupLoadFailed.value = Boolean(credentialId.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,6 +345,11 @@ watch(
|
|||
);
|
||||
|
||||
const steps = computed(() => [
|
||||
{
|
||||
id: 'register-app',
|
||||
title: i18n.baseText('agents.channels.teams.setup.registerApp.title'),
|
||||
description: i18n.baseText('agents.channels.teams.setup.registerApp.description'),
|
||||
},
|
||||
{
|
||||
id: 'create-credential',
|
||||
title: i18n.baseText('agents.channels.teams.setup.createCredential.title'),
|
||||
|
|
@ -319,7 +404,23 @@ defineExpose({ credentialId, validationError: null, currentSettings });
|
|||
<N8nStepper v-if="mode === 'setup'" :steps="steps">
|
||||
<template #default="{ step }">
|
||||
<div :class="$style.stepContent">
|
||||
<div v-if="step.id === 'create-credential'" :class="$style.stepStack">
|
||||
<div v-if="step.id === 'register-app'" :class="$style.stepStack">
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.registerApp.hint') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
:href="ENTRA_APP_REGISTRATION_URL"
|
||||
target="_blank"
|
||||
variant="subtle"
|
||||
size="medium"
|
||||
icon="entra"
|
||||
data-testid="teams-entra-register-link"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.registerApp.button') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step.id === 'create-credential'" :class="$style.stepStack">
|
||||
<AgentIntegrationCredentialConnection
|
||||
v-if="!connected"
|
||||
v-model="credentialId"
|
||||
|
|
@ -328,9 +429,9 @@ defineExpose({ credentialId, validationError: null, currentSettings });
|
|||
:credentials="credentials"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:credentials-loading="credentialsLoading"
|
||||
:disabled="loading"
|
||||
:disabled="loading || downloading"
|
||||
:loading="loading"
|
||||
:error-message="errorMessage"
|
||||
:error-message="showConnectError ? '' : errorMessage"
|
||||
:error-is-conflict="errorIsConflict"
|
||||
:force-new-credential="forceNewCredential"
|
||||
@create="emit('create')"
|
||||
|
|
@ -349,114 +450,7 @@ defineExpose({ credentialId, validationError: null, currentSettings });
|
|||
})
|
||||
}}
|
||||
</N8nText>
|
||||
|
||||
<N8nText :class="$style.hint" size="small" data-testid="teams-create-bot-prerequisites">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createCredential.prerequisites') }}
|
||||
<a
|
||||
:href="ENTRA_APP_REGISTRATION_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="teams-entra-register-link"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createCredential.button') }}
|
||||
</a>
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step.id === 'create-bot'" :class="$style.stepStack">
|
||||
<N8nButton
|
||||
v-if="setupState?.deployToAzureUrl"
|
||||
:href="setupState.deployToAzureUrl"
|
||||
target="_blank"
|
||||
variant="subtle"
|
||||
size="medium"
|
||||
data-testid="teams-deploy-to-azure"
|
||||
@click="agentTelemetry.trackClickedDeployToAzure({ agentId })"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.button') }}
|
||||
</N8nButton>
|
||||
<N8nText v-else :class="$style.hint" size="small" data-testid="teams-deploy-blocked">
|
||||
{{
|
||||
credentialClaimedBy
|
||||
? i18n.baseText('agents.channels.teams.setup.createBot.needsFreeCredential')
|
||||
: i18n.baseText('agents.channels.teams.setup.createBot.needsCredential')
|
||||
}}
|
||||
</N8nText>
|
||||
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.hint') }}
|
||||
</N8nText>
|
||||
|
||||
<!-- The deployment sets the endpoint, so only someone wiring up an
|
||||
existing bot needs to see it. -->
|
||||
<N8nButton
|
||||
v-if="!showEndpoint"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
data-testid="teams-show-endpoint"
|
||||
@click="showEndpoint = true"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.existingBot') }}
|
||||
</N8nButton>
|
||||
<div v-else :class="$style.field" data-testid="teams-endpoint-field">
|
||||
<label for="teams-messaging-endpoint-url">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.messagingEndpointUrl.label') }}
|
||||
</N8nText>
|
||||
</label>
|
||||
<N8nCopyInput
|
||||
id="teams-messaging-endpoint-url"
|
||||
:value="messagingEndpointUrl"
|
||||
size="large"
|
||||
:class="$style.urlInput"
|
||||
:copy-label="i18n.baseText('agents.builder.addTrigger.copy')"
|
||||
:copied-label="i18n.baseText('agents.builder.addTrigger.copied')"
|
||||
/>
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.existingBotHint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step.id === 'availability'" :class="$style.stepStack">
|
||||
<AgentChannelTeamsAvailability
|
||||
:model-value="availability"
|
||||
@update:model-value="editAvailability"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step.id === 'install'" :class="$style.stepStack">
|
||||
<N8nButton
|
||||
v-if="canDownloadPackage"
|
||||
variant="subtle"
|
||||
size="medium"
|
||||
icon="download"
|
||||
:loading="downloading"
|
||||
data-testid="teams-download-package"
|
||||
@click="downloadPackage"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.button') }}
|
||||
</N8nButton>
|
||||
<N8nText v-else size="small" :class="$style.hint" data-testid="teams-package-blocked">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.needsBot') }}
|
||||
</N8nText>
|
||||
|
||||
<N8nText
|
||||
v-if="downloadError"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="teams-download-error"
|
||||
>
|
||||
{{ downloadError }}
|
||||
</N8nText>
|
||||
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.hint') }}
|
||||
</N8nText>
|
||||
|
||||
<!-- Last, because the modal closes on it: the package has to be
|
||||
downloaded by then. -->
|
||||
<template v-if="!connected">
|
||||
<template v-else-if="!connected">
|
||||
<N8nText
|
||||
v-if="checking"
|
||||
:class="$style.hint"
|
||||
|
|
@ -480,27 +474,191 @@ defineExpose({ credentialId, validationError: null, currentSettings });
|
|||
{{ i18n.baseText('agents.channels.teams.setup.install.recheck') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<!-- Only when nothing is picked; a verified credential says nothing. -->
|
||||
<N8nText
|
||||
v-else-if="!credentialId"
|
||||
:class="$style.hint"
|
||||
v-else-if="credentialVerified"
|
||||
size="small"
|
||||
data-testid="teams-connect-blocked"
|
||||
color="success"
|
||||
:class="$style.verified"
|
||||
data-testid="teams-credential-verified"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.needsCredential') }}
|
||||
<N8nIcon icon="check" size="small" />
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.verified') }}
|
||||
</N8nText>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step.id === 'create-bot'" :class="$style.stepStack">
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.hint') }}
|
||||
</N8nText>
|
||||
<div :class="$style.actions">
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:href="deployToAzureUrl ?? undefined"
|
||||
target="_blank"
|
||||
variant="subtle"
|
||||
size="medium"
|
||||
:disabled="!credentialVerified || Boolean(credentialClaimedBy) || loading"
|
||||
:loading="loading"
|
||||
data-testid="teams-connect"
|
||||
icon="azure"
|
||||
:disabled="!deployToAzureUrl"
|
||||
data-testid="teams-deploy-to-azure"
|
||||
@click="agentTelemetry.trackClickedDeployToAzure({ agentId })"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.button') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
data-testid="teams-show-endpoint"
|
||||
@click="showEndpoint = !showEndpoint"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.existingBot') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="setupLoadFailed"
|
||||
:class="$style.actions"
|
||||
data-testid="teams-setup-load-failed"
|
||||
>
|
||||
<N8nText size="small" :class="$style.error">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.loadFailed') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
data-testid="teams-setup-retry"
|
||||
@click="loadSetupState"
|
||||
>
|
||||
{{ i18n.baseText('generic.retry') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<N8nText
|
||||
v-else-if="!deployToAzureUrl && !checking"
|
||||
:class="$style.hint"
|
||||
size="small"
|
||||
data-testid="teams-deploy-blocked"
|
||||
>
|
||||
{{
|
||||
credentialClaimedBy
|
||||
? i18n.baseText('agents.channels.teams.setup.createBot.needsFreeCredential')
|
||||
: i18n.baseText('agents.channels.teams.setup.createBot.needsCredential')
|
||||
}}
|
||||
</N8nText>
|
||||
|
||||
<!-- The deployment sets the endpoint, so only someone wiring up an
|
||||
existing bot needs to see it. -->
|
||||
<div v-if="showEndpoint" :class="$style.field" data-testid="teams-endpoint-field">
|
||||
<label for="teams-messaging-endpoint-url">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.messagingEndpointUrl.label') }}
|
||||
</N8nText>
|
||||
</label>
|
||||
<N8nCopyInput
|
||||
id="teams-messaging-endpoint-url"
|
||||
:value="messagingEndpointUrl"
|
||||
size="large"
|
||||
:class="$style.urlInput"
|
||||
:copy-label="i18n.baseText('agents.builder.addTrigger.copy')"
|
||||
:copied-label="i18n.baseText('agents.builder.addTrigger.copied')"
|
||||
/>
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.existingBotHint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="step.id === 'availability'"
|
||||
:class="[$style.stepStack, { [$style.locked]: !ready }]"
|
||||
:inert="!ready || undefined"
|
||||
data-testid="teams-availability-step"
|
||||
>
|
||||
<AgentChannelTeamsAvailability
|
||||
:model-value="availability"
|
||||
start-collapsed
|
||||
@update:model-value="editAvailability"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step.id === 'install'" :class="$style.stepStack">
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.hint') }}
|
||||
</N8nText>
|
||||
|
||||
<div
|
||||
:class="[$style.identity, ready ? $style.identityReady : $style.locked]"
|
||||
data-testid="teams-identity"
|
||||
>
|
||||
<AgentPersonalisationIcon :personalisation="personalisation" :size="36" />
|
||||
<div :class="$style.identityText">
|
||||
<N8nText size="small" bold>
|
||||
{{ defaultDisplayName }}
|
||||
<N8nTooltip
|
||||
:content="i18n.baseText('agents.channels.teams.setup.install.identityTooltip')"
|
||||
>
|
||||
<N8nIcon icon="info" size="xsmall" :class="$style.hint" />
|
||||
</N8nTooltip>
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{ defaultDescription }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="medium"
|
||||
:disabled="!ready || loading"
|
||||
:loading="downloading || loading"
|
||||
data-testid="teams-download-package"
|
||||
@click="downloadAndConnect"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.button') }}
|
||||
<N8nIcon icon="download" size="medium" />
|
||||
</N8nButton>
|
||||
</div>
|
||||
|
||||
<N8nText
|
||||
v-if="staleDownload"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="teams-stale-download"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.staleDownload') }}
|
||||
</N8nText>
|
||||
<!-- A failed load and a claimed credential already say so in steps 2 and 3. -->
|
||||
<N8nText
|
||||
v-if="!ready && !credentialVerified && !setupLoadFailed"
|
||||
:class="$style.hint"
|
||||
size="small"
|
||||
data-testid="teams-package-blocked"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.needsReady') }}
|
||||
</N8nText>
|
||||
<!-- Connect errors otherwise land in step 2, far from this button. -->
|
||||
<div v-if="showConnectError" :class="$style.actions">
|
||||
<N8nText size="small" :class="$style.error" data-testid="teams-connect-error">
|
||||
{{
|
||||
i18n.baseText('agents.channels.teams.setup.install.connectFailed', {
|
||||
interpolate: { error: errorMessage },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
<!-- The package is already downloaded, so a retry only connects. -->
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:disabled="loading"
|
||||
data-testid="teams-connect-retry"
|
||||
@click="emit('connect')"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.connectButton') }}
|
||||
{{ i18n.baseText('generic.retry') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</div>
|
||||
<N8nText
|
||||
v-if="downloadError"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="teams-download-error"
|
||||
>
|
||||
{{ downloadError }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="connected && !isPublished"
|
||||
:class="$style.hint"
|
||||
|
|
@ -640,6 +798,52 @@ defineExpose({ credentialId, validationError: null, currentSettings });
|
|||
color: var(--color--danger);
|
||||
}
|
||||
|
||||
.verified {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.locked {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
width: 100%;
|
||||
padding: var(--spacing--xs);
|
||||
/* Matches the availability panel above it. */
|
||||
border: var(--border-width, 1px) solid var(--border-color--subtle);
|
||||
border-radius: var(--radius--xs);
|
||||
}
|
||||
|
||||
/*
|
||||
* One step off the modal in both themes. The semantic tokens are relative to
|
||||
* the page, and the dark modal is lighter than the dark page surface.
|
||||
*/
|
||||
.identityReady {
|
||||
background: light-dark(var(--color--neutral-50), var(--color--white-alpha-50));
|
||||
/* The dark fill matches the subtle border, which would hide it. */
|
||||
border-color: light-dark(var(--border-color--subtle), var(--border-color));
|
||||
}
|
||||
|
||||
.identityText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.urlInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
AgentIntegrationDisconnectWarning,
|
||||
AgentJsonConfig,
|
||||
AgentIntegrationSettings,
|
||||
ChatIntegrationDescriptor,
|
||||
} from '@n8n/api-types';
|
||||
|
|
@ -59,6 +60,8 @@ export interface AgentChannelViewProps {
|
|||
agentName: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
personalisation?: AgentJsonConfig['personalisation'] | null;
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
forceNewCredential: boolean;
|
||||
simpleSetup: boolean;
|
||||
runtime: AgentChannelRuntime;
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ const i18n = useI18n();
|
|||
:agent-unsaved="agentUnsaved"
|
||||
:ensure-agent-persisted="ensureAgentPersisted"
|
||||
:task-refs="localConfig?.tasks ?? []"
|
||||
:personalisation="localConfig?.personalisation ?? agent?.schema?.personalisation"
|
||||
:reload-key="tasksReloadKey"
|
||||
@update:connected-triggers="emit('update:connected-triggers', $event)"
|
||||
@trigger-added="emit('trigger-added', $event)"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type { AgentApproval } from '@n8n/api-types';
|
||||
import type { AgentApproval, AgentJsonConfig } from '@n8n/api-types';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
|
|
@ -35,6 +35,7 @@ interface Props {
|
|||
isPublished?: boolean;
|
||||
simpleSetup?: boolean;
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
personalisation?: AgentJsonConfig['personalisation'] | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
|
@ -42,6 +43,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
isPublished: false,
|
||||
simpleSetup: false,
|
||||
ensureAgentPersisted: undefined,
|
||||
personalisation: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -561,6 +563,8 @@ watch(
|
|||
:agent-name="agentId"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
:personalisation="personalisation"
|
||||
:ensure-agent-persisted="ensureAgentPersisted"
|
||||
:force-new-credential="false"
|
||||
:simple-setup="simpleSetup"
|
||||
:runtime="currentRuntime"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import type { AgentConfigValidationIssue, AgentJsonTaskConfig } from '@n8n/api-types';
|
||||
import type {
|
||||
AgentConfigValidationIssue,
|
||||
AgentJsonConfig,
|
||||
AgentJsonTaskConfig,
|
||||
} from '@n8n/api-types';
|
||||
import { updatedIconSet, type IconName } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
|
|
@ -34,6 +38,7 @@ const props = withDefaults(
|
|||
/** No agent row exists yet — nothing can be connected to it. */
|
||||
agentUnsaved?: boolean;
|
||||
ensureAgentPersisted?: () => Promise<void>;
|
||||
personalisation?: AgentJsonConfig['personalisation'] | null;
|
||||
}>(),
|
||||
{
|
||||
connectedTriggers: () => [],
|
||||
|
|
@ -44,6 +49,7 @@ const props = withDefaults(
|
|||
simpleChannelSetup: false,
|
||||
taskRefs: () => [],
|
||||
ensureAgentPersisted: undefined,
|
||||
personalisation: null,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -302,6 +308,7 @@ function handleChannelDisconnected(channelType: string) {
|
|||
:disabled="props.disabled"
|
||||
:simple-setup="simpleChannelSetup"
|
||||
:ensure-agent-persisted="ensureAgentPersisted"
|
||||
:personalisation="personalisation"
|
||||
@channel-connected="handleChannelConnected"
|
||||
@channel-disconnected="handleChannelDisconnected"
|
||||
@agent-changed="emit('agent-changed')"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user