Files
2bb8d01100 Mattermost Blocks (#36338)
* Mattermost Blocks

* Rethink some block props and styles

* Remove obsolete comment

* Style fixes and add examples

* Add mention, search, and image metadata support

* Several UI improvements

* Validate blocks by translating them

* Add initial e2e tests

* Fix lint

* Fix tests and lint

* Fix lint

* Improve e2e and simplify doPostAction

* server side cleanup

* Fix typo

* Fix style

* Fix some tests

* Fix more tests

* Fix cypress tests

* Fix playwhright tests

* Fix flaky system console test

* Frontend cleanup

* Add blocks editor

* Fix CI

* Limit all blocks width

* Fix test

* Enable webhook service on playwhright CI

* fix flaky test

* Fix style

* Cleanups

* Address coderabbit comments

* Address coderabbit comments

* Address CI errors

* Fix tests

* Address changes in valid actions

* UX fixes

* Fix test

* Fix danger buttons and add allow emojis in buttons

* style tweaks

use semantic color tokens for buttons and accents, update max-heights for scollables,  adjustments to containers, removed btn-sm (using default button size for mm blocks with height and padding adjustments for use within blocks. removed hover selector from utils to prevent hover state issue with primary button, refined semantic-color-warning.

* Address feedback

* Add interactive content to post previews

* Add collapse transitions

* Address autocomplete position

* Handle columns within column sets

* Fix css lint

* Fix tests

* Use feature flag

* Address feedback

* Fix lint and add missing tests

* Fix e2e tests

* Fix e2e test

* Allow interactive posts without a message

* Fix lint

* Address feedback

* Fix ci

* Fix lint

* Add limits to block translations

* Fix lint

* Address feedback

* Address feedback

* Update snapshot

* Fix collapsible not defaulting to the right value

* Add total text limit

* Sync translations with mobile

* Fix snapshot

---------

Co-authored-by: Matthew Birtch <[email protected]>
Co-authored-by: Mattermost Build <[email protected]>
2026-06-29 20:33:46 +02:00

318 lines
12 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {type AxiosResponse} from 'axios';
import type {ChainableT} from '../types';
/**
* postMessageAs is a task which is wrapped as command with post-verification
* that a message is successfully posted by the user/sender
* @param {Object} sender - a user object who will post a message
* @param {String} message - message in a post
* @param {Object} channelId - where a post will be posted
*/
export interface PostMessageResp {
id: string;
status: number;
// Post data from the API response
data: Record<string, any>;
}
interface PostMessageArg {
sender: {
username: string;
password: string;
};
message: string;
channelId: string;
rootId?: string;
createAt?: number;
}
interface PostMessageRequest {
token: string;
message: string;
props?: Record<string, unknown>;
channelId: string;
rootId?: string;
createAt?: number;
failOnStatus?: boolean;
}
function postMessageAs(arg: PostMessageArg): ChainableT<PostMessageResp> {
const {sender, message, channelId, rootId, createAt} = arg;
const baseUrl = Cypress.config('baseUrl');
// cy.task() returns untyped data
return cy.task('postMessageAs', {sender, message, channelId, rootId, createAt, baseUrl}).then((response: any) => {
const {status, data} = response;
expect(status).to.equal(201);
// # Return the data so it can be interacted in a test
return cy.wrap({id: data.id, status, data});
});
}
Cypress.Commands.add('postMessageAs', postMessageAs);
/**
* @param {string} [numberOfMessages = 30] - Number of messages
* @param {Object} sender - a user object who will post a message
* @param {String} message - message in a post
* @param {Object} channelId - where a post will be posted
*/
function postListOfMessages({numberOfMessages = 30, ...rest}): ChainableT<unknown> {
const baseUrl = Cypress.config('baseUrl');
// Cast needed: Cypress.Commands.add requires cast for overloaded commands
return (cy as any).
task('postListOfMessages', {numberOfMessages, baseUrl, ...rest}, {timeout: numberOfMessages * 200}).
each((message: {status: number}) => expect(message.status).to.equal(201));
}
Cypress.Commands.add('postListOfMessages', postListOfMessages);
/**
* reactToMessageAs is a task wrapped as command with post-verification
* that a reaction is added successfully to a message by a user/sender
* @param {Object} sender - a user object who will post a message
* @param {String} postId - post on which reaction is intended
* @param {String} reaction - emoji text eg. smile
*/
Cypress.Commands.add('reactToMessageAs', ({sender, postId, reaction}) => {
const baseUrl = Cypress.config('baseUrl');
// cy.task() returns untyped data
return cy.task('reactToMessageAs', {sender, postId, reaction, baseUrl}).then(({status, data}: any) => {
expect(status).to.equal(200);
// # Return the response after reaction is added
return cy.wrap({status, data});
});
});
/**
* postIncomingWebhook is a task which is wrapped as command with post-verification
* that the incoming webhook is successfully posted
* @param {String} url - incoming webhook URL
* @param {Object} data - payload on incoming webhook
*/
interface WebhookData {
text?: string;
attachments?: Array<Record<string, unknown> & {pretext?: string}>;
[key: string]: unknown;
}
function postIncomingWebhook({url, data, waitFor}: {
url: string;
data: WebhookData;
waitFor?: string;
}): ChainableT {
cy.task('postIncomingWebhook', {url, data}).its('status').should('be.equal', 200);
if (!waitFor) {
return cy.wrap(undefined) as ChainableT;
}
return cy.waitUntil(() => cy.getLastPost().then((el) => {
switch (waitFor) {
case 'text': {
const textEl = el.find('.post-message__text > p')[0];
return Boolean(textEl && data.text && textEl.textContent.includes(data.text));
}
case 'attachment-pretext': {
const pretext = data.attachments?.[0]?.pretext;
if (!pretext) {
return false;
}
const mmBlocksText = el.find('.mm-blocks-text');
if (mmBlocksText.length) {
return Array.from(mmBlocksText).some((node) => node.textContent?.includes(pretext));
}
const attachmentPretextEl = el.find('.attachment__thumb-pretext > p')[0];
return Boolean(attachmentPretextEl && attachmentPretextEl.textContent.includes(pretext));
}
default:
return false;
}
}));
}
Cypress.Commands.add('postIncomingWebhook', postIncomingWebhook);
interface ExternalRequestArg<T> {
user: Record<string, unknown>;
method: string;
path: string;
data?: T;
failOnStatusCode?: boolean;
}
function externalRequest<T=unknown, U=unknown>(arg: ExternalRequestArg<U>): ChainableT<Pick<AxiosResponse<T>, 'data' | 'status'>> {
const {user, method, path, data, failOnStatusCode = true} = arg;
const baseUrl = Cypress.config('baseUrl');
// cy.task() returns untyped data
return cy.task('externalRequest', {baseUrl, user, method, path, data}).then((response: any) => {
// Temporarily ignore error related to Cloud
const cloudErrorId = [
'ent.cloud.request_error',
'api.cloud.get_subscription.error',
];
if (response.data && !cloudErrorId.includes(response.data.id) && failOnStatusCode) {
expect(response.status).to.be.oneOf([200, 201, 204]);
}
return cy.wrap(response as Pick<AxiosResponse<T>, 'data' | 'status'>);
}) as ChainableT<Pick<AxiosResponse<T>, 'data' | 'status'>>;
}
// Cypress.Commands.add requires cast for overloaded commands
Cypress.Commands.add('externalRequest', externalRequest as any);
/**
* postMessageAs is a task which is wrapped as command with post-verification
* that a message is successfully posted by the bot
* @param {String} message - message in a post
* @param {Object} channelId - where a post will be posted
*/
function postBotMessage({token, message, props, channelId, rootId, createAt, failOnStatus = true}: PostMessageRequest): ChainableT<PostMessageResp> {
const baseUrl = Cypress.config('baseUrl');
// cy.task() returns untyped data
return cy.task('postBotMessage', {token, message, props, channelId, rootId, createAt, baseUrl}).then(({status, data}: any) => {
if (failOnStatus) {
expect(status).to.equal(201);
}
// # Return the data so it can be interacted in a test
return cy.wrap({id: data.id, status, data});
});
}
Cypress.Commands.add('postBotMessage', postBotMessage);
/**
* urlHealthCheck is a task wrapped as command that checks whether
* a URL is healthy and reachable.
* @param {String} name - name of service to check
* @param {String} url - URL to check
* @param {String} helperMessage - a message to display on error to help resolve the issue
* @param {String} method - a request using a specific method
* @param {String} httpStatus - expected HTTP status
*/
function urlHealthCheck({name, url, helperMessage, method, httpStatus}: {name: string; url: string; helperMessage: string; method: string; httpStatus: number}): ChainableT {
Cypress.log({name, message: `Checking URL health at ${url}`});
// cy.task() returns untyped data
return cy.task('urlHealthCheck', {url, method}).then(({data, errorCode, status, success}: any) => {
const urlService = `__${name}__ at ${url}`;
const successMessage = success ? `${urlService}: reachable` : `${errorCode}: The test you're running requires ${urlService} to be reachable. \n${helperMessage}`;
expect(success, successMessage).to.equal(true);
const statusMessage = status === httpStatus ? `${urlService}: responded with ${status} HTTP status` : `${urlService}: expected to respond with ${httpStatus} but got ${status} HTTP status`;
expect(status, statusMessage).to.equal(httpStatus);
return cy.wrap({data, status});
});
}
Cypress.Commands.add('urlHealthCheck', urlHealthCheck);
Cypress.Commands.add('requireWebhookServer', () => {
const baseUrl = Cypress.config('baseUrl');
const webhookBaseUrl = Cypress.expose('webhookBaseUrl');
const adminUsername = Cypress.expose('adminUsername');
const adminPassword = Cypress.expose('adminPassword');
const helperMessage = `
__Tips:__
1. In local development, you may run "__npm run start:webhook__" at "/e2e" folder.
2. If reachable from remote host, you may export it as env variable, like "__CYPRESS_webhookBaseUrl=[url] npm run cypress:open__".
`;
cy.urlHealthCheck({
name: 'Webhook Server',
url: webhookBaseUrl,
helperMessage,
method: 'get',
httpStatus: 200,
});
cy.task('postIncomingWebhook', {
url: `${webhookBaseUrl}/setup`,
data: {
baseUrl,
webhookBaseUrl,
adminUsername,
adminPassword,
}}).
its('status').should('be.equal', 201);
});
Cypress.Commands.overwrite('log', (subject, message) => cy.task('log', message));
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* externalRequest is a task which is wrapped as command with post-verification
* that the external request is successfully completed
* @param {Object} options
* @param {<UserProfile, 'username' | 'password'>} options.user - a user initiating external request
* @param {String} options.method - an HTTP method (e.g. get, post, etc)
* @param {String} options.path - API path that is relative to Cypress.config().baseUrl
* @param {Object} options.data - payload
* @param {Boolean} options.failOnStatusCode - whether to fail on status code, default is true
*
* @example
* cy.externalRequest({user: sysadmin, method: 'POST', path: 'config', data});
*/
externalRequest(options?: {
user: Pick<UserProfile, 'username' | 'password'>;
method: string;
path: string;
data?: Record<string, unknown> | unknown[];
failOnStatusCode?: boolean;
}): Chainable<Pick<AxiosResponse, 'data' | 'status'>>;
/**
* Adds a given reaction to a specific post from a user
* @param {Object} reactToMessageObject - Information on person and post to which a reaction needs to be added
* @param {Object} reactToMessageObject.sender - a user object who will post a message
* @param {string} reactToMessageObject.postId - post on which reaction is intended
* @param {string} reactToMessageObject.reaction - emoji text eg. smile
* @returns {Response} response: Cypress-chainable response
*
* @example
* cy.reactToMessageAs({sender:user2, postId:"ABC123", reaction: 'smile'});
*/
reactToMessageAs({sender, postId, reaction}: {sender: Record<string, unknown>; postId: string; reaction: string}): Chainable<{status: number; data: unknown}>;
/**
* Verify that the webhook server is accessible, and then sets up base URLs and credential.
*
* @example
* cy.requireWebhookServer();
*/
requireWebhookServer(): Chainable;
postMessageAs: typeof postMessageAs;
postListOfMessages: typeof postListOfMessages;
postIncomingWebhook: typeof postIncomingWebhook;
postBotMessage: typeof postBotMessage;
urlHealthCheck: typeof urlHealthCheck;
}
}
}