mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
feat(editor): Add an upload step to the Microsoft Teams setup (no-changelog) (#40339)
This commit is contained in:
@@ -9232,7 +9232,7 @@
|
||||
"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.hint": "You need an Azure subscription.",
|
||||
"agents.channels.teams.setup.createBot.hint": "Needs an Azure subscription. Opens Azure with the n8n bot template filled in (Azure may note it isn’t from Microsoft).",
|
||||
"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",
|
||||
@@ -9261,7 +9261,13 @@
|
||||
"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.download.title": "Download app package",
|
||||
"agents.channels.teams.setup.download.description": "Built from the steps above.",
|
||||
"agents.channels.teams.setup.install.description": "Upload the zip in Teams under Apps › Manage your apps › Upload an app.",
|
||||
"agents.channels.teams.setup.install.addTo.teams": "Then choose Add to a team and pick the team it should join.",
|
||||
"agents.channels.teams.setup.install.addTo.chats": "Then choose Add to a chat and pick the chat it should join.",
|
||||
"agents.channels.teams.setup.install.addTo.both": "Then choose Add to a team or Add to a chat, and pick where it should work.",
|
||||
"agents.channels.teams.setup.install.done": "Done",
|
||||
"agents.channels.teams.setup.install.button": "Download app package",
|
||||
"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.",
|
||||
@@ -9567,8 +9573,9 @@
|
||||
"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.settings.updateNotice": "Changing these means downloading and uploading the app again.",
|
||||
"agents.channels.teams.settings.updateNoticeChanged": "Saving downloads a new package. Upload it in Teams to apply the change.",
|
||||
"agents.channels.teams.settings.updateNotice": "You’ll need to upload the app again to apply changes.",
|
||||
"agents.channels.teams.settings.uploadHint": "To update, upload the new package in Teams under Apps › Manage your apps › Upload an app.",
|
||||
"agents.channels.teams.settings.findBot": "Find your Azure Bot",
|
||||
"agents.channels.teams.settings.availabilityLabel": "Where it works",
|
||||
"agents.channels.teams.settings.identityLabel": "Appearance in Teams",
|
||||
"agents.channels.teams.settings.saveAndDownload": "Save and download package",
|
||||
|
||||
+22
-1
@@ -6,6 +6,7 @@ import type { AgentApproval, ChatIntegrationDescriptor } from '@n8n/api-types';
|
||||
import AgentChannelModal, { type ChannelView } from '../components/AgentChannelModal.vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
keepOpenAfterConnect: false,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
fetchStatus: vi.fn(),
|
||||
@@ -71,7 +72,7 @@ vi.mock('../channels/registry', async () => {
|
||||
const { ref, defineComponent } = await import('vue');
|
||||
const platformView = {
|
||||
props: ['modelValue', 'mode', 'isPublished', 'runtime', 'ensureAgentPersisted'],
|
||||
emits: ['update:modelValue', 'connect', 'connected'],
|
||||
emits: ['update:modelValue', 'connect', 'connected', 'done'],
|
||||
setup: () => {
|
||||
// Platforms that drive their own flow (Slack) report `connected` while
|
||||
// still reporting `loading`, so the two are controlled together here.
|
||||
@@ -81,6 +82,7 @@ vi.mock('../channels/registry', async () => {
|
||||
validationError: null,
|
||||
beforeSave: mocks.beforeSave,
|
||||
afterSave: mocks.afterSave,
|
||||
keepOpenAfterConnect: mocks.keepOpenAfterConnect,
|
||||
saveLabel: platformSaveLabel,
|
||||
loading,
|
||||
startOwnFlow: () => {
|
||||
@@ -97,6 +99,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="platform-done" @click="$emit('done')" />
|
||||
<button data-testid="persist-agent" @click="ensureAgentPersisted?.()" />
|
||||
<button data-testid="platform-own-flow" @click="startOwnFlow(); $emit('connected')" />
|
||||
</div>
|
||||
@@ -343,6 +346,7 @@ enableAutoUnmount(afterEach);
|
||||
describe('AgentChannelModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.keepOpenAfterConnect = false;
|
||||
catalog.value = [exampleIntegration];
|
||||
statuses.value = {};
|
||||
connectedCredentials.value = {};
|
||||
@@ -455,6 +459,23 @@ describe('AgentChannelModal', () => {
|
||||
expect(wrapper.emitted('agent-changed')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stays open after connecting a view that asks to, until the view is done', async () => {
|
||||
mocks.keepOpenAfterConnect = true;
|
||||
selectedCredentials.value.example = 'credential-new';
|
||||
const wrapper = mountModal('example_setup');
|
||||
|
||||
await wrapper.get('[data-testid="connect-channel"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.connect).toHaveBeenCalledOnce();
|
||||
expect(wrapper.emitted('channel-connected')).toEqual([['example']]);
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined();
|
||||
|
||||
await wrapper.get('[data-testid="platform-done"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]]);
|
||||
});
|
||||
|
||||
it('lets the platform view save the agent before it needs agent-scoped data', async () => {
|
||||
const wrapper = mountModal('example_setup');
|
||||
|
||||
|
||||
+240
-4
@@ -240,6 +240,14 @@ describe('AgentChannelTeamsSetup', () => {
|
||||
});
|
||||
|
||||
describe('step 3, deploy the bot', () => {
|
||||
it('shows the Azure subscription hint', async () => {
|
||||
const { getByText } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText('agents.channels.teams.setup.createBot.hint')).toBeVisible(),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the endpoint URL out of the way, since the deployment sets it', async () => {
|
||||
const { getByTestId, container } = renderComponent({ props: props() });
|
||||
|
||||
@@ -528,7 +536,7 @@ describe('AgentChannelTeamsSetup', () => {
|
||||
expect(vi.mocked(fetchTeamsAppPackage).mock.calls[0]?.slice(1, 3)).toEqual(['p', 'a']);
|
||||
});
|
||||
|
||||
it('connects once the package is saved, since the modal closes on connect', async () => {
|
||||
it('connects once the package is saved', async () => {
|
||||
withBot();
|
||||
const { getByTestId, emitted } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
@@ -563,6 +571,15 @@ describe('AgentChannelTeamsSetup', () => {
|
||||
expect(emitted().connect).toBeFalsy();
|
||||
});
|
||||
|
||||
it('names the download step and what the package is built from', async () => {
|
||||
const { getByText } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText('agents.channels.teams.setup.download.title')).toBeVisible(),
|
||||
);
|
||||
expect(getByText('agents.channels.teams.setup.download.description')).toBeVisible();
|
||||
});
|
||||
|
||||
it('reports a failed download and does not connect', async () => {
|
||||
withBot();
|
||||
vi.mocked(fetchTeamsAppPackage).mockRejectedValue(new Error('401'));
|
||||
@@ -576,6 +593,169 @@ describe('AgentChannelTeamsSetup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 6, upload in Teams', () => {
|
||||
const UPLOAD = 'agents.channels.teams.setup.install.description';
|
||||
|
||||
const downloadAndConnect = 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).toBeTruthy());
|
||||
return utils;
|
||||
};
|
||||
|
||||
it('says where to upload the zip, and what to do without the option', async () => {
|
||||
const { getByText, getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText('agents.channels.teams.setup.install.title')).toBeVisible(),
|
||||
);
|
||||
expect(getByText(UPLOAD)).toBeVisible();
|
||||
expect(getByTestId('teams-upload-hint')).toHaveTextContent(
|
||||
'agents.channels.teams.setup.install.hint',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[['teams-scope-channels'], 'agents.channels.teams.setup.install.addTo.teams'],
|
||||
[['teams-scope-groups'], 'agents.channels.teams.setup.install.addTo.chats'],
|
||||
[
|
||||
['teams-scope-channels', 'teams-scope-groups'],
|
||||
'agents.channels.teams.setup.install.addTo.both',
|
||||
],
|
||||
])('tells the user where to add the app for %j', async (scopes, addTo) => {
|
||||
withBot();
|
||||
const { getByTestId, getByText } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
await openAvailability(getByTestId);
|
||||
|
||||
for (const scope of scopes) await fireEvent.click(getByTestId(scope));
|
||||
|
||||
await waitFor(() => expect(getByText(`${UPLOAD} ${addTo}`)).toBeVisible());
|
||||
});
|
||||
|
||||
it('adds nothing about teams or chats for direct chat only', async () => {
|
||||
const { getByText } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByText(UPLOAD)).toBeVisible());
|
||||
});
|
||||
|
||||
it('asks the modal to stay open after connecting in setup, but not in settings', async () => {
|
||||
const Host = defineComponent({
|
||||
components: { AgentChannelTeamsSetup },
|
||||
props: { viewProps: { type: Object, required: true } },
|
||||
setup: () => ({ view: ref<InstanceType<typeof AgentChannelTeamsSetup>>() }),
|
||||
template: `<div><AgentChannelTeamsSetup ref="view" v-bind="viewProps" /><span data-testid="keep-open">{{ String(view?.keepOpenAfterConnect) }}</span></div>`,
|
||||
});
|
||||
const renderHost = createComponentRenderer(Host);
|
||||
|
||||
const setup = renderHost({ props: { viewProps: props() } });
|
||||
await waitFor(() => expect(setup.getByTestId('keep-open')).toHaveTextContent('true'));
|
||||
setup.unmount();
|
||||
|
||||
const settings = renderHost({ props: { viewProps: props({ mode: 'edit' }) } });
|
||||
await waitFor(() => expect(settings.getByTestId('keep-open')).toHaveTextContent('false'));
|
||||
});
|
||||
|
||||
it('keeps Done disabled until the package is downloaded and the channel connected', async () => {
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
expect(getByTestId('teams-done')).toBeDisabled();
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
await waitFor(() => expect(saveAs).toHaveBeenCalled());
|
||||
|
||||
expect(getByTestId('teams-done')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('keeps Done disabled on a connected channel until a package is downloaded', async () => {
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1', connected: true }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
expect(getByTestId('teams-done')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('finishes on Done once connected, without another toast', async () => {
|
||||
const { getByTestId, emitted, rerender } = await downloadAndConnect();
|
||||
await rerender(props({ modelValue: 'cred-1', connected: true }));
|
||||
await waitFor(() => expect(getByTestId('teams-done')).toBeEnabled());
|
||||
showMessage.mockClear();
|
||||
|
||||
await fireEvent.click(getByTestId('teams-done'));
|
||||
|
||||
expect(emitted().done).toHaveLength(1);
|
||||
expect(showMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a failed connect in this step, with Done still disabled', async () => {
|
||||
const { getByTestId, rerender } = await downloadAndConnect();
|
||||
|
||||
await rerender(props({ modelValue: 'cred-1', errorMessage: 'Bot rejected' }));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-connect-retry')).toBeVisible());
|
||||
const step = getByTestId('teams-install-step');
|
||||
expect(step).toContainElement(getByTestId('teams-connect-error'));
|
||||
expect(step).toContainElement(getByTestId('teams-done'));
|
||||
expect(getByTestId('teams-done')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('asks to publish a connected agent that is not published, next to Done', async () => {
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1', connected: true, isPublished: false }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-publish-notice')).toBeVisible());
|
||||
expect(getByTestId('teams-install-step')).toContainElement(
|
||||
getByTestId('teams-publish-notice'),
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the availability once connected, so a new download matches what was saved', async () => {
|
||||
const { getByTestId, rerender } = await downloadAndConnect();
|
||||
|
||||
await rerender(props({ modelValue: 'cred-1', connected: true }));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-availability-step')).toHaveAttribute('inert'));
|
||||
});
|
||||
|
||||
it('locks the availability from the download on, even when connecting fails', 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());
|
||||
expect(getByTestId('teams-availability-step')).not.toHaveAttribute('inert');
|
||||
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
await waitFor(() => expect(getByTestId('teams-availability-step')).toHaveAttribute('inert'));
|
||||
|
||||
release?.(new Blob(['zip']));
|
||||
await waitFor(() => expect(emitted().connect).toBeTruthy());
|
||||
await rerender(props({ modelValue: 'cred-1', errorMessage: 'Bot rejected' }));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-connect-retry')).toBeVisible());
|
||||
expect(getByTestId('teams-availability-step')).toHaveAttribute('inert');
|
||||
});
|
||||
|
||||
it('does not count a package for a credential that was swapped since', async () => {
|
||||
const { getByTestId, rerender } = await downloadAndConnect();
|
||||
|
||||
await rerender(props({ modelValue: 'cred-2', connected: true }));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-done')).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings', () => {
|
||||
const settingsProps = (overrides: Record<string, unknown> = {}) =>
|
||||
props({ mode: 'edit', connected: true, modelValue: 'cred-1', ...overrides });
|
||||
@@ -586,6 +766,57 @@ describe('AgentChannelTeamsSetup', () => {
|
||||
await waitFor(() => expect(getByTestId('teams-update-notice')).toBeVisible());
|
||||
});
|
||||
|
||||
it('says where to upload a new package in Teams', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('teams-upload-instructions')).toHaveTextContent(
|
||||
'agents.channels.teams.settings.uploadHint',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('links to the Azure Bot resources, since the bot itself is not known here', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('teams-azure-bot-link')).toHaveAttribute(
|
||||
'href',
|
||||
'https://portal.azure.com/#browse/Microsoft.BotService%2FbotServices',
|
||||
),
|
||||
);
|
||||
expect(getByTestId('teams-azure-bot-link')).toHaveAttribute('target', '_blank');
|
||||
expect(getByTestId('teams-azure-bot-link')).toHaveTextContent(
|
||||
'agents.channels.teams.settings.findBot',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the Azure link out of the setup', async () => {
|
||||
const { queryByTestId, getByText } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText('agents.channels.teams.setup.install.title')).toBeVisible(),
|
||||
);
|
||||
expect(queryByTestId('teams-azure-bot-link')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds the admin hint to the toast after a download from the card', async () => {
|
||||
withBot();
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'agents.channels.teams.setup.install.downloaded',
|
||||
message: 'agents.channels.teams.setup.install.hint',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the app name and description from the agent, not from inputs here', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
@@ -672,7 +903,7 @@ describe('AgentChannelTeamsSetup', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('says a changed availability is saved with a new package', async () => {
|
||||
it('stresses the upload notice once the availability changes', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -680,12 +911,14 @@ describe('AgentChannelTeamsSetup', () => {
|
||||
'agents.channels.teams.settings.updateNotice',
|
||||
),
|
||||
);
|
||||
const quiet = getByTestId('teams-update-notice').className;
|
||||
await openAvailability(getByTestId);
|
||||
await fireEvent.click(getByTestId('teams-scope-groups'));
|
||||
|
||||
expect(getByTestId('teams-update-notice')).toHaveTextContent(
|
||||
'agents.channels.teams.settings.updateNoticeChanged',
|
||||
'agents.channels.teams.settings.updateNotice',
|
||||
);
|
||||
expect(getByTestId('teams-update-notice').className).not.toBe(quiet);
|
||||
});
|
||||
|
||||
it('downloads the saved app from the card, not unsaved changes', async () => {
|
||||
@@ -840,7 +1073,10 @@ describe('AgentChannelTeamsSetup', () => {
|
||||
expect.objectContaining({ groupChats: true }),
|
||||
);
|
||||
expect(showMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'agents.channels.teams.setup.install.downloaded' }),
|
||||
expect.objectContaining({
|
||||
title: 'agents.channels.teams.setup.install.downloaded',
|
||||
message: 'agents.channels.teams.setup.install.hint',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+100
-26
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { N8nButton, N8nCopyInput, N8nIcon, N8nStepper, N8nText } from '@n8n/design-system';
|
||||
import { N8nButton, N8nCopyInput, N8nIcon, N8nLink, N8nStepper, N8nText } from '@n8n/design-system';
|
||||
import type {
|
||||
AgentJsonConfig,
|
||||
AgentTeamsIntegrationSettings,
|
||||
@@ -65,6 +65,7 @@ const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [];
|
||||
connect: [];
|
||||
done: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
@@ -72,6 +73,8 @@ const rootStore = useRootStore();
|
||||
const toast = useToast();
|
||||
const agentTelemetry = useAgentTelemetry();
|
||||
|
||||
// The bot's subscription and resource group are picked in Azure, so this lists every Azure Bot.
|
||||
const AZURE_BOTS_URL = 'https://portal.azure.com/#browse/Microsoft.BotService%2FbotServices';
|
||||
const ENTRA_APP_REGISTRATION_URL =
|
||||
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade';
|
||||
|
||||
@@ -265,8 +268,9 @@ onBeforeUnmount(() => (unmounted = true));
|
||||
|
||||
// Set when the credential changed during a download, so the saved zip is for the old bot.
|
||||
const staleDownload = ref(false);
|
||||
// The credential the last package was built for. Done waits for one of the current credential.
|
||||
const downloadedFor = ref<string | null>(null);
|
||||
|
||||
// Connecting closes the modal, so it waits for the package to be saved.
|
||||
async function downloadAndConnect() {
|
||||
const id = credentialId.value;
|
||||
staleDownload.value = false;
|
||||
@@ -277,6 +281,7 @@ async function downloadAndConnect() {
|
||||
staleDownload.value = true;
|
||||
return;
|
||||
}
|
||||
downloadedFor.value = id;
|
||||
toast.showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('agents.channels.teams.setup.install.downloaded'),
|
||||
@@ -284,6 +289,32 @@ async function downloadAndConnect() {
|
||||
if (!props.connected) emit('connect');
|
||||
}
|
||||
|
||||
// The package fixes these settings, and connecting saves them, so they lock from the download on.
|
||||
// Channel settings is where they change after that.
|
||||
const availabilityLocked = computed(
|
||||
() =>
|
||||
!ready.value ||
|
||||
props.connected ||
|
||||
downloading.value ||
|
||||
downloadedFor.value === credentialId.value,
|
||||
);
|
||||
|
||||
const canFinish = computed(() => downloadedFor.value === credentialId.value && props.connected);
|
||||
|
||||
function addToText(scopes?: { teamChannels?: boolean; groupChats?: boolean }) {
|
||||
if (scopes?.teamChannels && scopes.groupChats)
|
||||
return i18n.baseText('agents.channels.teams.setup.install.addTo.both');
|
||||
if (scopes?.teamChannels) return i18n.baseText('agents.channels.teams.setup.install.addTo.teams');
|
||||
if (scopes?.groupChats) return i18n.baseText('agents.channels.teams.setup.install.addTo.chats');
|
||||
return null;
|
||||
}
|
||||
|
||||
function uploadInstruction(scopes?: { teamChannels?: boolean; groupChats?: boolean }) {
|
||||
const upload = i18n.baseText('agents.channels.teams.setup.install.description');
|
||||
const addTo = addToText(scopes);
|
||||
return addTo ? `${upload} ${addTo}` : upload;
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -371,10 +402,15 @@ const steps = computed(() => [
|
||||
title: i18n.baseText('agents.channels.teams.setup.availability.title'),
|
||||
description: i18n.baseText('agents.channels.teams.setup.availability.description'),
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
title: i18n.baseText('agents.channels.teams.setup.download.title'),
|
||||
description: i18n.baseText('agents.channels.teams.setup.download.description'),
|
||||
},
|
||||
{
|
||||
id: 'install',
|
||||
title: i18n.baseText('agents.channels.teams.setup.install.title'),
|
||||
description: i18n.baseText('agents.channels.teams.setup.install.description'),
|
||||
description: uploadInstruction(availability.value),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -389,10 +425,12 @@ const currentSettings = computed<AgentTeamsIntegrationSettings>(() => {
|
||||
return { ...rest, ...availability.value };
|
||||
});
|
||||
|
||||
// Saving closes the modal, so after "Save and download" the toast is the only place left for the hint.
|
||||
function showDownloaded() {
|
||||
toast.showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('agents.channels.teams.setup.install.downloaded'),
|
||||
message: i18n.baseText('agents.channels.teams.setup.install.hint'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,6 +480,7 @@ defineExpose({
|
||||
saveLabel,
|
||||
beforeSave,
|
||||
afterSave,
|
||||
keepOpenAfterConnect: computed(() => props.mode === 'setup'),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -613,8 +652,8 @@ defineExpose({
|
||||
|
||||
<div
|
||||
v-else-if="step.id === 'availability'"
|
||||
:class="[$style.stepStack, { [$style.locked]: !ready }]"
|
||||
:inert="!ready || undefined"
|
||||
:class="[$style.stepStack, { [$style.locked]: availabilityLocked }]"
|
||||
:inert="availabilityLocked || undefined"
|
||||
data-testid="teams-availability-step"
|
||||
>
|
||||
<AgentChannelTeamsAvailability
|
||||
@@ -624,11 +663,7 @@ defineExpose({
|
||||
/>
|
||||
</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 v-else-if="step.id === 'download'" :class="$style.stepStack">
|
||||
<AgentChannelTeamsIdentityCard
|
||||
:name="defaultDisplayName"
|
||||
:description="defaultDescription"
|
||||
@@ -656,7 +691,25 @@ defineExpose({
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.needsReady') }}
|
||||
</N8nText>
|
||||
<!-- Connect errors otherwise land in step 2, far from this button. -->
|
||||
<N8nText
|
||||
v-if="downloadError"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="teams-download-error"
|
||||
>
|
||||
{{ downloadError }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="step.id === 'install'"
|
||||
:class="$style.stepStack"
|
||||
data-testid="teams-install-step"
|
||||
>
|
||||
<N8nText :class="$style.hint" size="small" data-testid="teams-upload-hint">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.hint') }}
|
||||
</N8nText>
|
||||
<!-- Connect errors otherwise land in step 2, far from this step. -->
|
||||
<div v-if="showConnectError" :class="$style.actions">
|
||||
<N8nText size="small" :class="$style.error" data-testid="teams-connect-error">
|
||||
{{
|
||||
@@ -676,14 +729,6 @@ defineExpose({
|
||||
{{ i18n.baseText('generic.retry') }}
|
||||
</N8nButton>
|
||||
</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"
|
||||
@@ -692,6 +737,15 @@ defineExpose({
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.publishNotice') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:disabled="!canFinish"
|
||||
data-testid="teams-done"
|
||||
@click="emit('done')"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.done') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -712,13 +766,7 @@ defineExpose({
|
||||
:class="manifestChanged ? undefined : $style.hint"
|
||||
data-testid="teams-update-notice"
|
||||
>
|
||||
{{
|
||||
i18n.baseText(
|
||||
manifestChanged
|
||||
? 'agents.channels.teams.settings.updateNoticeChanged'
|
||||
: 'agents.channels.teams.settings.updateNotice',
|
||||
)
|
||||
}}
|
||||
{{ i18n.baseText('agents.channels.teams.settings.updateNotice') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
@@ -739,6 +787,9 @@ defineExpose({
|
||||
:loading="downloading"
|
||||
@download="downloadSaved"
|
||||
/>
|
||||
<N8nText :class="$style.hint" size="small" data-testid="teams-upload-instructions">
|
||||
{{ i18n.baseText('agents.channels.teams.settings.uploadHint') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="downloadError"
|
||||
size="small"
|
||||
@@ -748,6 +799,19 @@ defineExpose({
|
||||
{{ downloadError }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nLink
|
||||
:href="AZURE_BOTS_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="small"
|
||||
:class="$style.azureLink"
|
||||
data-testid="teams-azure-bot-link"
|
||||
>
|
||||
<span :class="$style.linkContent">
|
||||
{{ i18n.baseText('agents.channels.teams.settings.findBot') }}
|
||||
<N8nIcon icon="external-link" size="xsmall" />
|
||||
</span>
|
||||
</N8nLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -811,6 +875,16 @@ defineExpose({
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.azureLink {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.linkContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.urlInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface AgentChannelViewExpose {
|
||||
saveLabel?: string;
|
||||
/** Runs after a successful save, before the modal closes. Must not throw. */
|
||||
afterSave?: () => Promise<void>;
|
||||
/** Keeps the modal open after connecting. The view emits `done` to close it. */
|
||||
keepOpenAfterConnect?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentChannelRuntimeContext {
|
||||
|
||||
@@ -481,7 +481,7 @@ function finishConnect(channelType: string) {
|
||||
endSetupTracking(true);
|
||||
emit('channel-connected', channelType);
|
||||
emit('agent-changed');
|
||||
completeAndClose();
|
||||
if (!channelViewRef.value?.keepOpenAfterConnect) completeAndClose();
|
||||
}
|
||||
|
||||
/** Same shape as `persistAgent`: reports its own failure and returns whether it saved. */
|
||||
@@ -670,6 +670,7 @@ watch(
|
||||
@edit="editCredential"
|
||||
@connect="saveChannelConfig"
|
||||
@connected="handlePlatformConnected"
|
||||
@done="completeAndClose"
|
||||
/>
|
||||
<N8nText
|
||||
v-if="saveAttempted && !currentChannelCredentialId"
|
||||
|
||||
Reference in New Issue
Block a user