diff --git a/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts b/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts index e0dd19f76d..f4abc03909 100644 --- a/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts +++ b/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts @@ -6,6 +6,7 @@ import { } from '@openops/blocks-framework'; import { networkUtls, SharedSystemProp, system } from '@openops/server-shared'; import { ExecutionType } from '@openops/shared'; +import { buildWrapperUrl } from '../common/build-wrapper-url'; import { chatExists } from '../common/chat-exists'; import { ChannelOption, ChatOption, ChatTypes } from '../common/chat-types'; import { chatsAndChannels } from '../common/chats-and-channels'; @@ -68,6 +69,31 @@ export const requestActionMessageAction = createAction({ ], }, }), + followUpQuestion: Property.ShortText({ + displayName: 'Follow-up question', + description: + 'Optionally ask a follow-up question in a popup window when this button is clicked.', + required: false, + }), + answerFormat: Property.StaticDropdown({ + displayName: 'Answer format', + description: 'Only used when a follow-up question is set.', + required: false, + defaultValue: 'text', + options: { + options: [ + { label: 'Text', value: 'text' }, + { label: 'Email', value: 'email' }, + { label: 'Number', value: 'number' }, + ], + }, + }), + noAnswerOption: Property.ShortText({ + displayName: 'No answer option', + description: + "Optional text for a secondary choice when the person can't answer.", + required: false, + }), }, }), timeoutInDays: Property.Number({ @@ -133,9 +159,19 @@ export const requestActionMessageAction = createAction({ ); return { ...action, - resumeUrl: `${frontendUrl}/html/resume_execution.html?isTest=${ - context.run.isTest - }&redirectUrl=${encodeURIComponent(resumeUrl)}`, + resumeUrl: buildWrapperUrl({ + frontendUrl, + isTest: context.run.isTest, + resumeUrl, + followUp: action.followUpQuestion + ? { + question: action.followUpQuestion, + answerFormat: action.answerFormat, + noAnswerOption: action.noAnswerOption, + title: header, + } + : undefined, + }), }; }); diff --git a/packages/blocks/microsoft-teams/src/lib/common/build-wrapper-url.ts b/packages/blocks/microsoft-teams/src/lib/common/build-wrapper-url.ts new file mode 100644 index 0000000000..a872ed193f --- /dev/null +++ b/packages/blocks/microsoft-teams/src/lib/common/build-wrapper-url.ts @@ -0,0 +1,58 @@ +const RESUME_PAGE = 'resume_execution.html'; +const COLLECT_INPUT_PAGE = 'collect_input.html'; + +const ANSWER_FORMATS = new Set(['text', 'email', 'number']); +const DEFAULT_ANSWER_FORMAT = 'text'; + +export type ButtonFollowUp = { + question: string; + answerFormat?: string; + noAnswerOption?: string; + title?: string; +}; + +// The collect-input page sends the answer back as the `answer` resume query +// param, surfaced in the step output as parameters.answer. +export function buildWrapperUrl({ + frontendUrl, + isTest, + resumeUrl, + followUp, +}: { + frontendUrl: string; + isTest: boolean; + resumeUrl: string; + followUp?: ButtonFollowUp; +}): string { + const question = followUp?.question?.trim(); + + if (!question) { + return `${frontendUrl}/html/${RESUME_PAGE}?isTest=${isTest}&redirectUrl=${encodeURIComponent( + resumeUrl, + )}`; + } + + const format = + followUp?.answerFormat && ANSWER_FORMATS.has(followUp.answerFormat) + ? followUp.answerFormat + : DEFAULT_ANSWER_FORMAT; + + const params = new URLSearchParams({ + isTest: String(isTest), + question, + format, + redirectUrl: resumeUrl, + }); + + const noAnswerOption = followUp?.noAnswerOption?.trim(); + if (noAnswerOption) { + params.set('noAnswerOption', noAnswerOption); + } + + const title = followUp?.title?.trim(); + if (title) { + params.set('title', title); + } + + return `${frontendUrl}/html/${COLLECT_INPUT_PAGE}?${params.toString()}`; +} diff --git a/packages/blocks/microsoft-teams/src/lib/common/generate-message-with-buttons.ts b/packages/blocks/microsoft-teams/src/lib/common/generate-message-with-buttons.ts index 7c093b5a7f..6387678aa0 100644 --- a/packages/blocks/microsoft-teams/src/lib/common/generate-message-with-buttons.ts +++ b/packages/blocks/microsoft-teams/src/lib/common/generate-message-with-buttons.ts @@ -1,6 +1,9 @@ export type TeamsMessageAction = { buttonText: string; buttonStyle: string; + followUpQuestion?: string; + answerFormat?: string; + noAnswerOption?: string; }; export type TeamsMessageButton = TeamsMessageAction & { resumeUrl?: string }; diff --git a/packages/blocks/microsoft-teams/src/lib/common/on-action-received.ts b/packages/blocks/microsoft-teams/src/lib/common/on-action-received.ts index 558fd6de11..ae5be361ff 100644 --- a/packages/blocks/microsoft-teams/src/lib/common/on-action-received.ts +++ b/packages/blocks/microsoft-teams/src/lib/common/on-action-received.ts @@ -5,6 +5,18 @@ import { TeamsMessageButton, } from './generate-message-with-buttons'; +// Query params the resume mechanism uses for routing; everything else is +// user-provided data (e.g. from a form wrapper page) surfaced as `parameters`. +const EXCLUDED_RESUME_PARAMS = new Set([ + 'button', + 'path', + 'executionCorrelationId', + 'isTest', + '__proto__', + 'constructor', + 'prototype', +]); + export const onActionReceived = async ({ messageObj, actions, @@ -14,8 +26,9 @@ export const onActionReceived = async ({ actions: TeamsMessageButton[]; context: any; }) => { - const resumePayload = context.resumePayload - ?.queryParams as unknown as InteractionPayload; + const resumePayload = context.resumePayload?.queryParams as unknown as + | (InteractionPayload & Record) + | undefined; const isResumedDueToButtonClicked = !!resumePayload?.button; if (!isResumedDueToButtonClicked) { @@ -23,6 +36,7 @@ export const onActionReceived = async ({ action: '', isExpired: true, message: messageObj, + parameters: {}, }; } @@ -52,12 +66,20 @@ export const onActionReceived = async ({ action: '', isExpired: undefined, message: messageObj, + parameters: {}, }; } + const parameters = Object.fromEntries( + Object.entries(resumePayload).filter( + ([key]) => !EXCLUDED_RESUME_PARAMS.has(key), + ), + ); + return { action: resumePayload.button, message: messageObj, isExpired: false, + parameters, }; }; diff --git a/packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts b/packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts new file mode 100644 index 0000000000..a36e93c3e0 --- /dev/null +++ b/packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts @@ -0,0 +1,101 @@ +import { buildWrapperUrl } from '../src/lib/common/build-wrapper-url'; + +const baseParams = { + frontendUrl: 'https://app.openops.com', + isTest: false, + resumeUrl: + 'https://api.openops.com/v1/flow-runs/run-1/requests/pause-1?button=Not%20mine&path=step_1', +}; + +const encodedResumeUrl = encodeURIComponent(baseParams.resumeUrl); + +describe('buildWrapperUrl', () => { + test('uses the default resume page when no follow-up question is given', () => { + const url = buildWrapperUrl(baseParams); + + expect(url).toBe( + `https://app.openops.com/html/resume_execution.html?isTest=false&redirectUrl=${encodedResumeUrl}`, + ); + }); + + test('uses the collect-input page when a follow-up question is given', () => { + const url = buildWrapperUrl({ + ...baseParams, + followUp: { + question: "What is the correct owner's email?", + answerFormat: 'email', + }, + }); + + const parsed = new URL(url); + expect(parsed.origin).toBe('https://app.openops.com'); + expect(parsed.pathname).toBe('/html/collect_input.html'); + expect(parsed.searchParams.get('question')).toBe( + "What is the correct owner's email?", + ); + expect(parsed.searchParams.get('format')).toBe('email'); + expect(parsed.searchParams.get('isTest')).toBe('false'); + expect(parsed.searchParams.get('redirectUrl')).toBe(baseParams.resumeUrl); + }); + + test('includes the message header as the form title when given', () => { + const url = buildWrapperUrl({ + ...baseParams, + followUp: { + question: 'Who owns this?', + title: 'Cost savings opportunity: vol-123', + }, + }); + + expect(new URL(url).searchParams.get('title')).toBe( + 'Cost savings opportunity: vol-123', + ); + }); + + test('includes the no-answer option when given', () => { + const url = buildWrapperUrl({ + ...baseParams, + followUp: { + question: "What is the correct owner's email?", + noAnswerOption: "I don't know the owner", + }, + }); + + expect(new URL(url).searchParams.get('noAnswerOption')).toBe( + "I don't know the owner", + ); + }); + + test('omits the no-answer option param when not given', () => { + const url = buildWrapperUrl({ + ...baseParams, + followUp: { question: 'Why was this dismissed?' }, + }); + + expect(new URL(url).searchParams.has('noAnswerOption')).toBe(false); + }); + + test('ignores a follow-up with an empty question', () => { + const url = buildWrapperUrl({ + ...baseParams, + followUp: { question: ' ' }, + }); + + expect(url).toContain('/html/resume_execution.html?'); + }); + + test('defaults the answer format to text for unknown formats', () => { + const url = buildWrapperUrl({ + ...baseParams, + followUp: { question: 'Why?', answerFormat: 'dropdown' }, + }); + + expect(new URL(url).searchParams.get('format')).toBe('text'); + }); + + test('passes isTest through', () => { + const url = buildWrapperUrl({ ...baseParams, isTest: true }); + + expect(url).toContain('isTest=true'); + }); +}); diff --git a/packages/blocks/microsoft-teams/test/on-action-received.test.ts b/packages/blocks/microsoft-teams/test/on-action-received.test.ts index 159dcacb5b..cfa721c72f 100644 --- a/packages/blocks/microsoft-teams/test/on-action-received.test.ts +++ b/packages/blocks/microsoft-teams/test/on-action-received.test.ts @@ -23,7 +23,7 @@ const mockContext = { queryParams: { button: 'Approve', path: 'execution-path-1', - }, + } as Record, }, currentExecutionPath: 'execution-path-1', run: { pause: jest.fn() }, @@ -50,6 +50,7 @@ describe('onActionReceived', () => { action: '', isExpired: true, message: mockMessageObj, + parameters: {}, }); }); @@ -89,6 +90,78 @@ describe('onActionReceived', () => { action: 'Approve', message: mockMessageObj, isExpired: false, + parameters: {}, }); }); + + test('should surface extra resume query params as parameters', async () => { + mockContext.store.get.mockResolvedValueOnce({ pauseMetadata: 'mock-data' }); + mockContext.resumePayload = { + queryParams: { + button: 'Approve', + path: 'execution-path-1', + executionCorrelationId: 'corr-1', + isTest: 'false', + newOwner: 'alice@example.com', + }, + }; + + const result = await onActionReceived({ + messageObj: mockMessageObj, + actions: mockActions, + context: mockContext, + }); + + expect(result).toEqual({ + action: 'Approve', + message: mockMessageObj, + isExpired: false, + parameters: { newOwner: 'alice@example.com' }, + }); + }); + + test('should not include prototype-pollution keys in parameters', async () => { + mockContext.store.get.mockResolvedValueOnce({ pauseMetadata: 'mock-data' }); + const queryParams = Object.create(null) as Record; + queryParams['button'] = 'Approve'; + queryParams['path'] = 'execution-path-1'; + Object.defineProperty(queryParams, '__proto__', { + value: 'polluted', + enumerable: true, + configurable: true, + writable: true, + }); + queryParams['constructor'] = 'polluted'; + queryParams['prototype'] = 'polluted'; + queryParams['answer'] = 'alice@example.com'; + mockContext.resumePayload = { queryParams }; + + const result = await onActionReceived({ + messageObj: mockMessageObj, + actions: mockActions, + context: mockContext, + }); + + expect(result.parameters).toEqual({ answer: 'alice@example.com' }); + }); + + test('should not include internal routing params in parameters', async () => { + mockContext.store.get.mockResolvedValueOnce({ pauseMetadata: 'mock-data' }); + mockContext.resumePayload = { + queryParams: { + button: 'Approve', + path: 'execution-path-1', + executionCorrelationId: 'corr-1', + isTest: 'true', + }, + }; + + const result = await onActionReceived({ + messageObj: mockMessageObj, + actions: mockActions, + context: mockContext, + }); + + expect(result.parameters).toEqual({}); + }); }); diff --git a/packages/react-ui/public/html/collect_input.html b/packages/react-ui/public/html/collect_input.html new file mode 100644 index 0000000000..832295c442 --- /dev/null +++ b/packages/react-ui/public/html/collect_input.html @@ -0,0 +1,286 @@ + + + + + + Provide Input + + + + + +
+ + +
+
+ + +

Please enter a valid value.

+ +
+ +
+ + + + +
+ + + +