mattermost/e2e-tests/cypress/webhook_serve.js
Scott Bishel 53aa05d8c6
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Interactive Dialog - DateTime manual entry and timezone support (#34932)
* Respect user display preferences for date and time formatting

User Preference Support:
- Read isUseMilitaryTime preference from user settings
- Apply 24-hour format when enabled (14:00 instead of 2:00 PM)
- Apply 12-hour format when disabled (2:00 PM instead of 14:00)
- Pass useTime prop to Timestamp component with correct hourCycle/hour12

Date Formatting Consistency:
- Create formatDateForDisplay() utility in date_utils.ts
- Centralize date formatting logic (month: 'short', day/year: 'numeric')
- Use consistent "Jan 15, 2025" format across all date/datetime fields
- Replace DateTime.fromJSDate().toLocaleString() which varies by browser

Components Updated:
- DateTimeInput: Use isMilitaryTime for dropdown and selected time display
- DateTimeInput: Use formatDateForDisplay for date display
- AppsFormDateField: Use formatDateForDisplay instead of inline Intl code

Tests Added:
- 4 tests for user preference handling (military time, locale)
- 5 tests for formatDateForDisplay utility
- Updated snapshot for Timestamp changes

Benefits:
- Single source of truth for date formatting
- Easy to change format globally by updating one function
- Respects user preferences consistently
- Fixes inconsistency where datetime showed "1/1/2026" vs date showing "Jan 1, 2026"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add timezone and manual time entry support for datetime fields

Object Model:
- Create DialogDateTimeConfig with TimeInterval, LocationTimezone, AllowManualTimeEntry
- Add DateTimeConfig field to DialogElement
- Keep legacy MinDate, MaxDate, TimeInterval for fallback

Timezone Support (location_timezone):
- Display datetime in specific IANA timezone (e.g., "Europe/London", "Asia/Tokyo")
- Show timezone indicator: "🌍 Times in GMT"
- Preserve timezone through all operations
- Fix momentToString to clone before converting to UTC (prevents mutation)
- Use moment.tz array syntax for timezone-safe moment creation
- Generate time intervals starting at midnight in display timezone

Manual Time Entry (allow_manual_time_entry):
- Add parseTimeString() function supporting multiple formats:
  - 12-hour: 12a, 12:30p, 3:45pm
  - 24-hour: 14:30, 9:15
- Add TimeInputManual component with text input
- Conditional rendering: manual input OR dropdown
- No rounding for manual entry (exact minutes preserved)
- No auto-advance (validation only, show error for invalid format)
- Respects user's 12h/24h preference for placeholder

Critical Bug Fixes:
- Fix getTimeInIntervals to return Moment[] instead of Date[] (preserves timezone)
- Fix momentToString mutation: use .clone() before .utc()
- Use .clone() when calling .startOf('day') to preserve timezone
- Use moment.tz([...], timezone) array syntax instead of .tz().hour() mutation
- Display selected time using .format() instead of Timestamp component
- Fix null handling: optional fields start empty, show '--:--'
- Manual entry gets exact current time, dropdown gets rounded time

Component Updates:
- DateTimeInput: Add TimeInputManual component, parseTimeString, timezone handling
- AppsFormDateTimeField: Extract config, timezone indicator, pass timezone to child
- Modal components: Handle Moment | null signatures
- CSS: Add manual entry input styles with error states

Features:
- Timezone-aware time generation (dropdown starts at midnight in display TZ)
- Manual entry works with timezones (creates moments in correct TZ)
- Optional fields start empty (null value, no display default)
- Required datetime fields get rounded default from apps_form_component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Fix momentToString mutation - clone before converting to UTC

Prevents .utc() from mutating the original moment object.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add E2E test for 12h/24h time preference support

Test MM-T2530H verifies that datetime fields respect user's display preference:
- Sets preference to 24-hour format
- Verifies dropdown shows times as 14:00, 15:00, etc.
- Verifies selected time displays in 24-hour format
- Changes preference to 12-hour format
- Verifies dropdown shows times as 2:00 PM, 3:00 PM, etc.

Uses cy.apiSaveClockDisplayModeTo24HourPreference() to set user preference.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Auto-round time to interval boundaries in DateTimeInput

Automatically rounds displayed time to timePickerInterval to ensure
consistent behavior across all callers.

Problem:
- DND modal and Custom Status modal showed unrounded times (e.g., 13:47)
- Should show rounded times (e.g., 14:00) to match dropdown intervals
- Some callers pre-rounded, others didn't (inconsistent)

Solution:
- Add useEffect in DateTimeInput that auto-rounds on mount
- Only calls handleChange if time needs rounding
- Uses timePickerInterval prop or 30-minute default
- Harmless for callers that already pre-round (no change triggered)

Behavior:
- DND modal: Now shows 14:00 instead of 13:47
- Custom Status: Still works (already pre-rounded, so no-op)
- Post Reminder: Still works (already pre-rounded, so no-op)
- Interactive Dialog: Still works (uses custom intervals)

Added 3 unit tests for auto-rounding behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* lint fix

* Add deferred login cleanup to post_test.go 'not logged in' test

Ensures the test helper is logged back in after the logout test completes, preventing test state issues for subsequent tests.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add unit tests for parseTimeString and timezone handling

parseTimeString tests (9 test cases):
- 12-hour format with AM/PM (12a, 3:30pm, etc.)
- 24-hour format (14:30, 23:59, etc.)
- Time without minutes (defaults to :00)
- Invalid hours, minutes, and formats
- Edge cases (midnight 12:00am, noon 12:00pm)

Timezone handling tests (3 test cases):
- Preserve timezone in getTimeInIntervals
- Generate intervals starting at midnight in timezone
- Timezone conversion pattern verification

Total: 12 new tests added (32 total in file)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add E2E tests and webhook support for timezone/manual entry

E2E Tests Added (MM-T2530O through MM-T2530S):
- MM-T2530O: Manual time entry basic functionality
- MM-T2530P: Manual time entry multiple formats (12a, 14:30, 9pm)
- MM-T2530Q: Manual time entry invalid format handling
- MM-T2530R: Timezone support dropdown (London GMT)
- MM-T2530S: Timezone support manual entry (London GMT)

Webhook Server Support:
- Added getTimezoneManualDialog() to webhook_utils.js
- Added 'timezone-manual' case to webhook_serve.js
- Dialog with 3 fields: local manual, London dropdown, London manual

Bug Fixes:
- Skip auto-rounding for allowManualTimeEntry fields (preserve exact minutes)
- Generate dropdown options even when displayTime is null (use currentTime fallback)
- Scope Cypress selectors with .within() to avoid duplicate ID issues

All tests passing (13 total datetime tests).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Fix ESLint no-multi-spaces in apps.ts

Remove extra spacing before comments to comply with ESLint rules.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Fix gofmt formatting in integration_action.go

Align Options, MultiSelect, and Refresh field spacing to match Go formatting standards.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* more lint fixes

* css lint fix

* i18n-extract

* lint fixes

* update snapshot

* Fix modal scroll containment for datetime fields

The .modal-overflow class was applying overflow: visible to .modal-body,
which broke scroll containment when datetime fields were present. This
caused the entire form to scroll instead of scrolling within the modal-body
viewport.

Changes:
- Remove .modal-body overflow override from .modal-overflow class to
  preserve scroll containment while still allowing date/time popups to
  display correctly via z-index
- Remove italic styling from timezone indicator for cleaner appearance
- Remove redundant "Time" label from manual time entry input (aria-label
  is sufficient for accessibility)
- Add CSS rule to ensure "(optional)" label text is not bold

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* fixes for cypress tests

* fix for using timezone crossing dates

* fix dateonly strings parse failures

* regex fix

* linter fix

---------

Co-authored-by: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-02-16 13:32:31 -07:00

601 lines
20 KiB
JavaScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
const express = require('express');
const axios = require('axios');
const ClientOAuth2 = require('client-oauth2');
const webhookUtils = require('./utils/webhook_utils');
const postMessageAs = require('./tests/plugins/post_message_as');
const port = 3000;
const server = express();
server.use(express.json());
server.use(express.urlencoded({extended: true}));
process.title = process.argv[2];
server.get('/', ping);
server.post('/setup', doSetup);
server.post('/message_menus', postMessageMenus);
server.post('/dialog_request', onDialogRequest);
server.post('/simple_dialog_request', onSimpleDialogRequest);
server.post('/user_and_channel_dialog_request', onUserAndChannelDialogRequest);
server.post('/dialog_submit', onDialogSubmit);
server.post('/boolean_dialog_request', onBooleanDialogRequest);
server.post('/multiselect_dialog_request', onMultiSelectDialogRequest);
server.post('/dynamic_select_dialog_request', onDynamicSelectDialogRequest);
server.post('/dynamic_select_source', onDynamicSelectSource);
server.post('/dialog/field-refresh', onFieldRefreshDialogRequest);
server.post('/dialog/multistep', onMultistepDialogRequest);
server.post('/field_refresh_source', onFieldRefreshSource);
server.post('/datetime_dialog_request', onDateTimeDialogRequest);
server.post('/datetime_dialog_submit', onDateTimeDialogSubmit);
server.post('/slack_compatible_message_response', postSlackCompatibleMessageResponse);
server.post('/send_message_to_channel', postSendMessageToChannel);
server.post('/post_outgoing_webhook', postOutgoingWebhook);
server.post('/send_oauth_credentials', postSendOauthCredentials);
server.get('/start_oauth', getStartOAuth);
server.get('/complete_oauth', getCompleteOauth);
server.post('/post_oauth_message', postOAuthMessage);
server.listen(port, () => console.log(`Webhook test server listening on port ${port}!`));
function ping(req, res) {
return res.json({
message: 'I\'m alive!',
endpoints: [
'GET /',
'POST /setup',
'POST /message_menus',
'POST /dialog_request',
'POST /simple_dialog_request',
'POST /user_and_channel_dialog_request',
'POST /dialog_submit',
'POST /boolean_dialog_request',
'POST /multiselect_dialog_request',
'POST /dynamic_select_dialog_request',
'POST /dynamic_select_source',
'POST /dialog/field-refresh',
'POST /dialog/multistep',
'POST /field_refresh_source',
'POST /datetime_dialog_request',
'POST /datetime_dialog_submit',
'POST /slack_compatible_message_response',
'POST /send_message_to_channel',
'POST /post_outgoing_webhook',
'POST /send_oauth_credentials',
'GET /start_oauth',
'GET /complete_oauth',
'POST /post_oauth_message',
],
});
}
// Set base URLs and credential to be accessible by any endpoint
let baseUrl;
let webhookBaseUrl;
let adminUsername;
let adminPassword;
function doSetup(req, res) {
baseUrl = req.body.baseUrl;
webhookBaseUrl = req.body.webhookBaseUrl;
adminUsername = req.body.adminUsername;
adminPassword = req.body.adminPassword;
return res.status(201).send('Successfully setup the new base URLs and credential.');
}
let client;
let authedUser;
function postSendOauthCredentials(req, res) {
const {
appID,
appSecret,
} = req.body;
client = new ClientOAuth2({
clientId: appID,
clientSecret: appSecret,
authorizationUri: `${baseUrl}/oauth/authorize`,
accessTokenUri: `${baseUrl}/oauth/access_token`,
redirectUri: `${webhookBaseUrl}/complete_oauth`,
});
return res.status(200).send('OK');
}
function getStartOAuth(req, res) {
return res.redirect(client.code.getUri());
}
function getCompleteOauth(req, res) {
client.code.getToken(req.originalUrl).then((user) => {
authedUser = user;
return res.status(200).send('OK');
}).catch((reason) => {
return res.status(reason.status).send(reason);
});
}
async function postOAuthMessage(req, res) {
const {channelId, message, rootId, createAt} = req.body;
const apiUrl = `${baseUrl}/api/v4/posts`;
authedUser.sign({
method: 'post',
url: apiUrl,
});
try {
await axios({
url: apiUrl,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
Authorization: 'Bearer ' + authedUser.accessToken,
},
method: 'post',
data: {
channel_id: channelId,
message,
type: '',
create_at: createAt,
root_id: rootId,
},
});
} catch {
// Do nothing
}
return res.status(200).send('OK');
}
function postSlackCompatibleMessageResponse(req, res) {
const {spoiler, skipSlackParsing} = req.body.context;
res.setHeader('Content-Type', 'application/json');
return res.json({
ephemeral_text: spoiler,
skip_slack_parsing: skipSlackParsing,
});
}
function postMessageMenus(req, res) {
let responseData = {};
const {body} = req;
if (body && body.context.action === 'do_something') {
responseData = {
ephemeral_text: `Ephemeral | ${body.type} ${body.data_source} option: ${body.context.selected_option}`,
};
}
res.setHeader('Content-Type', 'application/json');
return res.json(responseData);
}
async function openDialog(dialog) {
await axios({
method: 'post',
url: `${baseUrl}/api/v4/actions/dialogs/open`,
data: dialog,
});
}
function onDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getFullDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Full dialog triggered via slash command!'});
}
function onSimpleDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getSimpleDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Simple dialog triggered via slash command!'});
}
function onUserAndChannelDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getUserAndChannelDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Simple dialog triggered via slash command!'});
}
function onBooleanDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getBooleanDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Simple dialog triggered via slash command!'});
}
function onMultiSelectDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
// Check URL parameters or body for includeDefaults flag
const includeDefaults = req.query.includeDefaults === 'true' || req.query.includeDefaults === true;
const dialog = webhookUtils.getMultiSelectDialog(body.trigger_id, webhookBaseUrl, includeDefaults);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Multiselect dialog triggered via slash command!'});
}
function onDynamicSelectDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getDynamicSelectDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Dynamic select dialog triggered via slash command!'});
}
function onDynamicSelectSource(req, res) {
const {body} = req;
// Simulate dynamic options based on search text
const searchText = (body.submission.query || '').toLowerCase();
const allOptions = [
{text: 'Backend Engineer', value: 'backend_eng'},
{text: 'Frontend Engineer', value: 'frontend_eng'},
{text: 'Full Stack Engineer', value: 'fullstack_eng'},
{text: 'DevOps Engineer', value: 'devops_eng'},
{text: 'QA Engineer', value: 'qa_eng'},
{text: 'Product Manager', value: 'product_mgr'},
{text: 'Engineering Manager', value: 'eng_mgr'},
{text: 'Senior Backend Engineer', value: 'sr_backend_eng'},
{text: 'Senior Frontend Engineer', value: 'sr_frontend_eng'},
{text: 'Principal Engineer', value: 'principal_eng'},
{text: 'Staff Engineer', value: 'staff_eng'},
{text: 'Technical Lead', value: 'tech_lead'},
];
// Filter options based on search text
const filteredOptions = searchText ?
allOptions.filter((option) =>
option.text.toLowerCase().includes(searchText) ||
option.value.toLowerCase().includes(searchText)) :
allOptions.slice(0, 6); // Limit to first 6 if no search
res.setHeader('Content-Type', 'application/json');
return res.json({
items: filteredOptions,
});
}
function onDateTimeDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
let dialog;
const command = body.text ? body.text.trim() : '';
// Use focused dialog functions based on command parameter
switch (command) {
case 'basic':
dialog = webhookUtils.getBasicDateDialog(body.trigger_id, webhookBaseUrl);
break;
case 'mindate':
dialog = webhookUtils.getMinDateConstraintDialog(body.trigger_id, webhookBaseUrl);
break;
case 'interval':
dialog = webhookUtils.getCustomIntervalDialog(body.trigger_id, webhookBaseUrl);
break;
case 'relative':
dialog = webhookUtils.getRelativeDateDialog(body.trigger_id, webhookBaseUrl);
break;
case 'timezone-manual':
dialog = webhookUtils.getTimezoneManualDialog(body.trigger_id, webhookBaseUrl);
break;
default:
// Default to basic datetime dialog for backward compatibility
dialog = webhookUtils.getBasicDateTimeDialog(body.trigger_id, webhookBaseUrl);
break;
}
console.log('Opening DateTime dialog', dialog.dialog.title);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'DateTime dialog triggered via slash command!'});
}
function onDateTimeDialogSubmit(req, res) {
console.log('DateTime dialog submit handler called!');
const {body} = req;
res.setHeader('Content-Type', 'application/json');
// Log the submitted datetime values for debugging
console.log('DateTime dialog submission:', JSON.stringify(body, null, 2));
// Extract datetime values from submission
const submission = body.submission || {};
const eventDate = submission.event_date;
const meetingTime = submission.meeting_time;
const relativeDate = submission.relative_date;
const relativeDateTime = submission.relative_datetime;
// Create a success message with the submitted values
let message = 'Form submitted successfully! ';
if (eventDate || meetingTime || relativeDate || relativeDateTime) {
message += 'Submitted values: ';
if (eventDate) {
message += `Event Date: ${eventDate}, `;
}
if (meetingTime) {
message += `Meeting Time: ${meetingTime}, `;
}
if (relativeDate) {
message += `Relative Date: ${relativeDate}, `;
}
if (relativeDateTime) {
message += `Relative DateTime: ${relativeDateTime}, `;
}
message = message.slice(0, -2); // Remove trailing comma and space
}
// Send success response that will appear as a post in the channel
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
function onDialogSubmit(req, res) {
const {body} = req;
res.setHeader('Content-Type', 'application/json');
let message;
if (body.cancelled) {
message = 'Dialog cancelled';
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
// Check if this is a multistep submission
if (body.callback_id === 'multistep_callback') {
const currentState = body.state || '';
// Determine next step based on current state
if (currentState === 'step1') {
// Move to step 2
const nextForm = webhookUtils.getMultistepStep2Dialog(null, webhookBaseUrl);
return res.json({
type: 'form',
form: nextForm,
});
} else if (currentState === 'step2') {
// Move to step 3
const nextForm = webhookUtils.getMultistepStep3Dialog(null, webhookBaseUrl);
return res.json({
type: 'form',
form: nextForm,
});
}
// Final step - complete the multistep
const submission = body.submission || {};
message = `Multistep completed successfully! Final step values: ${JSON.stringify(submission, null, 2)}`;
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
// Check if this is a field refresh dialog submission
if (body.callback_id === 'field_refresh_callback') {
const submission = body.submission || {};
message = `Field refresh dialog submitted successfully! Values: ${JSON.stringify(submission, null, 2)}`;
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
// Regular dialog submission
message = 'Dialog submitted';
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
/**
* @route "POST /send_message_to_channel?type={messageType}&channel_id={channelId}"
* @query type - message type of empty string for regular message if not provided (default), "system_message", etc
* @query channel_id - channel where to send the message
*/
function postSendMessageToChannel(req, res) {
const channelId = req.query.channel_id;
const response = {
response_type: 'in_channel',
text: 'Extra response 2',
channel_id: channelId,
extra_responses: [{
response_type: 'in_channel',
text: 'Hello World',
channel_id: channelId,
}],
};
if (req.query.type) {
response.type = req.query.type;
}
res.json(response);
}
// Convenient way to send response in a channel by using sysadmin account
function sendSysadminResponse(message, channelId) {
postMessageAs({
sender: {
username: adminUsername,
password: adminPassword,
},
message,
channelId,
baseUrl,
});
}
const responseTypes = ['in_channel', 'comment'];
function getWebhookResponse(body, {responseType, username, iconUrl}) {
const payload = Object.entries(body).map(([key, value]) => `- ${key}: "${value}"`).join('\n');
return `
\`\`\`
#### Outgoing Webhook Payload
${payload}
#### Webhook override to Mattermost instance
- response_type: "${responseType}"
- type: ""
- username: "${username}"
- icon_url: "${iconUrl}"
\`\`\`
`;
}
/**
* @route "POST /post_outgoing_webhook?override_username={username}&override_icon_url={iconUrl}&response_type={comment}"
* @query override_username - the user name that overrides the user name defined by the outgoing webhook
* @query override_icon_url - the user icon url that overrides the user icon url defined by the outgoing webhook
* @query response_type - "in_channel" (default) or "comment"
*/
function postOutgoingWebhook(req, res) {
const {body, query} = req;
if (!body) {
res.status(404).send({error: 'Invalid data'});
}
const responseType = query.response_type || responseTypes[0];
const username = query.override_username || '';
const iconUrl = query.override_icon_url || '';
const response = {
text: getWebhookResponse(body, {responseType, username, iconUrl}),
username,
icon_url: iconUrl,
type: '',
response_type: responseType,
};
res.status(200).send(response);
}
function onFieldRefreshDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getFieldRefreshDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Field refresh dialog triggered via slash command!'});
}
function onMultistepDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getMultistepStep1Dialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Multistep dialog triggered via slash command!'});
}
function onFieldRefreshSource(req, res) {
const {body} = req;
const submission = body.submission || {};
const projectType = submission.project_type;
const projectName = submission.project_name || '';
res.setHeader('Content-Type', 'application/json');
// Return updated form based on project type selection
const elements = [
{
display_name: 'Project Name',
name: 'project_name',
type: 'text',
placeholder: 'Enter project name',
default: projectName,
optional: false,
},
{
display_name: 'Project Type',
name: 'project_type',
type: 'select',
refresh: true,
placeholder: 'Select project type...',
default: projectType,
options: [
{text: 'Web Application', value: 'web'},
{text: 'Mobile App', value: 'mobile'},
{text: 'API Service', value: 'api'},
],
},
];
// Add different fields based on project type
if (projectType === 'web') {
elements.push({
display_name: 'Framework',
name: 'framework',
type: 'select',
placeholder: 'Select framework...',
options: [
{text: 'React', value: 'react'},
{text: 'Vue', value: 'vue'},
{text: 'Angular', value: 'angular'},
],
});
} else if (projectType === 'mobile') {
elements.push({
display_name: 'Platform',
name: 'platform',
type: 'select',
placeholder: 'Select platform...',
options: [
{text: 'iOS', value: 'ios'},
{text: 'Android', value: 'android'},
{text: 'React Native', value: 'react-native'},
],
});
} else if (projectType === 'api') {
elements.push({
display_name: 'Language',
name: 'language',
type: 'select',
placeholder: 'Select language...',
options: [
{text: 'Go', value: 'go'},
{text: 'Node.js', value: 'nodejs'},
{text: 'Python', value: 'python'},
],
});
}
return res.json({
type: 'form',
form: {
title: 'Field Refresh Demo',
introduction_text: 'Enter project name then select type to see different fields',
submit_label: 'Submit',
elements,
},
});
}