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.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.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.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.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.description": "Changing these later means downloading and uploading the app again.",
|
||||||
"agents.channels.teams.setup.availability.directChat": "Direct chat",
|
"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.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.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.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.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.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.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.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.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.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.updateNotice": "You’ll need to upload the app again to apply changes.",
|
||||||
"agents.channels.teams.settings.updateNoticeChanged": "Saving downloads a new package. Upload it in Teams to apply the change.",
|
"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.availabilityLabel": "Where it works",
|
||||||
"agents.channels.teams.settings.identityLabel": "Appearance in Teams",
|
"agents.channels.teams.settings.identityLabel": "Appearance in Teams",
|
||||||
"agents.channels.teams.settings.saveAndDownload": "Save and download package",
|
"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';
|
import AgentChannelModal, { type ChannelView } from '../components/AgentChannelModal.vue';
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
keepOpenAfterConnect: false,
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
fetchStatus: vi.fn(),
|
fetchStatus: vi.fn(),
|
||||||
@@ -71,7 +72,7 @@ vi.mock('../channels/registry', async () => {
|
|||||||
const { ref, defineComponent } = await import('vue');
|
const { ref, defineComponent } = await import('vue');
|
||||||
const platformView = {
|
const platformView = {
|
||||||
props: ['modelValue', 'mode', 'isPublished', 'runtime', 'ensureAgentPersisted'],
|
props: ['modelValue', 'mode', 'isPublished', 'runtime', 'ensureAgentPersisted'],
|
||||||
emits: ['update:modelValue', 'connect', 'connected'],
|
emits: ['update:modelValue', 'connect', 'connected', 'done'],
|
||||||
setup: () => {
|
setup: () => {
|
||||||
// Platforms that drive their own flow (Slack) report `connected` while
|
// Platforms that drive their own flow (Slack) report `connected` while
|
||||||
// still reporting `loading`, so the two are controlled together here.
|
// still reporting `loading`, so the two are controlled together here.
|
||||||
@@ -81,6 +82,7 @@ vi.mock('../channels/registry', async () => {
|
|||||||
validationError: null,
|
validationError: null,
|
||||||
beforeSave: mocks.beforeSave,
|
beforeSave: mocks.beforeSave,
|
||||||
afterSave: mocks.afterSave,
|
afterSave: mocks.afterSave,
|
||||||
|
keepOpenAfterConnect: mocks.keepOpenAfterConnect,
|
||||||
saveLabel: platformSaveLabel,
|
saveLabel: platformSaveLabel,
|
||||||
loading,
|
loading,
|
||||||
startOwnFlow: () => {
|
startOwnFlow: () => {
|
||||||
@@ -97,6 +99,7 @@ vi.mock('../channels/registry', async () => {
|
|||||||
>
|
>
|
||||||
<button data-testid="select-credential" @click="$emit('update:modelValue', 'credential-new')" />
|
<button data-testid="select-credential" @click="$emit('update:modelValue', 'credential-new')" />
|
||||||
<button data-testid="connect-channel" @click="$emit('connect')" />
|
<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="persist-agent" @click="ensureAgentPersisted?.()" />
|
||||||
<button data-testid="platform-own-flow" @click="startOwnFlow(); $emit('connected')" />
|
<button data-testid="platform-own-flow" @click="startOwnFlow(); $emit('connected')" />
|
||||||
</div>
|
</div>
|
||||||
@@ -343,6 +346,7 @@ enableAutoUnmount(afterEach);
|
|||||||
describe('AgentChannelModal', () => {
|
describe('AgentChannelModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mocks.keepOpenAfterConnect = false;
|
||||||
catalog.value = [exampleIntegration];
|
catalog.value = [exampleIntegration];
|
||||||
statuses.value = {};
|
statuses.value = {};
|
||||||
connectedCredentials.value = {};
|
connectedCredentials.value = {};
|
||||||
@@ -455,6 +459,23 @@ describe('AgentChannelModal', () => {
|
|||||||
expect(wrapper.emitted('agent-changed')).toHaveLength(1);
|
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 () => {
|
it('lets the platform view save the agent before it needs agent-scoped data', async () => {
|
||||||
const wrapper = mountModal('example_setup');
|
const wrapper = mountModal('example_setup');
|
||||||
|
|
||||||
|
|||||||
+240
-4
@@ -240,6 +240,14 @@ describe('AgentChannelTeamsSetup', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('step 3, deploy the bot', () => {
|
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 () => {
|
it('keeps the endpoint URL out of the way, since the deployment sets it', async () => {
|
||||||
const { getByTestId, container } = renderComponent({ props: props() });
|
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']);
|
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();
|
withBot();
|
||||||
const { getByTestId, emitted } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
const { getByTestId, emitted } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
await waitFor(() => expect(getByTestId('teams-download-package')).toBeEnabled());
|
||||||
@@ -563,6 +571,15 @@ describe('AgentChannelTeamsSetup', () => {
|
|||||||
expect(emitted().connect).toBeFalsy();
|
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 () => {
|
it('reports a failed download and does not connect', async () => {
|
||||||
withBot();
|
withBot();
|
||||||
vi.mocked(fetchTeamsAppPackage).mockRejectedValue(new Error('401'));
|
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', () => {
|
describe('settings', () => {
|
||||||
const settingsProps = (overrides: Record<string, unknown> = {}) =>
|
const settingsProps = (overrides: Record<string, unknown> = {}) =>
|
||||||
props({ mode: 'edit', connected: true, modelValue: 'cred-1', ...overrides });
|
props({ mode: 'edit', connected: true, modelValue: 'cred-1', ...overrides });
|
||||||
@@ -586,6 +766,57 @@ describe('AgentChannelTeamsSetup', () => {
|
|||||||
await waitFor(() => expect(getByTestId('teams-update-notice')).toBeVisible());
|
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 () => {
|
it('takes the app name and description from the agent, not from inputs here', async () => {
|
||||||
const { getByTestId, queryByTestId } = renderComponent({ props: settingsProps() });
|
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() });
|
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -680,12 +911,14 @@ describe('AgentChannelTeamsSetup', () => {
|
|||||||
'agents.channels.teams.settings.updateNotice',
|
'agents.channels.teams.settings.updateNotice',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const quiet = getByTestId('teams-update-notice').className;
|
||||||
await openAvailability(getByTestId);
|
await openAvailability(getByTestId);
|
||||||
await fireEvent.click(getByTestId('teams-scope-groups'));
|
await fireEvent.click(getByTestId('teams-scope-groups'));
|
||||||
|
|
||||||
expect(getByTestId('teams-update-notice')).toHaveTextContent(
|
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 () => {
|
it('downloads the saved app from the card, not unsaved changes', async () => {
|
||||||
@@ -840,7 +1073,10 @@ describe('AgentChannelTeamsSetup', () => {
|
|||||||
expect.objectContaining({ groupChats: true }),
|
expect.objectContaining({ groupChats: true }),
|
||||||
);
|
);
|
||||||
expect(showMessage).toHaveBeenCalledWith(
|
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">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
import { saveAs } from 'file-saver';
|
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 {
|
import type {
|
||||||
AgentJsonConfig,
|
AgentJsonConfig,
|
||||||
AgentTeamsIntegrationSettings,
|
AgentTeamsIntegrationSettings,
|
||||||
@@ -65,6 +65,7 @@ const emit = defineEmits<{
|
|||||||
create: [];
|
create: [];
|
||||||
edit: [];
|
edit: [];
|
||||||
connect: [];
|
connect: [];
|
||||||
|
done: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
@@ -72,6 +73,8 @@ const rootStore = useRootStore();
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const agentTelemetry = useAgentTelemetry();
|
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 =
|
const ENTRA_APP_REGISTRATION_URL =
|
||||||
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade';
|
'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.
|
// Set when the credential changed during a download, so the saved zip is for the old bot.
|
||||||
const staleDownload = ref(false);
|
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() {
|
async function downloadAndConnect() {
|
||||||
const id = credentialId.value;
|
const id = credentialId.value;
|
||||||
staleDownload.value = false;
|
staleDownload.value = false;
|
||||||
@@ -277,6 +281,7 @@ async function downloadAndConnect() {
|
|||||||
staleDownload.value = true;
|
staleDownload.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
downloadedFor.value = id;
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: i18n.baseText('agents.channels.teams.setup.install.downloaded'),
|
title: i18n.baseText('agents.channels.teams.setup.install.downloaded'),
|
||||||
@@ -284,6 +289,32 @@ async function downloadAndConnect() {
|
|||||||
if (!props.connected) emit('connect');
|
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 modal clears the error when the credential changes. A conflict is about
|
||||||
// the credential itself, so it stays in step 2.
|
// the credential itself, so it stays in step 2.
|
||||||
const showConnectError = computed(
|
const showConnectError = computed(
|
||||||
@@ -371,10 +402,15 @@ const steps = computed(() => [
|
|||||||
title: i18n.baseText('agents.channels.teams.setup.availability.title'),
|
title: i18n.baseText('agents.channels.teams.setup.availability.title'),
|
||||||
description: i18n.baseText('agents.channels.teams.setup.availability.description'),
|
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',
|
id: 'install',
|
||||||
title: i18n.baseText('agents.channels.teams.setup.install.title'),
|
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 };
|
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() {
|
function showDownloaded() {
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: i18n.baseText('agents.channels.teams.setup.install.downloaded'),
|
title: i18n.baseText('agents.channels.teams.setup.install.downloaded'),
|
||||||
|
message: i18n.baseText('agents.channels.teams.setup.install.hint'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +480,7 @@ defineExpose({
|
|||||||
saveLabel,
|
saveLabel,
|
||||||
beforeSave,
|
beforeSave,
|
||||||
afterSave,
|
afterSave,
|
||||||
|
keepOpenAfterConnect: computed(() => props.mode === 'setup'),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -613,8 +652,8 @@ defineExpose({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="step.id === 'availability'"
|
v-else-if="step.id === 'availability'"
|
||||||
:class="[$style.stepStack, { [$style.locked]: !ready }]"
|
:class="[$style.stepStack, { [$style.locked]: availabilityLocked }]"
|
||||||
:inert="!ready || undefined"
|
:inert="availabilityLocked || undefined"
|
||||||
data-testid="teams-availability-step"
|
data-testid="teams-availability-step"
|
||||||
>
|
>
|
||||||
<AgentChannelTeamsAvailability
|
<AgentChannelTeamsAvailability
|
||||||
@@ -624,11 +663,7 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="step.id === 'install'" :class="$style.stepStack">
|
<div v-else-if="step.id === 'download'" :class="$style.stepStack">
|
||||||
<N8nText :class="$style.hint" size="small">
|
|
||||||
{{ i18n.baseText('agents.channels.teams.setup.install.hint') }}
|
|
||||||
</N8nText>
|
|
||||||
|
|
||||||
<AgentChannelTeamsIdentityCard
|
<AgentChannelTeamsIdentityCard
|
||||||
:name="defaultDisplayName"
|
:name="defaultDisplayName"
|
||||||
:description="defaultDescription"
|
:description="defaultDescription"
|
||||||
@@ -656,7 +691,25 @@ defineExpose({
|
|||||||
>
|
>
|
||||||
{{ i18n.baseText('agents.channels.teams.setup.install.needsReady') }}
|
{{ i18n.baseText('agents.channels.teams.setup.install.needsReady') }}
|
||||||
</N8nText>
|
</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">
|
<div v-if="showConnectError" :class="$style.actions">
|
||||||
<N8nText size="small" :class="$style.error" data-testid="teams-connect-error">
|
<N8nText size="small" :class="$style.error" data-testid="teams-connect-error">
|
||||||
{{
|
{{
|
||||||
@@ -676,14 +729,6 @@ defineExpose({
|
|||||||
{{ i18n.baseText('generic.retry') }}
|
{{ i18n.baseText('generic.retry') }}
|
||||||
</N8nButton>
|
</N8nButton>
|
||||||
</div>
|
</div>
|
||||||
<N8nText
|
|
||||||
v-if="downloadError"
|
|
||||||
size="small"
|
|
||||||
:class="$style.error"
|
|
||||||
data-testid="teams-download-error"
|
|
||||||
>
|
|
||||||
{{ downloadError }}
|
|
||||||
</N8nText>
|
|
||||||
<N8nText
|
<N8nText
|
||||||
v-if="connected && !isPublished"
|
v-if="connected && !isPublished"
|
||||||
:class="$style.hint"
|
:class="$style.hint"
|
||||||
@@ -692,6 +737,15 @@ defineExpose({
|
|||||||
>
|
>
|
||||||
{{ i18n.baseText('agents.channels.teams.setup.publishNotice') }}
|
{{ i18n.baseText('agents.channels.teams.setup.publishNotice') }}
|
||||||
</N8nText>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -712,13 +766,7 @@ defineExpose({
|
|||||||
:class="manifestChanged ? undefined : $style.hint"
|
:class="manifestChanged ? undefined : $style.hint"
|
||||||
data-testid="teams-update-notice"
|
data-testid="teams-update-notice"
|
||||||
>
|
>
|
||||||
{{
|
{{ i18n.baseText('agents.channels.teams.settings.updateNotice') }}
|
||||||
i18n.baseText(
|
|
||||||
manifestChanged
|
|
||||||
? 'agents.channels.teams.settings.updateNoticeChanged'
|
|
||||||
: 'agents.channels.teams.settings.updateNotice',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -739,6 +787,9 @@ defineExpose({
|
|||||||
:loading="downloading"
|
:loading="downloading"
|
||||||
@download="downloadSaved"
|
@download="downloadSaved"
|
||||||
/>
|
/>
|
||||||
|
<N8nText :class="$style.hint" size="small" data-testid="teams-upload-instructions">
|
||||||
|
{{ i18n.baseText('agents.channels.teams.settings.uploadHint') }}
|
||||||
|
</N8nText>
|
||||||
<N8nText
|
<N8nText
|
||||||
v-if="downloadError"
|
v-if="downloadError"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -748,6 +799,19 @@ defineExpose({
|
|||||||
{{ downloadError }}
|
{{ downloadError }}
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -811,6 +875,16 @@ defineExpose({
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.azureLink {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkContent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing--4xs);
|
||||||
|
}
|
||||||
|
|
||||||
.urlInput {
|
.urlInput {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface AgentChannelViewExpose {
|
|||||||
saveLabel?: string;
|
saveLabel?: string;
|
||||||
/** Runs after a successful save, before the modal closes. Must not throw. */
|
/** Runs after a successful save, before the modal closes. Must not throw. */
|
||||||
afterSave?: () => Promise<void>;
|
afterSave?: () => Promise<void>;
|
||||||
|
/** Keeps the modal open after connecting. The view emits `done` to close it. */
|
||||||
|
keepOpenAfterConnect?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentChannelRuntimeContext {
|
export interface AgentChannelRuntimeContext {
|
||||||
|
|||||||
@@ -481,7 +481,7 @@ function finishConnect(channelType: string) {
|
|||||||
endSetupTracking(true);
|
endSetupTracking(true);
|
||||||
emit('channel-connected', channelType);
|
emit('channel-connected', channelType);
|
||||||
emit('agent-changed');
|
emit('agent-changed');
|
||||||
completeAndClose();
|
if (!channelViewRef.value?.keepOpenAfterConnect) completeAndClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same shape as `persistAgent`: reports its own failure and returns whether it saved. */
|
/** Same shape as `persistAgent`: reports its own failure and returns whether it saved. */
|
||||||
@@ -670,6 +670,7 @@ watch(
|
|||||||
@edit="editCredential"
|
@edit="editCredential"
|
||||||
@connect="saveChannelConfig"
|
@connect="saveChannelConfig"
|
||||||
@connected="handlePlatformConnected"
|
@connected="handlePlatformConnected"
|
||||||
|
@done="completeAndClose"
|
||||||
/>
|
/>
|
||||||
<N8nText
|
<N8nText
|
||||||
v-if="saveAttempted && !currentChannelCredentialId"
|
v-if="saveAttempted && !currentChannelCredentialId"
|
||||||
|
|||||||
Reference in New Issue
Block a user