snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
# CLAUDE: `webapp/platform/`
## Purpose
- Shared packages consumed by every Mattermost web experience (Channels, Boards, Playbooks, plugins).
- Changes here affect multiple products—coordinate across teams before merging.
## Packages
| Package | Directory | Purpose |
|---------|-----------|---------|
| `@mattermost/types` | `types/` | TypeScript type definitions |
| `@mattermost/client` | `client/` | REST and WebSocket API client |
| `@mattermost/components` | `components/` | Shared React components |
| `@mattermost/shared` | `shared/` | Cross-product components and utilities for the web app and plugins (e.g. `Button`, `WithTooltip`). Prefer these over rolling your own |
| `@mattermost/eslint-plugin` | `eslint-plugin/` | Custom ESLint rules |
## Workspace Basics
- Each subpackage is its own npm workspace with independent `package.json`, tests, and build scripts.
- Run commands with `npm run <script> --workspace=@mattermost/<pkg>` (e.g., `@mattermost/client`).
- Versioning follows the monorepo; publishable artifacts come from CI pipelines.
## Import Convention
Always import using the full package name:
```typescript
// CORRECT
import {Client4} from '@mattermost/client';
// INCORRECT - never use relative paths
import Client4 from '../platform/client/src/client4';
```
## Build Relationship
Platform packages are automatically built on `npm install` via postinstall hook. Build order: `types` → `client`/`components`.
**Note**: When developing in `channels`, changes in `platform` packages may need a rebuild if not using a watcher that supports them.
## Adding Dependencies
When adding dependencies to platform packages:
```bash
npm add package-name --workspace=@mattermost/client
```
## Expectations
- Follow `webapp/STYLE_GUIDE.md`.
- Maintain 100% TypeScript coverage; no `any`.
- Update downstream consumers when making breaking changes.

165
webapp/platform/README.md Normal file
View File

@@ -0,0 +1,165 @@
This folder contains a number of packages intended to be built and shipped separately on NPM as well as a few legacy packages for internal use only (`reselect` and `mattermost-redux`). The following documentation only applies to the newer packages and not to the legacy ones.
### Importing a subpackage
Subpackages should be imported using their full name, both inside the web app and when installing them using `npm`. They should not be imported using a relative path, and the `src` folder shouldn't be necessary to include.
```javascript
// Correct
import {Client4} from '@mattermost/client';
import {UserProfile} from '@mattermost/types/users';
// Incorrect
import Client4 from 'packages/client/src/client4.ts';
import {UserProfile} from '../../types/src/users';
```
Some tools have difficulty doing this on their own, but they often support import path aliases so that we can keep them consistent acrosss the code base. More details on how to do this will be provided in packages where this is necessary such as `types`.
#### Importing one subpackage into another
When building packages that depend on each other, be careful to:
1. Avoid import loops. While JavaScript lets us get away with these in most cases within a project, we cannot have two packages that depend directly with each other.
1. Not compile one subpackage into another. We don't want the published libraries to include code from one subpackage into another. They should be set up so that they're peer dependencies in the `package.json`, and if a project wants to use multiple packages, they can install them each separately.
As above, some tooling may need additional configuration to have one subpackage use code from another. For example, in packages compiled with the TypeScript compiler (tsc), you'll need to have the `tsconfig.json` from the dependent pacakge reference its dependency using the `references` field.
### Versioning subpackages
At this time, we'll have the version of each package match the version of the web app. Versions can be incremented for each affected package by using [`npm version`](https://docs.npmjs.com/cli/v6/commands/npm-version), and then `npm install` should be run to propagate those changes into the shared `package-lock.json`.
```sh
# Set a version of a single package
npm version 6.7.8 --workspace=packages/apple
# Increment the version of each package to the next minor version
npm version minor --workspaces
## Increment the version of a package to a pre-release version of the next minor version
npm version preminor --workspace=packages/apple
```
When a subpackage imports another, it should be set to depend on the `*` version of the other subpackage.
### Adding a new subpackage
To set up a new package:
1. Add a `package.json` and `README.md` for that package.
1. Ensure all source files are located in `src` and all compiled files are built to `lib`.
1. Add an entry to the `workspaces` section of the root `package.json` so that NPM is aware of your package.
1. Set up import aliases so that the package is visible from the web app to the following tools:
1. **TypeScript** - In the root `tsconfig.json`, add an entry to the `compilerOptions.paths` section pointing to the `src` folder and an entry to the `references` section pointing to the root of your package which should contain its own `tsconfig.json`.
Note that the `compilerOptions.paths` entry will differ based on if your package exports just a single module (ie a single `index.js` file) or if it exports multiple submodules.
```json5
{
"compilerOptions": {
"paths": {
"@mattermost/apple": ["packages/apple/lib"], // import * as Apple from '@mattermost/apple';
"@mattermost/banana/*": ["packages/banana/lib/*"], // import Yellow from '@mattermost/banana/yellow';
}
},
"references": [
{"path": "./packages/apple"},
{"path": "./packages/banana"},
]
}
```
1. **Jest** - Add an entry to the `jest.moduleNameMapper` section of the root `jest.config.js` for your package. Since that setting supports regexes, you can add these to the existing patterns used by the `client` and `types` packages.
Similar to TypeScript, this will differ based on if the package exports a single module or multiple modules.
```json
{
"jest": {
"moduleNameMapper": {
"^@mattermost/(apple|client)$": "<rootDir>/packages/$1/src",
"^@mattermost/(banana|types)/(.*)$": "<rootDir>/packages/$1/src/$2",
}
}
}
```
1. Add the compiled code to the CircleCI dependency cache. This is done by modifying the `paths` used by the `save_cache` step in `.circleci/config.yml`
```yml
aliases:
- &save_cache
save_cache:
paths:
- ~/mattermost/mattermost-webapp/packages/apple/lib
- ~/mattermost/mattermost-webapp/packages/banana/lib
```
### Publishing a subpackage
The following is the rough process for releasing these packages. They'll require someone with write access on our NPM organization to run them, and they'll likely change over time as we improve this process.
For full releases accompanying new versions of Mattermost:
1. Clean the repo.
```sh
make clean
```
1. Update the version of the desired packages to match the server/web app as described above.
1. Download an up to date copy of the dependencies and update package-lock.json.
```sh
make node_modules
```
1. Check in the changes to the package-lock.json.
1. Build the desired packages.
```sh
npm run build --workspace=packages/apple --workspace=packages/banana
```
1. Test everything in the web app. This will be needed until the packages get their own standalone tests.
```sh
make check-style check-types test
```
1. Assuming those pass, you can now publish those packages to npm. You can also do a dry run first or use `npm pack` to see exactly which files will be pushed.
```sh
# Run a dry run which will list all the files to be included in the published package.
npm publish --dry-run --workspace=packages/apple
# Generate the tar file that will be uploaded to NPM for inspection.
npm pack --workspace=packages/apple
# Actually publish these packages. You can also use --workspaces to publish everything.
npm publish --access=public --workspace=packages/apple --workspace=packages/banana
```
The packages have now been published! There's still a few remaining cleanup tasks to do though.
1. Tag the commit for each package that has been updated. The tag name should be of the form `@mattermost/package-name@x.y.z`.
1. Push that commit and the corresponding tags up to GitHub
```sh
git push release-x.y
git push origin @mattermost/apple@x.y.z @mattermost/banana@x.y.z
```
#### Publishing a pre-release version
Similarly, you can publish a pre-release version of the package. This can be done either to use changes from master while developing another product/plugin or to generate a release candidate.
This process is the same as above, except the version will have a suffix like `-1`, `-2`, etc. As explained above, this can be automatically done by using `npm version preminor` for minor releases, `npm version premajor` for major releases, and `npm version prerelease` for patch releases. These versions won't be automatically installed when people add them using `npm add` without a version, but they can be installed by specifying the version number manually.
### Caveats
1. Currently, all packages are treated by CI as if they're part of the web app. This means that, for example, their style checking and tests are ran as part of the web app. In turn, that means that regardless of what tooling we use to build each package, they'll be compiled into the web app using webpack directly from source, and that it's possible for them to behave slightly differently in development compared to after release.
Eventually, we hope to get these building in parallel (so instead of having webpack watch the whole repo for changes during development, we'll have multiple watchers for the web app and each package) which should solve this issue, but that requires much larger changes that we're not ready to do yet.
1. For packages that export multiple submodules (such as `types`), we've chosen to expose these using Node's [subpath exports](https://nodejs.org/api/packages.html#subpath-exports) feature. Some tools like Webpack support this natively, but others like TypeScript and Jest don't support it yet. We've provided steps on how to support this in the `README.md` for the `types` package, but this may vary depending on the project's setup.

View File

@@ -0,0 +1,49 @@
# CLAUDE: `platform/client/` (`@mattermost/client`)
## Purpose
- Implements the Client4 HTTP layer and WebSocket client used by all Mattermost web apps and plugins.
- Source of truth for API endpoint definitions and low-level networking helpers.
## Structure
- `src/client4.ts` – REST endpoints, auth handling, retries.
- `src/websocket.ts` – WebSocket manager for real-time events.
- `src/helpers.ts` / `errors.ts` – shared logic for response parsing and error types.
## Client4 Usage
Singleton HTTP client for all Mattermost API requests. Methods return `Promise<ClientResponse<T>>`:
```typescript
interface ClientResponse<T> {
response: Response; // Fetch Response object
headers: Headers; // Response headers
data: T; // Parsed response data
}
```
### Usage Rules
1. **Actions Only**: `Client4` should only be called from Redux actions, never directly in components.
2. **Error Handling**: Always handle errors appropriately.
## Adding New Endpoints
Add new API methods to `client4.ts`. Keep signatures Promise-based.
```typescript
getSomething = (id: string) => {
return this.doFetch<SomethingType>(
`${this.getSomethingRoute(id)}`,
{method: 'get'},
);
};
```
## Error Handling
- Throw `ClientError` (see `errors.ts`) with enough context.
- Include `forceLogoutIfNecessary` logic upstream in calling actions; do not couple that here.
## WebSocket Client
`websocket.ts` provides real-time event handling, connection management, and automatic reconnection. Accessed via `WebSocketClient` wrapper in web app.
## Guidelines
- Follow `webapp/STYLE_GUIDE.md → Networking`.
- Each new server API must be added here first, including TypeScript types and tests.
- Never reference React or browser globals—this package must run in Node.

View File

@@ -0,0 +1,122 @@
# Mattermost Client
[![npm version](https://img.shields.io/npm/v/@mattermost/client?style=flat)](https://www.npmjs.com/package/@mattermost/client)
This package contains the JavaScript/TypeScript client for [Mattermost](https://github.com/mattermost/mattermost). It's used by [the Mattermost web app](https://github.com/mattermost/mattermost/tree/master/webapp/channels) and related projects.
## Installation
### JavaScript
```sh
$ npm install @mattermost/client
```
### TypeScript
```sh
$ npm install @mattermost/client @mattermost/types
```
## Usage
### Rest Client
To use this client, create an instance of `Client4`, set the server URL, and log in, and then you can start making requests.
```js
import {Client4} from '@mattermost/client';
const client = new Client4();
client.setUrl('https://mymattermostserver.example.com');
client.login('username', 'password').then((user) => {
// ...
});
```
If you already have a session token or a user access token, you can call `Client4.setToken` instead of logging in.
```js
import {Client4} from '@mattermost/client';
const client = new Client4();
client.setUrl('https://mymattermostserver.example.com');
client.setToken('accesstoken');
```
If needed, methods exist to set other headers such as the User-Agent (`Client4.setUserAgent`), the CSRF token (`Client4.setCSRF`), or any extra headers you wish to include (`Client4.setHeader`).
Methods of `Client4` which make requests to the server return a `Promise` which does the following:
- On success, the promise resolves to a `ClientResponse<T>` object which contains the the [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) (`response`), a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) of headers (`headers`), and the data sent from the server (`data`).
- On an error, the promise rejects with a `ClientError` which contains the error message and the URL being requested. If the error happened on the server, the status code and an error ID (`server_error_id`) are included.
```js
let user;
try {
user = (await client.getUser('userid')).data;
} catch (e) {
console.error(`An error occurred when making a request to ${e.url}: ${e.message}`);
}
```
### WebSocket Client
To use the WebSocket client, create an instance of `WebSocketClient` and then call its `initialize` method with the connection URL and an optional session token or user access token. After that, you can call the client's `addMessageListener` method to register a listener which will be called whenever a WebSocket message is received from the server.
```js
import {WebSocketClient} from '@mattermost/client';
// If you already have an instance of Client4, you can call its getWebSocketUrl method to get this URL
const connectionUrl = 'https://mymattermostserver.example.com/api/v4/websocket';
// In a browser, the token may be passed automatically from a cookie
const authToken = process.env.TOKEN;
const wsClient = new WebSocketClient();
wsClient.initialize(connectionUrl, authToken);
wsClient.addMessageListener((msg) => {
if (msg.event === 'posted') {
console.log('New post received', JSON.parse(msg.data.post));
}
});
```
#### Node.js
Note that `WebSocketClient` expects `globalThis.WebSocket` to be defined as it was originally written for use in the Mattermost web app. If you're using it in a Node.js environment, you should set `globalThis.WebSocket` before instantiating the `WebSocketClient`.
```js
import WebSocket from 'ws';
if (!globalThis.WebSocket) {
globalThis.WebSocket = WebSocket;
}
const wsClient = new WebSocketClient();
```
This can also be done using dynamic imports if you're using them.
```js
if (!globalThis.WebSocket) {
const {WebSocket} = await import('ws');
globalThis.WebSocket = WebSocket;
}
const wsClient = new WebSocketClient();
```
## Compilation and Packaging
As a member of Mattermost with write access to our NPM organization, you can build and publish this package by running the following commands:
```bash
npm run build --workspace=platform/client
npm publish --workspace=platform/client
```
Make sure to increment the version number in `package.json` first! You can add `-0`, `-1`, etc for pre-release versions.

View File

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import eslintPlugin from '@mattermost/eslint-plugin';
export default [
...eslintPlugin.configs.base,
{
files: ['**/*.js', '**/*.mjs', '**/*.ts'],
rules: {
'no-restricted-globals': [
'error',
'window',
],
},
},
{
files: ['**/*.test.js', '**/*.test.ts'],
rules: {
'no-restricted-globals': 'off',
},
},
];

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/** @type {import('jest').Config} */
module.exports = {
moduleNameMapper: {
'^@mattermost/types/(.*)$': '<rootDir>/../types/src/$1',
},
testPathIgnorePatterns: ['/node_modules/', '/lib/'],
setupFiles: ['<rootDir>/setup_jest.ts'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
],
coveragePathIgnorePatterns: [
'/node_modules/',
],
coverageReporters: ['json', 'lcov', 'text-summary'],
};

View File

@@ -0,0 +1,47 @@
{
"name": "@mattermost/client",
"version": "12.0.0",
"description": "JavaScript/TypeScript client for Mattermost",
"keywords": [
"mattermost"
],
"homepage": "https://github.com/mattermost/mattermost/tree/master/webapp/platform/client#readme",
"license": "MIT",
"type": "commonjs",
"files": [
"lib"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/mattermost/mattermost.git",
"directory": "webapp/platform/client"
},
"devDependencies": {
"@types/jest": "30.0.0",
"@types/node-fetch": "2.6.13",
"jest": "30.1.3",
"nock": "13.2.8",
"node-fetch": "2.7.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@mattermost/types": "12.0.0",
"typescript": "^4.3.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"scripts": {
"build": "tsc --build tsconfig.build.json --verbose",
"check": "eslint ./src eslint.config.mjs --quiet",
"fix": "eslint ./src eslint.config.mjs --quiet --fix",
"run": "tsc --watch --preserveWatchOutput",
"test": "jest",
"test-ci": "jest --ci --forceExit --detectOpenHandles --maxWorkers=100% --logHeapUsage",
"clean": "rm -rf lib node_modules *.tsbuildinfo"
}
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import nodeFetch from 'node-fetch';
// Nock 13 can't mock Node's built-in fetch, so we have to replace the it with node-fetch to be able to mock it despite
// some mismatched parameters.
globalThis.fetch = nodeFetch as unknown as typeof fetch;

View File

@@ -0,0 +1,484 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import nock from 'nock';
import Client4, {ClientError, HEADER_X_VERSION_ID} from './client4';
import {buildQueryString} from './helpers';
describe('Client4', () => {
beforeAll(() => {
if (!nock.isActive()) {
nock.activate();
}
});
afterAll(() => {
nock.restore();
});
describe('property field routes', () => {
let client: Client4;
beforeEach(() => {
client = new Client4();
client.setUrl('http://mattermost.example.com');
});
test('getPropertyFieldsRoute should build correct URL', () => {
expect(client.getPropertyFieldsRoute('my_group', 'user')).toBe(
'http://mattermost.example.com/api/v4/properties/groups/my_group/user/fields',
);
});
test('getPropertyFieldRoute should build correct URL', () => {
expect(client.getPropertyFieldRoute('my_group', 'user', 'field123')).toBe(
'http://mattermost.example.com/api/v4/properties/groups/my_group/user/fields/field123',
);
});
test('getPropertyFields should send GET with correct query params', async () => {
const fields = [{id: 'f1', name: 'test'}];
nock(client.getBaseRoute()).
get('/properties/groups/grp/user/fields').
query({target_type: 'system', per_page: '10', cursor_id: 'abc', cursor_create_at: '999'}).
reply(200, fields);
const result = await client.getPropertyFields('grp', 'user', 'system', undefined, {
perPage: 10,
cursorId: 'abc',
cursorCreateAt: 999,
});
expect(result).toEqual(fields);
});
test('getPropertyFields should include target_id when provided', async () => {
nock(client.getBaseRoute()).
get('/properties/groups/grp/user/fields').
query({target_type: 'channel', target_id: 'ch1'}).
reply(200, []);
const result = await client.getPropertyFields('grp', 'user', 'channel', 'ch1');
expect(result).toEqual([]);
});
test('getPropertyFields should send minimal params when no options', async () => {
nock(client.getBaseRoute()).
get('/properties/groups/grp/user/fields').
query({target_type: 'system'}).
reply(200, []);
const result = await client.getPropertyFields('grp', 'user', 'system');
expect(result).toEqual([]);
});
test('createPropertyField should send POST with field body', async () => {
const field = {name: 'classification', type: 'select' as const, target_type: 'system'};
const created = {id: 'new1', ...field};
nock(client.getBaseRoute()).
post('/properties/groups/grp/user/fields', (body) => {
return body.name === 'classification' && body.type === 'select';
}).
reply(201, created);
const result = await client.createPropertyField('grp', 'user', field);
expect(result).toEqual(created);
});
test('patchPropertyField should send PATCH to field URL', async () => {
const patch = {attrs: {options: []}};
const patched = {id: 'f1', name: 'classification', attrs: {options: []}};
nock(client.getBaseRoute()).
patch('/properties/groups/grp/user/fields/f1', (body) => {
return body.attrs !== undefined;
}).
reply(200, patched);
const result = await client.patchPropertyField('grp', 'user', 'f1', patch);
expect(result).toEqual(patched);
});
test('deletePropertyField should send DELETE to field URL', async () => {
nock(client.getBaseRoute()).
delete('/properties/groups/grp/user/fields/f1').
reply(200, {status: 'OK'});
const result = await client.deletePropertyField('grp', 'user', 'f1');
expect(result).toEqual({status: 'OK'});
});
});
describe('access control field autocomplete', () => {
let client: Client4;
beforeEach(() => {
client = new Client4();
client.setUrl('http://mattermost.example.com');
});
test('getAccessControlFields sends include_resource_fields when requested', async () => {
const fields = [{id: 'f1', name: 'classification'}];
nock(client.getBaseRoute()).
get('/access_control_policies/cel/autocomplete/fields').
query({after: '', limit: '100', include_resource_fields: 'true'}).
reply(200, fields);
const result = await client.getAccessControlFields('', 100, undefined, undefined, true);
expect(result).toEqual(fields);
});
test('getAccessControlFields omits include_resource_fields by default', async () => {
nock(client.getBaseRoute()).
get('/access_control_policies/cel/autocomplete/fields').
query((q) => q.include_resource_fields === undefined && q.after === '' && q.limit === '100').
reply(200, []);
const result = await client.getAccessControlFields('', 100);
expect(result).toEqual([]);
});
});
describe('content flagging routes', () => {
let client: Client4;
beforeEach(() => {
client = new Client4();
client.setUrl('http://mattermost.example.com');
});
test('flagPost should send comment as a plain string', async () => {
let receivedBody: any;
nock(client.getBaseRoute()).
post('/content_flagging/post/post123/flag', (body) => {
receivedBody = body;
return true;
}).
reply(200, {status: 'OK'});
await client.flagPost('post123', 'Spam', 'looks suspicious');
expect(receivedBody).toEqual({reason: 'Spam', comment: 'looks suspicious'});
});
test('flagPost should preserve an empty comment as an empty string', async () => {
let receivedBody: any;
nock(client.getBaseRoute()).
post('/content_flagging/post/post123/flag', (body) => {
receivedBody = body;
return true;
}).
reply(200, {status: 'OK'});
await client.flagPost('post123', 'Spam', '');
expect(receivedBody).toEqual({reason: 'Spam', comment: ''});
});
test('removeFlaggedPost should send comment as a plain string', async () => {
let receivedBody: any;
nock(client.getBaseRoute()).
put('/content_flagging/post/post123/remove', (body) => {
receivedBody = body;
return true;
}).
reply(200, {status: 'OK'});
await client.removeFlaggedPost('post123', 'violates policy');
expect(receivedBody).toEqual({comment: 'violates policy'});
});
test('removeFlaggedPost should preserve an empty comment as an empty string', async () => {
let receivedBody: any;
nock(client.getBaseRoute()).
put('/content_flagging/post/post123/remove', (body) => {
receivedBody = body;
return true;
}).
reply(200, {status: 'OK'});
await client.removeFlaggedPost('post123', '');
expect(receivedBody).toEqual({comment: ''});
});
test('keepFlaggedPost should send comment as a plain string', async () => {
let receivedBody: any;
nock(client.getBaseRoute()).
put('/content_flagging/post/post123/keep', (body) => {
receivedBody = body;
return true;
}).
reply(200, {status: 'OK'});
await client.keepFlaggedPost('post123', 'looks fine');
expect(receivedBody).toEqual({comment: 'looks fine'});
});
test('keepFlaggedPost should preserve an empty comment as an empty string', async () => {
let receivedBody: any;
nock(client.getBaseRoute()).
put('/content_flagging/post/post123/keep', (body) => {
receivedBody = body;
return true;
}).
reply(200, {status: 'OK'});
await client.keepFlaggedPost('post123', '');
expect(receivedBody).toEqual({comment: ''});
});
test('getPostExposureReportUrl should build correct URL', () => {
expect(client.getPostExposureReportUrl('post123')).toBe(
'http://mattermost.example.com/api/v4/content_flagging/post/post123/exposure_report',
);
});
test('generatePostExposureReport should return the CSV blob and the filename from Content-Disposition', async () => {
nock(client.getBaseRoute()).
post('/content_flagging/post/post123/exposure_report').
reply(200, '#Post ID,post123\nUser ID,Username\nuid1,alice\n', {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="post-exposure-post123-1700000000000.csv"',
});
const result = await client.generatePostExposureReport('post123');
expect(result.filename).toEqual('post-exposure-post123-1700000000000.csv');
expect(typeof result.blob.text).toBe('function');
expect(await result.blob.text()).toContain('uid1,alice');
});
test('generatePostExposureReport should fall back to a generated filename when the header is absent', async () => {
nock(client.getBaseRoute()).
post('/content_flagging/post/post123/exposure_report').
reply(200, 'User ID,Username\n', {'Content-Type': 'text/csv; charset=utf-8'});
const result = await client.generatePostExposureReport('post123');
expect(result.filename).toMatch(/^post-exposure-post123-\d+\.csv$/);
});
test('generatePostExposureReport should surface server errors as ClientError', async () => {
nock(client.getBaseRoute()).
post('/content_flagging/post/post123/exposure_report').
reply(400, {
id: 'api.data_spillage.error.post_not_in_progress',
message: 'The review of this post is already closed.',
status_code: 400,
}, {'Content-Type': 'application/json'});
await expect(client.generatePostExposureReport('post123')).rejects.toMatchObject({
server_error_id: 'api.data_spillage.error.post_not_in_progress',
status_code: 400,
});
});
});
describe('team access control routes', () => {
let client: Client4;
beforeEach(() => {
client = new Client4();
client.setUrl('http://mattermost.example.com');
});
test('assignTeamsToAccessControlPolicy POSTs team_ids to the assign route', async () => {
let receivedBody: any;
nock(client.getBaseRoute()).
post('/access_control_policies/pol1/assign', (body) => {
receivedBody = body;
return true;
}).
reply(200, {status: 'OK'});
const result = await client.assignTeamsToAccessControlPolicy('pol1', ['team1', 'team2']);
expect(receivedBody).toEqual({team_ids: ['team1', 'team2']});
expect(result).toEqual({status: 'OK'});
});
test('unassignTeamsFromAccessControlPolicy DELETEs team_ids to the unassign route', async () => {
let receivedBody: any;
nock(client.getBaseRoute()).
delete('/access_control_policies/pol1/unassign', (body) => {
receivedBody = body;
return true;
}).
reply(200, {status: 'OK'});
const result = await client.unassignTeamsFromAccessControlPolicy('pol1', ['team1']);
expect(receivedBody).toEqual({team_ids: ['team1']});
expect(result).toEqual({status: 'OK'});
});
test('getTeamAccessControlPolicy GETs the per-team policy route', async () => {
const response = {policy: null, enforced: false};
nock(client.getBaseRoute()).
get('/teams/team1/access_control/policy').
reply(200, response);
const result = await client.getTeamAccessControlPolicy('team1');
expect(result).toEqual(response);
});
test('getProfilesMatchingTeamPolicy GETs users with not_in_team + abac_match_only', async () => {
const profiles = [{id: 'u1'}];
nock(client.getBaseRoute()).
get('/users').
query({not_in_team: 'team1', per_page: '60', abac_match_only: 'true'}).
reply(200, profiles);
const result = await client.getProfilesMatchingTeamPolicy('team1');
expect(result).toEqual(profiles);
});
test('getProfilesMatchingTeamPolicy includes cursor_id when provided', async () => {
const profiles: any[] = [];
nock(client.getBaseRoute()).
get('/users').
query({not_in_team: 'team1', per_page: '60', abac_match_only: 'true', cursor_id: 'u9'}).
reply(200, profiles);
const result = await client.getProfilesMatchingTeamPolicy('team1', 60, 'u9');
expect(result).toEqual(profiles);
});
test('getTeams sends for_directory=true so admins are filtered on the directory listing', async () => {
const teams = [{id: 't1'}];
nock(client.getBaseRoute()).
get('/teams').
query({page: '0', per_page: '60', include_total_count: 'true', exclude_policy_constrained: 'false', for_directory: 'true'}).
reply(200, teams);
const result = await client.getTeams(0, 60, true, false, true);
expect(result).toEqual(teams);
});
test('getTeams defaults for_directory to false so the management listing stays complete', async () => {
const teams = [{id: 't1'}];
nock(client.getBaseRoute()).
get('/teams').
query({page: '0', per_page: '60', include_total_count: 'false', exclude_policy_constrained: 'false', for_directory: 'false'}).
reply(200, teams);
const result = await client.getTeams(0, 60);
expect(result).toEqual(teams);
});
});
describe('doFetchWithResponse', () => {
test('serverVersion should be set from response header', async () => {
const client = new Client4();
client.setUrl('http://mattermost.example.com');
expect(client.serverVersion).toEqual('');
nock(client.getBaseRoute()).
get('/users/me').
reply(200, '{}', {[HEADER_X_VERSION_ID]: '5.0.0.5.0.0.abc123'});
await client.getMe();
expect(client.serverVersion).toEqual('5.0.0.5.0.0.abc123');
nock(client.getBaseRoute()).
get('/users/me').
reply(200, '{}', {[HEADER_X_VERSION_ID]: '5.3.0.5.3.0.abc123'});
await client.getMe();
expect(client.serverVersion).toEqual('5.3.0.5.3.0.abc123');
});
test('should parse NDJSON responses correctly', async () => {
const client = new Client4();
client.setUrl('http://mattermost.example.com');
const userId = 'dummy-user-id';
const page = -1; // Special value to trigger NDJSON response
// Sample NDJSON data with multiple channel memberships on separate lines
const ndjsonData = '{"user_id":"dummy-user-id","channel_id":"channel1","roles":"channel_user"}\n' +
'{"user_id":"dummy-user-id","channel_id":"channel2","roles":"channel_user channel_admin"}\n' +
'{"user_id":"dummy-user-id","channel_id":"channel3","roles":"channel_user"}';
// Create a mock endpoint for getAllChannelsMembers that returns NDJSON data
nock(client.getBaseRoute()).
get(`/users/${userId}/channel_members${buildQueryString({page, per_page: 60})}`).
reply(200, ndjsonData, {'Content-Type': 'application/x-ndjson'});
// Call the getAllChannelsMembers method which will use our implementation for NDJSON
const result = await client.getAllChannelsMembers(userId, page);
// Verify the response was parsed as an array of objects
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(3);
expect(result[0]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel1', roles: 'channel_user'});
expect(result[1]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel2', roles: 'channel_user channel_admin'});
expect(result[2]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel3', roles: 'channel_user'});
});
test('should parse ZIP responses as blobs', async () => {
const client = new Client4();
client.setUrl('http://mattermost.example.com');
const postId = 'dummy-post-id';
const zipData = Buffer.from('zip contents');
nock(client.getBaseRoute()).
post(`/content_flagging/post/${postId}/report`, {comment: 'investigation note'}).
reply(200, zipData, {'Content-Type': 'application/zip'});
const result = await client.generateFlaggedPostReport(postId, 'investigation note');
expect(typeof result.text).toBe('function');
expect(result.size).toEqual(zipData.length);
expect(result.type).toEqual('application/zip');
expect(await result.text()).toEqual('zip contents');
});
});
});
describe('ClientError', () => {
test('standard fields should be enumerable', () => {
const error = new ClientError('https://example.com', {
message: 'This is a message',
server_error_id: 'test.app_error',
status_code: 418,
url: 'https://example.com/api/v4/error',
});
const copy = {...error};
expect(copy.message).toEqual(error.message);
expect(copy.server_error_id).toEqual(error.server_error_id);
expect(copy.status_code).toEqual(error.status_code);
expect(copy.url).toEqual(error.url);
});
test('cause should be preserved when provided', () => {
const cause = new Error('the original error');
const error = new ClientError('https://example.com', {
message: 'This is a message',
server_error_id: 'test.app_error',
status_code: 418,
url: 'https://example.com/api/v4/error',
}, cause);
const copy = {...error};
expect(copy.message).toEqual(error.message);
expect(copy.server_error_id).toEqual(error.server_error_id);
expect(copy.status_code).toEqual(error.status_code);
expect(copy.url).toEqual(error.url);
expect(error.cause).toEqual(cause);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Client4 from './client4';
import {cleanUrlForLogging} from './errors';
describe('cleanUrlForLogging', () => {
const baseUrl = 'https://mattermost.example.com/subpath';
const client = new Client4();
client.setUrl(baseUrl);
const testCases = [{
name: 'should remove server URL',
input: client.getUserRoute('me'),
expected: `${client.urlVersion}/users/me`,
}, {
name: 'should filter user IDs',
input: client.getUserRoute('1234'),
expected: `${client.urlVersion}/users/<filtered>`,
}, {
name: 'should filter email addresses',
input: `${client.getUsersRoute()}/email/test@example.com`,
expected: `${client.urlVersion}/users/email/<filtered>`,
}, {
name: 'should filter query parameters',
input: `${client.getUserRoute('me')}?foo=bar`,
expected: `${client.urlVersion}/users/me?<filtered>`,
}];
for (const testCase of testCases) {
test(testCase.name, () => {
expect(cleanUrlForLogging(baseUrl, testCase.input)).toEqual(testCase.expected);
});
}
});

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Given a URL from an API request, return a URL that has any parts removed that are either sensitive or that would
// prevent properly grouping the messages in Sentry.
export function cleanUrlForLogging(baseUrl: string, apiUrl: string): string {
let url = apiUrl;
// Trim the host name
url = url.substring(baseUrl.length);
// Filter the query string
const index = url.indexOf('?');
if (index !== -1) {
url = url.substring(0, index);
}
// A non-exhaustive whitelist to exclude parts of the URL that are unimportant (eg IDs) or may be sentsitive
// (eg email addresses). We prefer filtering out fields that aren't recognized because there should generally
// be enough left over for debugging.
//
// Note that new API routes don't need to be added here since this shouldn't be happening for newly added routes.
const whitelist = [
'api', 'v4', 'users', 'teams', 'scheme', 'name', 'members', 'channels', 'posts', 'reactions', 'commands',
'files', 'preferences', 'hooks', 'incoming', 'outgoing', 'oauth', 'apps', 'emoji', 'brand', 'image',
'data_retention', 'jobs', 'plugins', 'roles', 'system', 'timezones', 'schemes', 'redirect_location', 'patch',
'mfa', 'password', 'reset', 'send', 'active', 'verify', 'terms_of_service', 'login', 'logout', 'ids',
'usernames', 'me', 'username', 'email', 'default', 'sessions', 'revoke', 'all', 'audits', 'device', 'status',
'search', 'switch', 'authorized', 'authorize', 'deauthorize', 'tokens', 'disable', 'enable', 'exists', 'unread',
'invite', 'batch', 'stats', 'import', 'schemeRoles', 'direct', 'group', 'convert', 'view', 'search_autocomplete',
'thread', 'info', 'flagged', 'pinned', 'pin', 'unpin', 'opengraph', 'actions', 'thumbnail', 'preview', 'link',
'delete', 'logs', 'ping', 'config', 'client', 'license', 'websocket', 'webrtc', 'token', 'regen_token',
'autocomplete', 'execute', 'regen_secret', 'policy', 'type', 'cancel', 'reload', 'environment', 's3_test', 'file',
'caches', 'invalidate', 'database', 'recycle', 'compliance', 'reports', 'cluster', 'ldap', 'test', 'sync', 'saml',
'certificate', 'public', 'private', 'idp', 'elasticsearch', 'purge_indexes', 'analytics', 'old', 'webapp', 'fake',
];
url = url.split('/').map((part) => {
if (part !== '' && whitelist.indexOf(part) === -1) {
return '<filtered>';
}
return part;
}).join('/');
if (index !== -1) {
// Add this on afterwards since it wouldn't pass the whitelist
url += '?<filtered>';
}
return url;
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString, extractFilenameFromContentDisposition} from './helpers';
describe('Helpers', () => {
test.each([
[{}, ''],
[{a: 1}, '?a=1'],
[{a: 1, b: 'str'}, '?a=1&b=str'],
[{a: 1, b: 'str', c: undefined}, '?a=1&b=str'],
[{a: 1, b: 'str', c: 0}, '?a=1&b=str&c=0'],
[{a: 1, b: 'str', c: ''}, '?a=1&b=str&c='],
[{a: 1, b: undefined, c: 'str'}, '?a=1&c=str'],
])('buildQueryString with %o should return %s', (params, expected) => {
expect(buildQueryString(params)).toEqual(expected);
});
describe('extractFilenameFromContentDisposition', () => {
const fallback = 'fallback.csv';
test.each([
['attachment; filename="report.csv"', 'report.csv'],
['attachment; filename=report.csv', 'report.csv'],
["attachment; filename='report.csv'", 'report.csv'],
['attachment; filename="post-exposure-abc123-1700000000000.csv"', 'post-exposure-abc123-1700000000000.csv'],
// An unquoted value ends at the parameter separator, with or without surrounding space.
['attachment; filename=report.csv; size=1', 'report.csv'],
['attachment; filename=report.csv;size=1', 'report.csv'],
['attachment; filename = report.csv ; size=1', 'report.csv'],
// A quoted value may contain the separator and escaped quotes.
['attachment; filename="report; final.csv"', 'report; final.csv'],
['attachment; filename="say \\"hi\\".csv"', 'say "hi".csv'],
["attachment; filename='say \\'hi\\'.csv'", "say 'hi'.csv"],
['attachment; filename="back\\\\slash.csv"', 'back\\slash.csv'],
["attachment; filename='back\\\\slash.csv'", 'back\\slash.csv'],
// RFC 5987 extended values are percent-decoded, and preferred over plain filename.
["attachment; filename*=UTF-8''report.csv", 'report.csv'],
["attachment; filename*=UTF-8''report%20name.csv", 'report name.csv'],
["attachment; filename=ascii.csv; filename*=UTF-8''unicode%E2%9C%93.csv", 'unicode✓.csv'],
["attachment; filename*=UTF-8''report.csv; size=1", 'report.csv'],
// An undecodable or malformed extended value falls through to plain filename, then fallback.
["attachment; filename=ascii.csv; filename*=UTF-8''%E0%A4%A.csv", 'ascii.csv'],
['attachment; filename*=no-charset-delimiters', fallback],
["attachment; filename*=UTF-8''%E0%A4%A.csv", fallback],
['attachment; filename=', fallback],
['attachment; filename=""', fallback],
['inline', fallback],
['', fallback],
[null, fallback],
[undefined, fallback],
])('with header %p should return %p', (header, expected) => {
expect(extractFilenameFromContentDisposition(header, fallback)).toEqual(expected);
});
// Only the "\\." alternative may consume a backslash. If the fallback character class
// could consume it too, every escaped character would have two ways to match and an
// unterminated value would backtrack exponentially, blocking the calling thread.
test.each([
['"', '\\!'],
["'", '\\&'],
])('with an unterminated %p quoted value repeating %p should not backtrack', (quote, escaped) => {
const value = escaped.repeat(80);
expect(extractFilenameFromContentDisposition(`attachment; filename=${quote}${value}`, fallback)).toEqual(`${quote}${value}`);
}, 1000);
});
});

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function buildQueryString(parameters: Record<string, any>): string {
const keys = Object.keys(parameters);
if (keys.length === 0) {
return '';
}
const queryParams = Object.entries(parameters).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
filter(([_, value]) => value !== undefined).
map(([key, value]) => `${key}=${encodeURIComponent(value)}`).
join('&');
return queryParams.length > 0 ? `?${queryParams}` : '';
}
// extractFilenameFromContentDisposition returns the filename advertised by a Content-Disposition
// response header, falling back to the given name when the header is missing or unparsable.
export function extractFilenameFromContentDisposition(header: string | null | undefined, fallback: string): string {
if (!header) {
return fallback;
}
// RFC 6266 prefers the RFC 5987 extended parameter when a header carries both.
const extended = (/filename\*\s*=\s*([^;]*)/i).exec(header);
if (extended) {
const decoded = decodeExtendedFilename(extended[1].trim());
if (decoded) {
return decoded;
}
}
// A quoted value may legitimately contain the ";" parameter separator, so consume it as a
// unit. An unquoted value ends at the next ";" or at the end of the header; without that
// bound a trailing parameter such as "; size=1" would be read as part of the filename.
const quoted = (/filename\s*=\s*"((?:\\.|[^"\\])*)"/i).exec(header) ??
(/filename\s*=\s*'((?:\\.|[^'\\])*)'/i).exec(header);
if (quoted) {
return quoted[1].replace(/\\(.)/g, '$1').trim() || fallback;
}
const unquoted = (/filename\s*=\s*([^;]*)/i).exec(header);
if (unquoted) {
return unquoted[1].trim() || fallback;
}
return fallback;
}
// decodeExtendedFilename decodes an RFC 5987 "charset'language'percent-encoded-value" parameter.
// It returns an empty string when the value is malformed or percent-decoding fails, so callers
// fall through to the plain "filename" parameter rather than surfacing a partial value.
function decodeExtendedFilename(value: string): string {
const parts = (/^[\w-]+'[\w-]*'(.*)$/).exec(value);
if (!parts) {
return '';
}
try {
return decodeURIComponent(parts[1]).trim();
} catch {
return '';
}
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type * as WebSocketMessages from './websocket_messages';
export {
default as Client4,
ClientError,
DEFAULT_LIMIT_AFTER,
DEFAULT_LIMIT_BEFORE,
} from './client4';
export {extractFilenameFromContentDisposition} from './helpers';
export {default as WebSocketClient} from './websocket';
export {WebSocketEvents} from './websocket_events';
export type {BaseWebSocketMessage, JsonEncodedValue, WebSocketBroadcast, WebSocketMessage} from './websocket_message';
export {WebSocketMessages};

View File

@@ -0,0 +1,664 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import WebSocketClient from './websocket';
// Define some browser globals that aren't defined in node
if (typeof WebSocket === 'undefined') {
(global as any).WebSocket = {
CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3,
};
}
// Mock Event class if it's not defined
if (typeof Event === 'undefined') {
(global as any).Event = class MockEvent {
type: string;
constructor(type: string) {
this.type = type;
}
};
}
// Mock CloseEvent class if it's not defined
if (typeof CloseEvent === 'undefined') {
(global as any).CloseEvent = class MockCloseEvent extends (global as any).Event {
code: number;
reason: string;
wasClean: boolean;
constructor(type: string, options?: {code?: number; reason?: string; wasClean?: boolean}) {
super(type);
this.code = options?.code || 0;
this.reason = options?.reason || '';
this.wasClean = options?.wasClean || false;
}
};
}
class MockWebSocket {
readonly binaryType: BinaryType = 'blob';
readonly bufferedAmount: number = 0;
readonly extensions: string = '';
readonly CONNECTING = WebSocket.CONNECTING;
readonly OPEN = WebSocket.OPEN;
readonly CLOSING = WebSocket.CLOSING;
readonly CLOSED = WebSocket.CLOSED;
public url: string = '';
readonly protocol: string = '';
public readyState: number = WebSocket.CONNECTING;
public onopen: (() => void) | null = null;
public onclose: (() => void) | null = null;
public onerror: (() => void) | null = null;
public onmessage: ((evt: any) => void) | null = null;
open() {
this.readyState = WebSocket.OPEN;
if (this.onopen) {
this.onopen();
}
}
close() {
this.readyState = WebSocket.CLOSED;
if (this.onclose) {
this.onclose();
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
send(msg: any) { }
addEventListener() { }
removeEventListener() { }
dispatchEvent(): boolean {
return false;
}
}
describe('websocketclient', () => {
test('initialize should register connection callbacks', () => {
const mockWebSocket = new MockWebSocket();
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
return mockWebSocket;
},
});
client.initialize('mock.url');
expect(mockWebSocket.onopen).toBeTruthy();
expect(mockWebSocket.onclose).toBeTruthy();
client.close();
});
test('should reconnect on websocket close', () => {
jest.useFakeTimers();
const mockWebSocket = new MockWebSocket();
const openSpy = jest.spyOn(mockWebSocket, 'open');
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
mockWebSocket.open();
return mockWebSocket;
},
minWebSocketRetryTime: 10,
reconnectJitterRange: 10,
});
client.initialize('mock.url');
expect(openSpy).toHaveBeenCalledTimes(1);
mockWebSocket.close();
jest.advanceTimersByTime(100);
client.close();
expect(openSpy).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
test('should close during reconnection delay', () => {
jest.useFakeTimers();
const mockWebSocket = new MockWebSocket();
const openSpy = jest.spyOn(mockWebSocket, 'open');
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
setTimeout(() => {
if (mockWebSocket.onopen) {
mockWebSocket.open();
}
}, 1);
return mockWebSocket;
},
minWebSocketRetryTime: 50,
reconnectJitterRange: 1,
});
const initializeSpy = jest.spyOn(client, 'initialize');
client.initialize('mock.url');
mockWebSocket.close();
jest.advanceTimersByTime(10);
client.close();
jest.advanceTimersByTime(80);
client.close();
expect(initializeSpy).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
test('should not re-open if initialize called during reconnection delay', () => {
jest.useFakeTimers();
const mockWebSocket = new MockWebSocket();
const openSpy = jest.spyOn(mockWebSocket, 'open');
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
setTimeout(() => {
if (mockWebSocket.onopen) {
mockWebSocket.open();
}
}, 1);
return mockWebSocket;
},
minWebSocketRetryTime: 50,
reconnectJitterRange: 1,
});
const initializeSpy = jest.spyOn(client, 'initialize');
client.initialize('mock.url');
mockWebSocket.close();
jest.advanceTimersByTime(10);
client.initialize('mock.url');
expect(initializeSpy).toHaveBeenCalledTimes(2);
expect(openSpy).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(80);
client.close();
expect(initializeSpy).toHaveBeenCalledTimes(3);
expect(openSpy).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
test('should not register second reconnection timeout if onclose called twice', () => {
jest.useFakeTimers();
const mockWebSocket = new MockWebSocket();
const openSpy = jest.spyOn(mockWebSocket, 'open');
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
setTimeout(() => {
if (mockWebSocket.onopen) {
mockWebSocket.open();
}
}, 1);
return mockWebSocket;
},
minWebSocketRetryTime: 50,
reconnectJitterRange: 1,
});
const initializeSpy = jest.spyOn(client, 'initialize');
client.initialize('mock.url');
mockWebSocket.close();
jest.advanceTimersByTime(10);
mockWebSocket.close();
jest.advanceTimersByTime(80);
client.close();
expect(initializeSpy).toHaveBeenCalledTimes(2);
expect(openSpy).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
test('should stay connected after ping response', () => {
jest.useFakeTimers();
const mockWebSocket = new MockWebSocket();
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
setTimeout(() => {
if (mockWebSocket.onopen) {
mockWebSocket.open();
}
}, 1);
return mockWebSocket;
},
minWebSocketRetryTime: 1,
reconnectJitterRange: 1,
clientPingInterval: 1,
});
let numPings = 0;
let numPongs = 0;
mockWebSocket.send = (evt) => {
const msg = JSON.parse(evt);
if (msg.action !== 'ping') {
return;
}
numPings++;
const rsp = {
text: 'pong',
seq_reply: msg.seq,
};
if (mockWebSocket.onmessage) {
mockWebSocket.onmessage({data: JSON.stringify(rsp)});
numPongs++;
}
};
const openSpy = jest.spyOn(mockWebSocket, 'open');
const closeSpy = jest.spyOn(mockWebSocket, 'close');
client.initialize('mock.url');
jest.advanceTimersByTime(30);
client.close();
expect(openSpy).toHaveBeenCalledTimes(1);
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(numPings).toBeGreaterThan(10);
expect(numPongs).toBeGreaterThan(10);
jest.useRealTimers();
});
test('should reconnect after no ping response', () => {
jest.useFakeTimers();
const mockWebSocket = new MockWebSocket();
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
setTimeout(() => {
if (mockWebSocket.onopen) {
mockWebSocket.open();
}
}, 1);
return mockWebSocket;
},
minWebSocketRetryTime: 1,
reconnectJitterRange: 1,
clientPingInterval: 10,
});
let numPings = 0;
let numPongs = 0;
mockWebSocket.send = (evt) => {
const msg = JSON.parse(evt);
if (msg.action !== 'ping') {
return;
}
numPings++;
// stop responding after three pings
if (numPings > 3) {
return;
}
const rsp = {
text: 'pong',
seq_reply: msg.seq,
};
if (mockWebSocket.onmessage) {
mockWebSocket.onmessage({data: JSON.stringify(rsp)});
numPongs++;
}
};
mockWebSocket.open = jest.fn(mockWebSocket.open);
mockWebSocket.close = jest.fn(() => {
mockWebSocket.readyState = WebSocket.CLOSED;
if (mockWebSocket.onclose) {
mockWebSocket.onclose();
}
if (jest.mocked(mockWebSocket.close).mock.calls.length === 3) {
setTimeout(() => {
client.close();
}, 1);
}
});
client.initialize('mock.url');
jest.advanceTimersByTime(100);
client.close();
expect(mockWebSocket.open).toHaveBeenCalledTimes(3);
expect(mockWebSocket.close).toHaveBeenCalledTimes(3);
expect(numPings).toBe(6);
expect(numPongs).toBe(3);
jest.useRealTimers();
});
test('should reset ping interval state when reconnecting during pending ping', () => {
jest.useFakeTimers();
const mockWebSocket = new MockWebSocket();
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
setTimeout(() => {
if (mockWebSocket.onopen) {
mockWebSocket.open();
}
}, 1);
return mockWebSocket;
},
minWebSocketRetryTime: 1,
reconnectJitterRange: 1,
clientPingInterval: 15,
});
let numPings = 0;
let numPongs = 0;
mockWebSocket.send = (evt) => {
const msg = JSON.parse(evt);
if (msg.action !== 'ping') {
return;
}
numPings++;
// don't respond to second ping
if (numPings === 2) {
return;
}
const rsp = {
text: 'pong',
seq_reply: msg.seq,
};
if (mockWebSocket.onmessage) {
mockWebSocket.onmessage({data: JSON.stringify(rsp)});
numPongs++;
}
};
const openSpy = jest.spyOn(mockWebSocket, 'open');
const closeSpy = jest.spyOn(mockWebSocket, 'close');
client.initialize('mock.url');
// Let first ping happen
jest.advanceTimersByTime(10);
expect(numPings).toBe(1);
expect(numPongs).toBe(1);
// Let second ping happen
jest.advanceTimersByTime(10);
expect(numPings).toBe(2);
expect(numPongs).toBe(1);
// Ensure we've still only connected once, and haven't disconnected, yet
expect(openSpy).toHaveBeenCalledTimes(1);
expect(closeSpy).toHaveBeenCalledTimes(0);
// Close and reopen connection before ping timeout
mockWebSocket.close();
// Let new connection run for a while to ensure no immediate reconnect
jest.advanceTimersByTime(100);
client.close();
expect(numPings).toBe(9);
expect(numPongs).toBe(numPings - 1); // Ensure we only skipped the first response
expect(openSpy).toHaveBeenCalledTimes(2); // Initial open and one reconnect
expect(closeSpy).toHaveBeenCalledTimes(2); // Manual close and final close
jest.useRealTimers();
});
describe('online/offline', () => {
const originalWindow = globalThis.window;
// Mock window and navigator if they're not defined
beforeAll(() => {
if (typeof window === 'undefined') {
const eventHandlers: {[key: string]: Array<(event: Event) => void>} = {};
// Create a mock window object with working event handlers
(globalThis as any).window = {
addEventListener: jest.fn((event: string, handler: (event: Event) => void) => {
if (!eventHandlers[event]) {
eventHandlers[event] = [];
}
eventHandlers[event].push(handler);
}),
removeEventListener: jest.fn((event: string, handler: (event: Event) => void) => {
if (eventHandlers[event]) {
const index = eventHandlers[event].indexOf(handler);
if (index !== -1) {
eventHandlers[event].splice(index, 1);
}
}
}),
dispatchEvent: jest.fn((event: Event) => {
const handlers = eventHandlers[event.type] || [];
handlers.forEach((handler) => handler(event));
return true;
}),
};
}
});
afterAll(() => {
globalThis.window = originalWindow;
});
test('should add network event listener on initialize', () => {
// Mock window.addEventListener
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
const addEventListenerMock = jest.fn();
const removeEventListenerMock = jest.fn();
window.addEventListener = addEventListenerMock;
window.removeEventListener = removeEventListenerMock;
// Create client
const mockWebSocket = new MockWebSocket();
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
return mockWebSocket;
},
});
// No listeners should be added on construction
expect(addEventListenerMock).not.toHaveBeenCalled();
// Initialize should add listeners
client.initialize('mock.url');
// Verify event listeners are added on initialize
expect(addEventListenerMock).toHaveBeenCalledWith('online', expect.any(Function));
// Clean up
client.close();
// Verify event listeners are removed
expect(removeEventListenerMock).toHaveBeenCalledWith('online', expect.any(Function));
// Restore mocks
window.addEventListener = originalAddEventListener;
window.removeEventListener = originalRemoveEventListener;
});
test('should reconnect when network comes online', () => {
jest.useFakeTimers();
var connected = true;
const mockWebSocket = new MockWebSocket();
const newWebSocketFn = jest.fn((url: string) => {
mockWebSocket.url = url;
// selectively simulate the network being down
setTimeout(() => {
if (!connected && mockWebSocket.onclose) {
mockWebSocket.close();
}
}, 1);
return mockWebSocket;
});
// Use a small minWebSocketRetryTime to speed up the test
const client = new WebSocketClient({
newWebSocketFn,
minWebSocketRetryTime: 100,
maxWebSocketRetryTime: 1000,
});
// Initialize the client
client.initialize('mock.url');
mockWebSocket.open();
expect(newWebSocketFn).toHaveBeenCalledTimes(1);
// Simulate network going offline
mockWebSocket.close();
connected = false;
// Connection should be closed
expect(mockWebSocket.readyState).toBe(WebSocket.CLOSED);
// Wait a very long time to max out retry timeout
jest.advanceTimersByTime(10000);
// Reset the mock to track the next retry
// which should be quicker than the max
newWebSocketFn.mockClear();
// Simulate network coming back online
connected = true;
const onlineEvent = new Event('online');
window.dispatchEvent(onlineEvent);
// Should not reconnect immediately (should wait for the timeout)
expect(newWebSocketFn).not.toHaveBeenCalled();
// Advance timers to trigger the reconnect
jest.advanceTimersByTime(110);
// Should have reconnected after the timeout
expect(newWebSocketFn).toHaveBeenCalledTimes(1);
// Clean up
client.close();
// Reset timers
jest.useRealTimers();
});
test('should send ping when network goes offline', () => {
jest.useFakeTimers();
const mockWebSocket = new MockWebSocket();
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
setTimeout(() => {
if (mockWebSocket.onopen) {
mockWebSocket.open();
}
}, 1);
return mockWebSocket;
},
clientPingInterval: 300,
});
let numPings = 0;
mockWebSocket.send = (evt) => {
const msg = JSON.parse(evt);
if (msg.action !== 'ping') {
return;
}
numPings++;
};
const openSpy = jest.spyOn(mockWebSocket, 'open');
const closeSpy = jest.spyOn(mockWebSocket, 'close');
client.initialize('mock.url');
jest.advanceTimersByTime(10);
expect(mockWebSocket.readyState).toBe(WebSocket.OPEN);
expect(openSpy).toHaveBeenCalledTimes(1);
expect(closeSpy).toHaveBeenCalledTimes(0);
expect(numPings).toBe(1);
// Simulate network going offline
const offlineEvent = new Event('offline');
window.dispatchEvent(offlineEvent);
jest.advanceTimersByTime(10);
client.close();
expect(openSpy).toHaveBeenCalledTimes(1);
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(numPings).toBe(2);
jest.useRealTimers();
});
});
test('should be able to use WebSocketClient in a non-browser environment', () => {
const mockWebSocket = new MockWebSocket();
const client = new WebSocketClient({
newWebSocketFn: (url: string) => {
mockWebSocket.url = url;
return mockWebSocket;
},
});
expect(window).not.toBeDefined();
client.initialize('mock.url');
expect(mockWebSocket.onopen).toBeTruthy();
expect(mockWebSocket.onclose).toBeTruthy();
client.close();
});
});

View File

@@ -0,0 +1,678 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {WebSocketEvents} from './websocket_events';
import type {WebSocketMessage} from './websocket_message';
export type MessageListener = (msg: WebSocketMessage) => void;
export type FirstConnectListener = () => void;
export type ReconnectListener = () => void;
export type MissedMessageListener = () => void;
export type ErrorListener = (event: Event) => void;
export type CloseListener = (connectFailCount: number) => void;
export type WebSocketClientConfig = {
maxWebSocketFails: number;
minWebSocketRetryTime: number;
maxWebSocketRetryTime: number;
reconnectJitterRange: number;
newWebSocketFn: (url: string) => WebSocket;
clientPingInterval: number;
};
// Custom close error codes must be in the range of 4000-4999
const clientPingTimeoutErrCode = 4000;
const clientSequenceMismatchErrCode = 4001;
const defaultWebSocketClientConfig: WebSocketClientConfig = {
maxWebSocketFails: 7,
minWebSocketRetryTime: 3000, // 3 seconds
maxWebSocketRetryTime: 300000, // 5 minutes
reconnectJitterRange: 2000, // 2 seconds
newWebSocketFn: (url: string) => {
return new WebSocket(url);
},
clientPingInterval: 30000, // 30 seconds
};
export default class WebSocketClient {
private config: WebSocketClientConfig;
private conn: WebSocket | null;
// responseSequence is the number to track a response sent
// via the websocket. A response will always have the same sequence number
// as the request.
private responseSequence: number;
// serverSequence is the incrementing sequence number from the
// server-sent event stream.
private serverSequence: number;
private connectFailCount: number;
private responseCallbacks: {[x: number]: ((msg: any) => void)};
private lastErrCode: string | null;
/**
* @deprecated Use messageListeners instead
*/
private eventCallback: MessageListener | null = null;
/**
* @deprecated Use firstConnectListeners instead
*/
private firstConnectCallback: FirstConnectListener | null = null;
/**
* @deprecated Use reconnectListeners instead
*/
private reconnectCallback: ReconnectListener | null = null;
/**
* @deprecated Use missedMessageListeners instead
*/
private missedEventCallback: MissedMessageListener | null = null;
/**
* @deprecated Use errorListeners instead
*/
private errorCallback: ErrorListener | null = null;
/**
* @deprecated Use closeListeners instead
*/
private closeCallback: CloseListener | null = null;
private messageListeners = new Set<MessageListener>();
private firstConnectListeners = new Set<FirstConnectListener>();
private reconnectListeners = new Set<ReconnectListener>();
private missedMessageListeners = new Set<MissedMessageListener>();
private errorListeners = new Set<ErrorListener>();
private closeListeners = new Set<CloseListener>();
private connectionId: string | null;
private serverHostname: string | null;
private postedAck: boolean;
private pingInterval: ReturnType<typeof setInterval> | null;
private waitingForPong: boolean;
// reconnectTimeout is used for automatic reconnect after socket close
private reconnectTimeout: ReturnType<typeof setTimeout> | null;
// Network event handlers
private onlineHandler: (() => void) | null = null;
private offlineHandler: (() => void) | null = null;
constructor(config?: Partial<WebSocketClientConfig>) {
this.conn = null;
this.responseSequence = 1;
this.serverSequence = 0;
this.connectFailCount = 0;
this.responseCallbacks = {};
this.connectionId = '';
this.serverHostname = '';
this.postedAck = false;
this.reconnectTimeout = null;
this.config = {...defaultWebSocketClientConfig, ...config};
this.pingInterval = null;
this.waitingForPong = false;
this.lastErrCode = null;
}
// on connect, only send auth cookie and blank state.
// on hello, get the connectionID and store it.
// on reconnect, send cookie, connectionID, sequence number.
initialize(connectionUrl: string, token?: string, postedAck?: boolean) {
if (this.conn) {
return;
}
// We have a timeout waiting to re-initialize the websocket.
// We should wait until that fires before initializing,
// otherwise we may not respect the configured backoff.
if (this.reconnectTimeout) {
return;
}
if (connectionUrl == null) {
console.log('websocket must have connection url'); //eslint-disable-line no-console
return;
}
if (this.connectFailCount === 0) {
console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console
}
if (typeof postedAck != 'undefined') {
this.postedAck = postedAck;
}
// Setup network event listener
// Remove existing listeners if any
if (this.onlineHandler) {
globalThis.window?.removeEventListener('online', this.onlineHandler);
}
if (this.offlineHandler) {
globalThis.window?.removeEventListener('offline', this.offlineHandler);
}
this.onlineHandler = () => {
// If we're already connected, don't need to do anything
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
return;
}
console.log('network online event received, scheduling reconnect'); // eslint-disable-line no-console
// Set a timer to reconnect after a delay to avoid rapid connection attempts
this.clearReconnectTimeout();
this.reconnectTimeout = setTimeout(
() => {
this.reconnectTimeout = null;
this.initialize(connectionUrl, token, this.postedAck);
},
this.config.minWebSocketRetryTime,
);
};
this.offlineHandler = () => {
// If we've detected a full disconnection, don't need to do anything more
if (this.conn && this.conn.readyState !== WebSocket.OPEN) {
return;
}
console.log('network offline event received, checking connection'); // eslint-disable-line no-console
// If we haven't detected a full disconnection,
// send a ping immediately to test the socket
//
// NOTE: There is a potential race condition here with the regular ping interval.
// If we send this ping close to when the interval check occurs (e.g., at 29.5s of the 30s interval),
// the server might not have enough time to respond before the interval executes.
// When the interval runs, it will see we're still waiting for a pong and close the connection,
// even though the network might be fine and the server just needs a bit more time to respond.
// This race condition is rare and the impact is just an unnecessary reconnect,
// so we accept this limitation to keep the implementation simple.
this.waitingForPong = true;
this.ping(() => {
this.waitingForPong = false;
});
};
globalThis.window?.addEventListener('online', this.onlineHandler);
globalThis.window?.addEventListener('offline', this.offlineHandler);
// Add connection id, and last_sequence_number to the query param.
// We cannot use a cookie because it will bleed across tabs.
// We cannot also send it as part of the auth_challenge, because the session cookie is already sent with the request.
let websocketUrl = `${connectionUrl}?connection_id=${this.connectionId}&sequence_number=${this.serverSequence}`;
if (this.postedAck) {
websocketUrl += '&posted_ack=true';
}
if (this.lastErrCode) {
websocketUrl += `&disconnect_err_code=${encodeURIComponent(this.lastErrCode)}`;
}
if (this.config.newWebSocketFn) {
this.conn = this.config.newWebSocketFn(websocketUrl);
} else {
this.conn = new WebSocket(websocketUrl);
}
const onclose = (event: CloseEvent) => {
this.conn = null;
this.responseSequence = 1;
if (!this.lastErrCode && event && event.code) {
this.lastErrCode = `${event.code}`;
}
if (this.connectFailCount === 0) {
console.log(`websocket closed: ${this.lastErrCode}`); //eslint-disable-line no-console
}
this.connectFailCount++;
this.closeCallback?.(this.connectFailCount);
this.closeListeners.forEach((listener) => listener(this.connectFailCount));
// Make sure we stop pinging if the connection is closed
this.stopPingInterval();
// If we've failed a bunch of connections then start backing off
let retryTime = this.config.minWebSocketRetryTime;
if (this.connectFailCount > this.config.maxWebSocketFails) {
retryTime = retryTime * this.connectFailCount * this.connectFailCount;
if (retryTime > this.config.maxWebSocketRetryTime) {
retryTime = this.config.maxWebSocketRetryTime;
}
}
// Applying jitter to avoid thundering herd problems.
retryTime += Math.random() * this.config.reconnectJitterRange;
// If we already have a reconnect timeout waiting,
// we should let that handle the next connection.
if (this.reconnectTimeout) {
return;
}
this.reconnectTimeout = setTimeout(
() => {
this.reconnectTimeout = null;
this.initialize(connectionUrl, token, this.postedAck);
},
retryTime,
);
};
this.conn.onclose = onclose;
this.conn.onopen = () => {
if (token) {
this.sendMessage('authentication_challenge', {token});
}
this.lastErrCode = null;
if (this.connectFailCount > 0) {
console.log('websocket re-established connection'); //eslint-disable-line no-console
this.reconnectCallback?.();
this.reconnectListeners.forEach((listener) => listener());
} else if (this.firstConnectCallback || this.firstConnectListeners.size > 0) {
this.firstConnectCallback?.();
this.firstConnectListeners.forEach((listener) => listener());
}
this.stopPingInterval();
// Send a ping immediately to test the socket
this.waitingForPong = true;
this.ping(() => {
this.waitingForPong = false;
});
// And every 30 seconds after, checking to ensure
// we're getting responses from the server
this.pingInterval = setInterval(
() => {
if (!this.waitingForPong) {
this.waitingForPong = true;
this.ping(() => {
this.waitingForPong = false;
});
return;
}
this.stopPingInterval();
// If we aren't connected, we should already be trying to
// re-connect the websocket. So there's nothing more to do here.
if (!this.conn || this.conn.readyState !== WebSocket.OPEN) {
return;
}
console.log('ping received no response within time limit: re-establishing websocket'); //eslint-disable-line no-console
const closeEvent = new CloseEvent('close', {
code: clientPingTimeoutErrCode,
wasClean: false,
});
// Calling conn.close() will trigger the onclose callback,
// but sometimes with a significant delay. So instead, we
// call the onclose callback ourselves immediately. We also
// unset the callback on the old connection to ensure it
// is only called once.
this.connectFailCount = 0;
this.responseSequence = 1;
this.conn.onclose = () => {};
this.conn.close();
onclose(closeEvent);
},
this.config.clientPingInterval);
this.connectFailCount = 0;
};
this.conn.onerror = (evt) => {
if (this.connectFailCount <= 1) {
console.log('websocket error'); //eslint-disable-line no-console
console.log(evt); //eslint-disable-line no-console
}
this.errorCallback?.(evt);
this.errorListeners.forEach((listener) => listener(evt));
};
this.conn.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
if (msg.seq_reply) {
// This indicates a reply to a websocket request.
// We ignore sequence number validation of message responses
// and only focus on the purely server side event stream.
if (msg.error) {
console.log(msg); //eslint-disable-line no-console
}
if (this.responseCallbacks[msg.seq_reply]) {
this.responseCallbacks[msg.seq_reply](msg);
Reflect.deleteProperty(this.responseCallbacks, msg.seq_reply);
}
} else if (this.eventCallback || this.messageListeners.size > 0) {
// We check the hello packet, which is always the first packet in a stream.
if (msg.event === WebSocketEvents.Hello && (this.missedEventCallback || this.missedMessageListeners.size > 0)) {
console.log('got connection id ', msg.data.connection_id); //eslint-disable-line no-console
// If we already have a connectionId present, and server sends a different one,
// that means it's either a long timeout, or server restart, or sequence number is not found.
// Then we do the sync calls, and reset sequence number to 0.
if (this.connectionId !== '' && this.connectionId !== msg.data.connection_id) {
console.log('long timeout, or server restart, or sequence number is not found.'); //eslint-disable-line no-console
this.missedEventCallback?.();
for (const listener of this.missedMessageListeners) {
try {
listener();
} catch (e) {
console.log(`missed message listener "${listener.name}" failed: ${e}`); // eslint-disable-line no-console
}
}
this.serverSequence = 0;
}
// If it's a fresh connection, we have to set the connectionId regardless.
// And if it's an existing connection, setting it again is harmless, and keeps the code simple.
this.connectionId = msg.data.connection_id;
// Also update the server hostname
this.serverHostname = msg.data.server_hostname;
}
// Now we check for sequence number, and if it does not match,
// we just disconnect and reconnect.
if (msg.seq !== this.serverSequence) {
console.log('missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence); //eslint-disable-line no-console
const closeEvent = new CloseEvent('close', {
code: clientSequenceMismatchErrCode,
wasClean: false,
});
// Calling conn.close() will trigger the onclose callback,
// but sometimes with a significant delay. So instead, we
// call the onclose callback ourselves immediately. We also
// unset the callback on the old connection to ensure it
// is only called once.
this.connectFailCount = 0;
this.responseSequence = 1;
if (this.conn) {
this.conn.onclose = () => {};
this.conn.close();
onclose(closeEvent);
}
return;
}
this.serverSequence = msg.seq + 1;
this.eventCallback?.(msg);
this.messageListeners.forEach((listener) => listener(msg));
}
};
}
/**
* @deprecated Use addMessageListener instead
*/
setEventCallback(callback: MessageListener) {
this.eventCallback = callback;
}
addMessageListener(listener: MessageListener) {
this.messageListeners.add(listener);
if (this.messageListeners.size > 5) {
// eslint-disable-next-line no-console
console.warn(`WebSocketClient has ${this.messageListeners.size} message listeners registered`);
}
}
removeMessageListener(listener: MessageListener) {
this.messageListeners.delete(listener);
}
/**
* @deprecated Use addFirstConnectListener instead
*/
setFirstConnectCallback(callback: FirstConnectListener) {
this.firstConnectCallback = callback;
}
addFirstConnectListener(listener: FirstConnectListener) {
this.firstConnectListeners.add(listener);
if (this.firstConnectListeners.size > 5) {
// eslint-disable-next-line no-console
console.warn(`WebSocketClient has ${this.firstConnectListeners.size} first connect listeners registered`);
}
}
removeFirstConnectListener(listener: FirstConnectListener) {
this.firstConnectListeners.delete(listener);
}
/**
* @deprecated Use addReconnectListener instead
*/
setReconnectCallback(callback: ReconnectListener) {
this.reconnectCallback = callback;
}
addReconnectListener(listener: ReconnectListener) {
this.reconnectListeners.add(listener);
if (this.reconnectListeners.size > 5) {
// eslint-disable-next-line no-console
console.warn(`WebSocketClient has ${this.reconnectListeners.size} reconnect listeners registered`);
}
}
removeReconnectListener(listener: ReconnectListener) {
this.reconnectListeners.delete(listener);
}
/**
* @deprecated Use addMissedMessageListener instead
*/
setMissedEventCallback(callback: MissedMessageListener) {
this.missedEventCallback = callback;
}
addMissedMessageListener(listener: MissedMessageListener) {
this.missedMessageListeners.add(listener);
if (this.missedMessageListeners.size > 5) {
// eslint-disable-next-line no-console
console.warn(`WebSocketClient has ${this.missedMessageListeners.size} missed message listeners registered`);
}
}
removeMissedMessageListener(listener: MissedMessageListener) {
this.missedMessageListeners.delete(listener);
}
/**
* @deprecated Use addErrorListener instead
*/
setErrorCallback(callback: ErrorListener) {
this.errorCallback = callback;
}
addErrorListener(listener: ErrorListener) {
this.errorListeners.add(listener);
if (this.errorListeners.size > 5) {
// eslint-disable-next-line no-console
console.warn(`WebSocketClient has ${this.errorListeners.size} error listeners registered`);
}
}
removeErrorListener(listener: ErrorListener) {
this.errorListeners.delete(listener);
}
/**
* @deprecated Use addCloseListener instead
*/
setCloseCallback(callback: CloseListener) {
this.closeCallback = callback;
}
addCloseListener(listener: CloseListener) {
this.closeListeners.add(listener);
if (this.closeListeners.size > 5) {
// eslint-disable-next-line no-console
console.warn(`WebSocketClient has ${this.closeListeners.size} close listeners registered`);
}
}
removeCloseListener(listener: CloseListener) {
this.closeListeners.delete(listener);
}
close() {
this.connectFailCount = 0;
this.responseSequence = 1;
this.clearReconnectTimeout();
this.lastErrCode = null;
this.stopPingInterval();
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
this.conn.onclose = () => {};
this.conn.close();
this.conn = null;
console.log('websocket closed manually'); //eslint-disable-line no-console
}
if (this.onlineHandler) {
globalThis.window?.removeEventListener('online', this.onlineHandler);
this.onlineHandler = null;
}
if (this.offlineHandler) {
globalThis.window?.removeEventListener('offline', this.offlineHandler);
this.offlineHandler = null;
}
}
clearReconnectTimeout() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
}
stopPingInterval() {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
ping(responseCallback?: (msg: any) => void) {
const msg = {
action: 'ping',
seq: this.responseSequence++,
};
if (responseCallback) {
this.responseCallbacks[msg.seq] = responseCallback;
}
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
this.conn.send(JSON.stringify(msg));
}
}
sendMessage(action: string, data: any, responseCallback?: (msg: any) => void) {
const msg = {
action,
seq: this.responseSequence++,
data,
};
if (responseCallback) {
this.responseCallbacks[msg.seq] = responseCallback;
}
// Only try to send the message if the websocket is open.
// If the websocket is closed here, we will drop the message.
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
this.conn.send(JSON.stringify(msg));
}
}
userTyping(channelId: string, parentId: string, callback?: () => void) {
const data = {
channel_id: channelId,
parent_id: parentId,
};
this.sendMessage('user_typing', data, callback);
}
updateActiveChannel(channelId: string, callback?: (msg: any) => void) {
const data = {
channel_id: channelId,
};
this.sendMessage('presence', data, callback);
}
updateActiveTeam(teamId: string, callback?: (msg: any) => void) {
const data = {
team_id: teamId,
};
this.sendMessage('presence', data, callback);
}
updateActiveThread(isThreadView: boolean, channelId: string, callback?: (msg: any) => void) {
const data = {
thread_channel_id: channelId,
is_thread_view: isThreadView,
};
this.sendMessage('presence', data, callback);
}
userUpdateActiveStatus(userIsActive: boolean, manual: boolean, callback?: () => void) {
const data = {
user_is_active: userIsActive,
manual,
};
this.sendMessage('user_update_active_status', data, callback);
}
acknowledgePostedNotification(postId: string, status: string, reason?: string, postedData?: string) {
const data = {
post_id: postId,
user_agent: globalThis.window?.navigator?.userAgent ?? '',
status,
reason,
data: postedData,
};
this.sendMessage('posted_notify_ack', data);
}
getStatuses(callback?: () => void) {
this.sendMessage('get_statuses', null, callback);
}
getStatusesByIds(userIds: string[], callback?: () => void) {
const data = {
user_ids: userIds,
};
this.sendMessage('get_statuses_by_ids', data, callback);
}
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const enum WebSocketEvents {
Typing = 'typing',
Posted = 'posted',
PostEdited = 'post_edited',
PostDeleted = 'post_deleted',
PostUnread = 'post_unread',
BurnOnReadPostRevealed = 'post_revealed',
BurnOnReadPostBurned = 'post_burned',
BurnOnReadPostAllRevealed = 'burn_on_read_all_revealed',
ChannelConverted = 'channel_converted',
ChannelCreated = 'channel_created',
ChannelDeleted = 'channel_deleted',
ChannelRestored = 'channel_restored',
ChannelUpdated = 'channel_updated',
ChannelMemberUpdated = 'channel_member_updated',
ChannelSchemeUpdated = 'channel_scheme_updated',
DirectAdded = 'direct_added',
GroupAdded = 'group_added',
NewUser = 'new_user',
AddedToTeam = 'added_to_team',
LeaveTeam = 'leave_team',
UpdateTeam = 'update_team',
DeleteTeam = 'delete_team',
RestoreTeam = 'restore_team', // This isn't currently used by the web app
UpdateTeamScheme = 'update_team_scheme',
UserAdded = 'user_added',
UserUpdated = 'user_updated',
UserRoleUpdated = 'user_role_updated',
MemberRoleUpdated = 'memberrole_updated',
UserRemoved = 'user_removed',
PreferenceChanged = 'preference_changed',
PreferencesChanged = 'preferences_changed',
PreferencesDeleted = 'preferences_deleted',
EphemeralMessage = 'ephemeral_message',
StatusChange = 'status_change',
Hello = 'hello',
AuthenticationChallenge = 'authentication_challenge', // This isn't currently used by the web app, and it's a message that would be sent from a client to the server
ReactionAdded = 'reaction_added',
ReactionRemoved = 'reaction_removed',
Response = 'response',
EmojiAdded = 'emoji_added',
MultipleChannelsViewed = 'multiple_channels_viewed',
PluginStatusesChanged = 'plugin_statuses_changed',
PluginEnabled = 'plugin_enabled',
PluginDisabled = 'plugin_disabled',
RoleUpdated = 'role_updated',
LicenseChanged = 'license_changed',
ConfigChanged = 'config_changed',
OpenDialog = 'open_dialog',
GuestsDeactivated = 'guests_deactivated', // This isn't currently used by the web app
UserActivationStatusChange = 'user_activation_status_change',
ReceivedGroup = 'received_group',
ReceivedGroupAssociatedToTeam = 'received_group_associated_to_team',
ReceivedGroupNotAssociatedToTeam = 'received_group_not_associated_to_team',
ReceivedGroupAssociatedToChannel = 'received_group_associated_to_channel',
ReceivedGroupNotAssociatedToChannel = 'received_group_not_associated_to_channel',
GroupMemberDeleted = 'group_member_deleted',
GroupMemberAdded = 'group_member_add',
SidebarCategoryCreated = 'sidebar_category_created',
SidebarCategoryUpdated = 'sidebar_category_updated',
SidebarCategoryDeleted = 'sidebar_category_deleted',
SidebarCategoryOrderUpdated = 'sidebar_category_order_updated',
PropertyFieldCreated = 'property_field_created',
PropertyFieldUpdated = 'property_field_updated',
PropertyFieldDeleted = 'property_field_deleted',
PropertyValuesUpdated = 'property_values_updated',
CloudSubscriptionChanged = 'cloud_subscription_changed',
ThreadUpdated = 'thread_updated',
ThreadFollowChanged = 'thread_follow_changed',
ThreadReadChanged = 'thread_read_changed',
FirstAdminVisitMarketplaceStatusReceived = 'first_admin_visit_marketplace_status_received',
DraftCreated = 'draft_created',
DraftUpdated = 'draft_updated',
DraftDeleted = 'draft_deleted',
PostAcknowledgementAdded = 'post_acknowledgement_added',
PostAcknowledgementRemoved = 'post_acknowledgement_removed',
PersistentNotificationTriggered = 'persistent_notification_triggered',
HostedCustomerSignupProgressUpdated = 'hosted_customer_signup_progress_updated',
ChannelBookmarkCreated = 'channel_bookmark_created',
ChannelBookmarkUpdated = 'channel_bookmark_updated',
ChannelBookmarkDeleted = 'channel_bookmark_deleted',
ChannelBookmarkSorted = 'channel_bookmark_sorted',
ChannelAccessControlUpdated = 'channel_access_control_updated',
PermissionPolicyUpdated = 'permission_policy_updated',
TeamAccessControlUpdated = 'team_access_control_updated',
PresenceIndicator = 'presence',
PostedNotifyAck = 'posted_notify_ack', // This isn't currently used by the web app
ScheduledPostCreated = 'scheduled_post_created',
ScheduledPostUpdated = 'scheduled_post_updated',
ScheduledPostDeleted = 'scheduled_post_deleted',
CPAFieldCreated = 'custom_profile_attributes_field_created',
CPAFieldUpdated = 'custom_profile_attributes_field_updated',
CPAFieldDeleted = 'custom_profile_attributes_field_deleted',
CPAValuesUpdated = 'custom_profile_attributes_values_updated',
ContentFlaggingReportValueUpdated = 'content_flagging_report_value_updated',
JobUpdated = 'job_updated',
RecapUpdated = 'recap_updated',
PostTranslationUpdated = 'post_translation_updated',
FileDownloadRejected = 'file_download_rejected',
FileUploadRejected = 'file_upload_rejected',
ShowToast = 'show_toast',
SharedChannelRemoteUpdated = 'shared_channel_remote_updated',
ChannelJoinRequestCreated = 'channel_join_request_created',
ChannelJoinRequestUpdated = 'channel_join_request_updated',
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {WebSocketEvents} from './websocket_events';
import type * as Messages from './websocket_messages';
export type WebSocketMessage = (
Messages.Hello |
Messages.AuthenticationChallenge |
Messages.Response |
Messages.Posted |
Messages.PostEdited |
Messages.PostDeleted |
Messages.PostUnread |
Messages.BurnOnReadPostRevealed |
Messages.BurnOnReadPostBurned |
Messages.BurnOnReadPostAllRevealed |
Messages.EphemeralPost |
Messages.PostReaction |
Messages.PostAcknowledgement |
Messages.PostDraft |
Messages.PersistentNotificationTriggered |
Messages.ScheduledPost |
Messages.PostTranslationUpdated |
Messages.ThreadUpdated |
Messages.ThreadFollowedChanged |
Messages.ThreadReadChanged |
Messages.ChannelCreated |
Messages.ChannelUpdated |
Messages.ChannelConverted |
Messages.SharedChannelRemoteUpdated |
Messages.ChannelSchemeUpdated |
Messages.ChannelDeleted |
Messages.ChannelRestored |
Messages.DirectChannelCreated |
Messages.GroupChannelCreated |
Messages.UserAddedToChannel |
Messages.UserRemovedFromChannel |
Messages.ChannelMemberUpdated |
Messages.MultipleChannelsViewed |
Messages.ChannelBookmarkCreated |
Messages.ChannelBookmarkUpdated |
Messages.ChannelBookmarkDeleted |
Messages.ChannelBookmarkSorted |
Messages.ChannelAccessControlUpdated |
Messages.PermissionPolicyUpdated |
Messages.TeamAccessControlUpdated |
Messages.ChannelJoinRequestCreated |
Messages.ChannelJoinRequestUpdated |
Messages.Team |
Messages.UpdateTeamScheme |
Messages.UserAddedToTeam |
Messages.UserRemovedFromTeam |
Messages.TeamMemberRoleUpdated |
Messages.NewUser |
Messages.UserUpdated |
Messages.UserActivationStatusChanged |
Messages.UserRoleUpdated |
Messages.StatusChanged |
Messages.Typing |
Messages.ReceivedGroup |
Messages.GroupAssociatedToTeam |
Messages.GroupAssociatedToChannel |
Messages.GroupMember |
Messages.PreferenceChanged |
Messages.PreferencesChanged |
Messages.SidebarCategoryCreated |
Messages.SidebarCategoryUpdated |
Messages.SidebarCategoryDeleted |
Messages.SidebarCategoryOrderUpdated |
Messages.PropertyFieldCreated |
Messages.PropertyFieldUpdated |
Messages.PropertyFieldDeleted |
Messages.PropertyValuesUpdated |
Messages.EmojiAdded |
Messages.RoleUpdated |
Messages.ConfigChanged |
Messages.GuestsDeactivated |
Messages.LicenseChanged |
Messages.CloudSubscriptionChanged |
Messages.FirstAdminVisitMarketplaceStatusReceived |
Messages.HostedCustomerSignupProgressUpdated |
Messages.CPAFieldCreated |
Messages.CPAFieldUpdated |
Messages.CPAFieldDeleted |
Messages.CPAValuesUpdated |
Messages.ContentFlaggingReportValueUpdated |
Messages.JobUpdated |
Messages.RecapUpdated |
Messages.FileDownloadRejected |
Messages.FileUploadRejected |
Messages.ShowToast |
Messages.Plugin |
Messages.PluginStatusesChanged |
Messages.OpenDialog |
BaseWebSocketMessage<WebSocketEvents.PresenceIndicator, unknown> |
BaseWebSocketMessage<WebSocketEvents.PostedNotifyAck, unknown>
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type JsonEncodedValue<T> = string;
export type BaseWebSocketMessage<Event, T = Record<string, never>> = {
event: Event;
data: T;
broadcast: WebSocketBroadcast;
seq: number;
};
export type WebSocketBroadcast = {
omit_users: Record<string, boolean>;
user_id: string;
channel_id: string;
team_id: string;
};

View File

@@ -0,0 +1,557 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChannelBookmarkWithFileInfo, UpdateChannelBookmarkResponse} from '@mattermost/types/channel_bookmarks';
import type {ChannelCategory} from '@mattermost/types/channel_categories';
import type {Channel, ChannelJoinRequest, ChannelMembership, ChannelType} from '@mattermost/types/channels';
import type {Limits, Subscription} from '@mattermost/types/cloud';
import type {ClientConfig, ClientLicense} from '@mattermost/types/config';
import type {Draft} from '@mattermost/types/drafts';
import type {CustomEmoji} from '@mattermost/types/emojis';
import type {Group, GroupMember as GroupMemberType} from '@mattermost/types/groups';
import type {OpenDialogRequest} from '@mattermost/types/integrations';
import type {Job} from '@mattermost/types/jobs';
import type {PluginManifest} from '@mattermost/types/plugins';
import type {Post, PostAcknowledgement as PostAcknowledgementType} from '@mattermost/types/posts';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {PropertyField, PropertyValue} from '@mattermost/types/properties';
import type {Reaction} from '@mattermost/types/reactions';
import type {Role} from '@mattermost/types/roles';
import type {ScheduledPost as ScheduledPostType} from '@mattermost/types/schedule_post';
import type {Team as TeamType, TeamMembership} from '@mattermost/types/teams';
import type {UserThread} from '@mattermost/types/threads';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import type {WebSocketEvents} from './websocket_events';
import type {BaseWebSocketMessage, JsonEncodedValue} from './websocket_message';
// WebSocket-related messages
export type Hello = BaseWebSocketMessage<WebSocketEvents.Hello, {
server_version: string;
connection_id: string;
server_hostname?: string;
}>;
export type AuthenticationChallenge = BaseWebSocketMessage<WebSocketEvents.AuthenticationChallenge, unknown>;
export type Response = BaseWebSocketMessage<WebSocketEvents.Response>;
// Post, reactions, and acknowledgement messages
export type Posted = BaseWebSocketMessage<WebSocketEvents.Posted, {
channel_type: ChannelType;
channel_display_name: string;
channel_name: string;
sender_name: string;
team_id: string;
set_online: boolean;
otherFile?: boolean;
image?: boolean;
post: JsonEncodedValue<Post>;
/**
* If the current user is mentioned by this post, this field will contain the ID of that user. Otherwise,
* it will be empty.
*/
mentions?: JsonEncodedValue<string[]>;
/**
* If the current user is following this post, this field will contain the ID of that user. Otherwise,
* it will be empty.
*/
followers?: JsonEncodedValue<string[]>;
should_ack?: boolean;
}>;
export type PostEdited = BaseWebSocketMessage<WebSocketEvents.PostEdited, {
post: JsonEncodedValue<Post>;
}>;
export type PostDeleted = BaseWebSocketMessage<WebSocketEvents.PostDeleted, {
post: JsonEncodedValue<Post>;
/** The user ID of the user who deleted the post, only sent to admin users. */
delete_by?: string;
}>;
export type PostUnread = BaseWebSocketMessage<WebSocketEvents.PostUnread, {
msg_count: number;
msg_count_root: number;
mention_count: number;
mention_count_root: number;
urgent_mention_count: number;
last_viewed_at: number;
post_id: string;
}>;
export type BurnOnReadPostRevealed = BaseWebSocketMessage<WebSocketEvents.BurnOnReadPostRevealed, {
post?: string | Post;
recipients?: string[];
}>;
export type BurnOnReadPostBurned = BaseWebSocketMessage<WebSocketEvents.BurnOnReadPostBurned, {
post_id: string;
}>;
export type BurnOnReadPostAllRevealed = BaseWebSocketMessage<WebSocketEvents.BurnOnReadPostAllRevealed, {
post_id: string;
sender_expire_at: number;
}>;
export type EphemeralPost = BaseWebSocketMessage<WebSocketEvents.EphemeralMessage, {
post: JsonEncodedValue<Post>;
}>;
export type PostReaction =
BaseWebSocketMessage<WebSocketEvents.ReactionAdded | WebSocketEvents.ReactionRemoved, {
reaction: JsonEncodedValue<Reaction>;
}>;
export type PostAcknowledgement =
BaseWebSocketMessage<WebSocketEvents.PostAcknowledgementAdded | WebSocketEvents.PostAcknowledgementRemoved, {
acknowledgement: JsonEncodedValue<PostAcknowledgementType>;
}>;
export type PostDraft =
BaseWebSocketMessage<WebSocketEvents.DraftCreated | WebSocketEvents.DraftUpdated | WebSocketEvents.DraftDeleted, {
draft: JsonEncodedValue<Draft>;
}>;
export type PersistentNotificationTriggered =
BaseWebSocketMessage<WebSocketEvents.PersistentNotificationTriggered, {
post: JsonEncodedValue<Post>;
channel_type: ChannelType;
channel_display_name: string;
channel_name: string;
sender_name: string;
team_id: string;
otherFile?: boolean;
image?: boolean;
mentions?: JsonEncodedValue<string[]>;
}>;
export type ScheduledPost =
BaseWebSocketMessage<WebSocketEvents.ScheduledPostCreated | WebSocketEvents.ScheduledPostUpdated | WebSocketEvents.ScheduledPostDeleted, {
scheduledPost: JsonEncodedValue<ScheduledPostType>;
}>;
// Thread messages
export type ThreadUpdated = BaseWebSocketMessage<WebSocketEvents.ThreadUpdated, {
thread: JsonEncodedValue<UserThread>;
previous_unread_mentions?: number;
previous_unread_replies?: number;
}>;
export type ThreadFollowedChanged = BaseWebSocketMessage<WebSocketEvents.ThreadFollowChanged, {
thread_id: string;
state: boolean;
reply_count: number;
}>;
export type ThreadReadChanged = BaseWebSocketMessage<WebSocketEvents.ThreadReadChanged, (
// App.UpdateThreadsReadForUser
Record<string, never>
) | (
// App.MarkChannelsAsViewed
{
timestamp: number;
}
) | (
// App.UpdateThreadReadForUser
{
thread_id: string;
timestamp: number;
unread_mentions: number;
unread_replies: number;
previous_unread_mentions: number;
previous_unread_replies: number;
channel_id: string;
}
)>;
// Channel and channel member messages
export type ChannelCreated = BaseWebSocketMessage<WebSocketEvents.ChannelCreated, {
channel_id: string;
team_id: string;
}>;
export type ChannelUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelUpdated, {
// Normally, the channel field is sent, except in some cases where a shared channel is updated in which case
// the channel_id field is used.
channel?: JsonEncodedValue<Channel>;
channel_id?: string;
}>;
export type ChannelConverted = BaseWebSocketMessage<WebSocketEvents.ChannelConverted, {
channel_id: string;
channel_type?: ChannelType;
}>;
export type SharedChannelRemoteUpdated = BaseWebSocketMessage<WebSocketEvents.SharedChannelRemoteUpdated, {
channel_id: string;
}>;
export type ChannelSchemeUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelSchemeUpdated>;
export type ChannelDeleted = BaseWebSocketMessage<WebSocketEvents.ChannelDeleted, {
channel_id: string;
delete_at: number;
}>;
export type ChannelRestored = BaseWebSocketMessage<WebSocketEvents.ChannelRestored, {
channel_id: string;
}>;
export type DirectChannelCreated = BaseWebSocketMessage<WebSocketEvents.DirectAdded, {
creator_id: string;
teammate_id: string;
}>;
export type GroupChannelCreated = BaseWebSocketMessage<WebSocketEvents.GroupAdded, {
teammate_ids: JsonEncodedValue<string[]>;
}>;
export type UserAddedToChannel = BaseWebSocketMessage<WebSocketEvents.UserAdded, {
user_id: string;
team_id: string;
}>;
export type UserRemovedFromChannel = BaseWebSocketMessage<WebSocketEvents.UserRemoved, {
/** The user ID of the user that was removed from the channel. It isn't sent to the user who was removed. */
user_id?: string;
/** The ID of the channel that the user was removed from. It's only sent to the user who was removed. */
channel_id?: string;
remover_id: string;
}>;
export type ChannelMemberUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelMemberUpdated, {
channelMember: JsonEncodedValue<ChannelMembership>;
}>;
export type MultipleChannelsViewed = BaseWebSocketMessage<WebSocketEvents.MultipleChannelsViewed, {
channel_times: Record<string, number>;
}>;
// Channel bookmark messages
export type ChannelBookmarkCreated = BaseWebSocketMessage<WebSocketEvents.ChannelBookmarkCreated, {
bookmark: JsonEncodedValue<ChannelBookmarkWithFileInfo>;
}>;
export type ChannelBookmarkUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelBookmarkUpdated, {
bookmarks: JsonEncodedValue<UpdateChannelBookmarkResponse>;
}>;
export type ChannelBookmarkDeleted = BaseWebSocketMessage<WebSocketEvents.ChannelBookmarkDeleted, {
bookmark: JsonEncodedValue<ChannelBookmarkWithFileInfo>;
}>;
export type ChannelBookmarkSorted = BaseWebSocketMessage<WebSocketEvents.ChannelBookmarkSorted, {
bookmarks: JsonEncodedValue<ChannelBookmarkWithFileInfo[]>;
}>;
// Channel access control messages
export type ChannelAccessControlUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelAccessControlUpdated, {
channel: JsonEncodedValue<Channel>;
}>;
// Emitted after a system-scoped (TypePermission) permission policy is created, updated,
// deleted, or toggled active. No payload — the policy is global so the client reconciles
// the currently visible channel only.
export type PermissionPolicyUpdated = BaseWebSocketMessage<WebSocketEvents.PermissionPolicyUpdated, Record<string, never>>;
export type TeamAccessControlUpdated = BaseWebSocketMessage<WebSocketEvents.TeamAccessControlUpdated, {
team: JsonEncodedValue<TeamType>;
}>;
// Discoverable Private Channels — join request messages
//
// Both events carry the request row as a JSON-encoded string under `request`,
// plus the unencoded channel id under `channel_id` so consumers can route
// without parsing the body when they only need to invalidate caches.
export type ChannelJoinRequestCreated = BaseWebSocketMessage<WebSocketEvents.ChannelJoinRequestCreated, {
request: JsonEncodedValue<ChannelJoinRequest>;
channel_id: string;
}>;
export type ChannelJoinRequestUpdated = BaseWebSocketMessage<WebSocketEvents.ChannelJoinRequestUpdated, {
request: JsonEncodedValue<ChannelJoinRequest>;
channel_id: string;
}>;
// Team and team member messages
export type Team =
BaseWebSocketMessage<WebSocketEvents.UpdateTeam | WebSocketEvents.DeleteTeam | WebSocketEvents.RestoreTeam, {
team: JsonEncodedValue<TeamType>;
}>;
export type UserAddedToTeam = BaseWebSocketMessage<WebSocketEvents.AddedToTeam, {
team_id: string;
user_id: string;
}>;
export type UserRemovedFromTeam = BaseWebSocketMessage<WebSocketEvents.LeaveTeam, {
user_id: string;
team_id: string;
}>;
export type UpdateTeamScheme = BaseWebSocketMessage<WebSocketEvents.UpdateTeamScheme, {
team: JsonEncodedValue<Team>;
}>;
export type TeamMemberRoleUpdated = BaseWebSocketMessage<WebSocketEvents.MemberRoleUpdated, {
member: JsonEncodedValue<TeamMembership>;
}>;
// User and status messages
export type NewUser = BaseWebSocketMessage<WebSocketEvents.NewUser, {
user_id: string;
}>;
export type UserUpdated = BaseWebSocketMessage<WebSocketEvents.UserUpdated, {
/** This user may be missing sensitive data based on if the recipient is that user or an admin. */
user: UserProfile;
}>;
export type UserActivationStatusChanged = BaseWebSocketMessage<WebSocketEvents.UserActivationStatusChange>;
export type UserRoleUpdated = BaseWebSocketMessage<WebSocketEvents.UserRoleUpdated, {
user_id: string;
roles: string;
}>;
export type StatusChanged = BaseWebSocketMessage<WebSocketEvents.StatusChange, {
status: UserStatus['status'];
user_id: string;
}>;
export type Typing = BaseWebSocketMessage<WebSocketEvents.Typing, {
parent_id: string;
user_id: string;
}>;
// Group-related messages
export type ReceivedGroup = BaseWebSocketMessage<WebSocketEvents.ReceivedGroup, {
group: JsonEncodedValue<Group>;
}>;
export type GroupAssociatedToTeam =
BaseWebSocketMessage<WebSocketEvents.ReceivedGroupAssociatedToTeam | WebSocketEvents.ReceivedGroupNotAssociatedToTeam, {
group_id: string;
}>;
export type GroupAssociatedToChannel =
BaseWebSocketMessage<WebSocketEvents.ReceivedGroupAssociatedToChannel | WebSocketEvents.ReceivedGroupNotAssociatedToChannel, {
group_id: string;
}>;
export type GroupMember =
BaseWebSocketMessage<WebSocketEvents.GroupMemberAdded | WebSocketEvents.GroupMemberDeleted, {
group_member: JsonEncodedValue<GroupMemberType>;
}>;
// Preference messages
export type PreferenceChanged = BaseWebSocketMessage<WebSocketEvents.PreferenceChanged, {
preference: JsonEncodedValue<PreferenceType>;
}>;
export type PreferencesChanged =
BaseWebSocketMessage<WebSocketEvents.PreferencesChanged | WebSocketEvents.PreferencesDeleted, {
preferences: JsonEncodedValue<PreferenceType[]>;
}>;
// Channel sidebar messages
export type SidebarCategoryCreated = BaseWebSocketMessage<WebSocketEvents.SidebarCategoryCreated, {
category_id: string;
}>;
export type SidebarCategoryUpdated = BaseWebSocketMessage<WebSocketEvents.SidebarCategoryUpdated, {
updatedCategories: JsonEncodedValue<ChannelCategory[]>;
}>;
export type SidebarCategoryDeleted = BaseWebSocketMessage<WebSocketEvents.SidebarCategoryDeleted, {
category_id: string;
}>;
export type SidebarCategoryOrderUpdated = BaseWebSocketMessage<WebSocketEvents.SidebarCategoryOrderUpdated, {
order: string[];
}>;
// Property system messages
export type PropertyFieldCreated = BaseWebSocketMessage<WebSocketEvents.PropertyFieldCreated, {
property_field: JsonEncodedValue<PropertyField>;
object_type: string;
}>;
export type PropertyFieldUpdated = BaseWebSocketMessage<WebSocketEvents.PropertyFieldUpdated, {
property_field: JsonEncodedValue<PropertyField>;
object_type: string;
}>;
export type PropertyFieldDeleted = BaseWebSocketMessage<WebSocketEvents.PropertyFieldDeleted, {
field_id: string;
object_type: string;
}>;
export type PropertyValuesUpdated = BaseWebSocketMessage<WebSocketEvents.PropertyValuesUpdated, {
object_type?: string;
target_id?: string;
field_id?: string;
values: JsonEncodedValue<Array<PropertyValue<unknown>>>;
}>;
// Emoji messages
export type EmojiAdded = BaseWebSocketMessage<WebSocketEvents.EmojiAdded, {
emoji: JsonEncodedValue<Omit<CustomEmoji, 'category'>>;
}>;
// Role messages
export type RoleUpdated = BaseWebSocketMessage<WebSocketEvents.RoleUpdated, {
role: JsonEncodedValue<Role>;
}>;
// Configuration and license messages
export type ConfigChanged = BaseWebSocketMessage<WebSocketEvents.ConfigChanged, {
config: ClientConfig;
}>;
export type GuestsDeactivated = BaseWebSocketMessage<WebSocketEvents.GuestsDeactivated>;
export type LicenseChanged = BaseWebSocketMessage<WebSocketEvents.LicenseChanged, {
license: ClientLicense;
}>;
export type CloudSubscriptionChanged = BaseWebSocketMessage<WebSocketEvents.CloudSubscriptionChanged, {
limits?: Limits;
subscription: Subscription;
}>;
export type FirstAdminVisitMarketplaceStatusReceived =
BaseWebSocketMessage<WebSocketEvents.FirstAdminVisitMarketplaceStatusReceived, {
firstAdminVisitMarketplaceStatus: JsonEncodedValue<boolean>;
}>;
export type HostedCustomerSignupProgressUpdated =
BaseWebSocketMessage<WebSocketEvents.HostedCustomerSignupProgressUpdated, {
progress: string;
}>;
// Custom properties messages
export type CPAFieldCreated = BaseWebSocketMessage<WebSocketEvents.CPAFieldCreated, {
field: PropertyField;
}>;
export type CPAFieldUpdated = BaseWebSocketMessage<WebSocketEvents.CPAFieldUpdated, {
field: PropertyField;
delete_values: boolean;
}>;
export type CPAFieldDeleted = BaseWebSocketMessage<WebSocketEvents.CPAFieldDeleted, {
field_id: string;
}>;
export type CPAValuesUpdated = BaseWebSocketMessage<WebSocketEvents.CPAValuesUpdated, {
user_id: string;
values: Array<PropertyValue<unknown>>;
}>;
// Content flagging messages
export type ContentFlaggingReportValueUpdated =
BaseWebSocketMessage<WebSocketEvents.ContentFlaggingReportValueUpdated, {
property_values: JsonEncodedValue<Array<PropertyValue<unknown>>>;
target_id: string;
}>;
// Job messages
export type JobUpdated = BaseWebSocketMessage<WebSocketEvents.JobUpdated, {
job: JsonEncodedValue<Job>;
}>;
// Recap messages
export type RecapUpdated = BaseWebSocketMessage<WebSocketEvents.RecapUpdated, {
recap_id: string;
}>;
// Post translation messages
export type PostTranslationUpdated = BaseWebSocketMessage<WebSocketEvents.PostTranslationUpdated, {
object_id: string;
translations: Record<string, {
state: 'ready' | 'skipped' | 'processing' | 'unavailable';
translation?: string;
translation_type?: string;
src_lang?: string;
}>;
}>;
// Plugin and integration messages
export type Plugin = BaseWebSocketMessage<WebSocketEvents.PluginEnabled | WebSocketEvents.PluginDisabled, {
manifest: PluginManifest;
}>;
// Signals admin clients to refetch the full plugin statuses on demand. plugin_statuses is always
// an empty array: it carries no data, but is retained (rather than omitted) so clients that call
// array methods on it don't break.
export type PluginStatusesChanged = BaseWebSocketMessage<WebSocketEvents.PluginStatusesChanged, {
plugin_statuses: never[];
}>;
export type OpenDialog = BaseWebSocketMessage<WebSocketEvents.OpenDialog, {
dialog: JsonEncodedValue<OpenDialogRequest>;
}>;
export type FileDownloadRejected = BaseWebSocketMessage<WebSocketEvents.FileDownloadRejected, {
file_id: string;
file_name: string;
rejection_reason: string;
channel_id: string;
post_id: string;
download_type: string;
}>;
export type FileUploadRejected = BaseWebSocketMessage<WebSocketEvents.FileUploadRejected, {
file_name: string;
rejection_reason: string;
channel_id: string;
}>;
export type ShowToast = BaseWebSocketMessage<WebSocketEvents.ShowToast, {
message: string;
position?: string;
}>;
/**
* Unknown is used for WebSocket messages which don't come from Mattermost itself. It's primarily intended for use
* by plugins.
*/
export type Unknown = BaseWebSocketMessage<string, unknown>;

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es2022",
"declaration": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react",
"outDir": "./lib",
"rootDir": "./src",
"composite": true,
},
"include": [
"./src/**/*"
],
"exclude": [
"**/*.test.*"
],
"references": [
{"path": "../types"}
]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es2022",
"declaration": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react",
"preserveConstEnums": true,
"outDir": "./lib",
"rootDir": "./src",
"composite": true,
},
"include": [
"./src/**/*"
],
"references": [
{"path": "../types"}
]
}

View File

@@ -0,0 +1,30 @@
# CLAUDE: `platform/components/` (`@mattermost/components`)
## Purpose
- Cross-product React components (GenericModal, tour tips, loaders, hooks) shared by Channels, Boards, Playbooks, and plugins.
- Ensures consistent UX, theming, and accessibility across Mattermost surfaces.
## Implementation Guidelines
- Follow `webapp/STYLE_GUIDE.md → React Component Structure`, `Styling & Theming`, `Accessibility`.
- Components must be framework-agnostic: accept data/handlers via props, avoid direct Redux or routing dependencies.
- Keep styles local via SCSS modules or styled components already used in this package; expose classNames for host overrides when necessary.
- Include Storybook/README snippets where helpful (see package README).
## Testing
- All components need RTL tests (`*.test.tsx`) demonstrating accessibility behaviors.
- Provide test utils (`testUtils.tsx`) for consumers who need to mock these components.
## Key Modules
- `generic_modal/` – baseline modal with focus trapping and keyboard handling.
- `tour_tip/` – coach marks with backdrop management.
- `skeleton_loader/`, `pulsating_dot/` – loading indicators.
- `hooks/` – shared hooks like `useFocusTrap`.
## Release Notes
- Breaking props or behavior requires updating `platform/components/README.md` and notifying dependent teams.
## References
- `generic_modal/generic_modal.tsx`, `tour_tip/tour_tip.tsx`, `hooks/useFocusTrap.test.tsx`.

View File

@@ -0,0 +1,24 @@
# Mattermost Components
The goal of this package is to be a place where components common to all products can be shared.
Currently a work in progress. Next steps involve implementing webpack module federation in the webapp and locking down how the development experience will work for the webapp multi product architecture.
## Usage
Coming soon with multi product architecture.
## Compilation
Building is done using rollup. This must be done so the webapp webpack will pick up the changes. (multi product development experience coming soon)
```bash
npm run build
```
or from the root of the webapp with
```bash
npm run build --workspace=platform/components
```

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const config = {
presets: [
['@babel/preset-env', {
targets: {
chrome: 66,
firefox: 60,
edge: 42,
safari: 12,
},
modules: false,
corejs: {
version: '3.26',
proposals: true,
},
debug: false,
shippedProposals: true,
useBuiltIns: 'usage',
}],
['@babel/preset-react', {
useBuiltIns: true,
}],
['@babel/typescript', {
allExtensions: true,
isTSX: true,
}],
],
plugins: [
'@babel/plugin-transform-runtime',
[
'babel-plugin-styled-components',
{
ssr: false,
fileName: false,
},
],
[
'formatjs',
{
idInterpolationPattern: '[sha512:contenthash:base64:6]',
ast: true,
},
],
],
};
config.env = {
test: {
presets: config.presets,
plugins: config.plugins,
},
};
config.env.test.presets[0][1].modules = 'auto';
module.exports = config;

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import eslintPlugin from '@mattermost/eslint-plugin';
export default [
...eslintPlugin.configs.base,
];

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/** @type {import('jest').Config} */
const config = {
moduleDirectories: ['src', 'node_modules'],
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['/node_modules/', '/.rollup.cache/', '/dist/'],
clearMocks: true,
moduleNameMapper: {
'^@mattermost/(components)$': '<rootDir>/../platform/$1/src',
'^@mattermost/(client)$': '<rootDir>/../platform/$1/src',
'^@mattermost/(types)/(.*)$': '<rootDir>/../platform/$1/src/$2',
'^.+\\.(css|scss)$': 'identity-obj-proxy',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
setupFilesAfterEnv: ['<rootDir>/setup_jest.ts'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
],
coveragePathIgnorePatterns: [
'/node_modules/',
'/.rollup.cache/',
'/dist/',
],
coverageReporters: ['json', 'lcov', 'text-summary'],
};
module.exports = config;

View File

@@ -0,0 +1,57 @@
{
"name": "@mattermost/components",
"version": "12.0.0",
"private": true,
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"styles": "dist/index.esm.css",
"scripts": {
"build": "rollup -c",
"check": "eslint ./src eslint.config.mjs --quiet",
"check-types": "tsc -b",
"fix": "eslint ./src eslint.config.mjs --quiet --fix",
"run": "rollup -c --watch",
"test": "cross-env TZ=Etc/UTC jest",
"test:updatesnapshot": "cross-env TZ=Etc/UTC jest --updateSnapshot",
"test-ci": "cross-env TZ=Etc/UTC jest --ci --forceExit --detectOpenHandles --maxWorkers=100% --logHeapUsage",
"clean": "rm -rf dist node_modules tsconfig.tsbuildinfo .rollup.cache"
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.17.0",
"@rollup/plugin-babel": "^5.3.1",
"@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-typescript": "11.1.6",
"@testing-library/jest-dom": "6.8.0",
"@types/lodash": "^4.14.178",
"@types/react": "19.2.18",
"@types/react-bootstrap": "^0.32.35",
"@types/react-dom": "19.2.6",
"@types/scheduler": "0.16.8",
"@types/shallow-equals": "^1.0.0",
"@types/styled-components": "^5.1.36",
"babel-plugin-styled-components": "2.1.4",
"jest-environment-jsdom": "30.1.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"rollup": "^2.75.7",
"rollup-plugin-scss": "4.0.0",
"rollup-plugin-ts": "^2.0.5",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@babel/runtime-corejs3": "^7.17.8",
"@mattermost/compass-icons": "0.1.61",
"@mattermost/shared": "12.0.0",
"classnames": "^2.3.1",
"lodash": "^4.17.21",
"react-intl": "*",
"shallow-equals": "^1.0.0",
"tippy.js": "^6.3.7"
},
"dependencies": {
"@tippyjs/react": "4.2.6",
"react-bootstrap": "github:mattermost/react-bootstrap#d693ec8082954f0e6d3e826ca6161979b29001cd",
"styled-components": "5.3.7"
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import scss from 'rollup-plugin-scss';
import packagejson from './package.json';
const externals = [
...Object.keys(packagejson.dependencies || {}),
...Object.keys(packagejson.peerDependencies || {}),
'lodash/throttle',
'react',
// react-dom is only a devDependency here, so without this it gets bundled into dist and the
// web app ends up running two copies of the renderer.
'react-dom',
'mattermost-redux',
'reselect',
];
export default [
{
input: 'src/index.tsx',
output: [
{
sourcemap: true,
file: packagejson.module,
format: 'es',
globals: {'styled-components': 'styled'},
},
],
plugins: [
scss({
fileName: 'index.esm.css',
outputToFilesystem: true,
}),
resolve({
browser: true,
extensions: ['.ts', '.tsx'],
}),
commonjs(),
typescript({
outputToFilesystem: true,
}),
],
external: externals,
watch: {
clearScreen: false,
},
},
];

View File

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MutableRefObject} from 'react';
import {useEffect} from 'react';
export function useClickOutsideRef(ref: MutableRefObject<HTMLElement | null>, handler: (event: MouseEvent) => void): void {
useEffect(() => {
function onMouseDown(event: MouseEvent) {
const target = event.target as any;
if (ref.current && target instanceof Node && !ref.current.contains(target)) {
handler(event);
}
}
// Bind the event listener
document.addEventListener('mousedown', onMouseDown);
return () => {
// Unbind the event listener on clean up
document.removeEventListener('mousedown', onMouseDown);
};
}, [ref, handler]);
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useRef, useState, useMemo} from 'react';
export function useElementAvailable(
elementIds: string[],
intervalMS = 250,
): boolean {
const checkAvailableInterval = useRef<NodeJS.Timeout | null>(null);
const [available, setAvailable] = useState(false);
useEffect(() => {
if (available) {
if (checkAvailableInterval.current) {
clearInterval(checkAvailableInterval.current);
checkAvailableInterval.current = null;
}
return;
} else if (checkAvailableInterval.current) {
return;
}
checkAvailableInterval.current = setInterval(() => {
if (elementIds.every((x) => document.getElementById(x))) {
setAvailable(true);
if (checkAvailableInterval.current) {
clearInterval(checkAvailableInterval.current);
checkAvailableInterval.current = null;
}
}
}, intervalMS);
}, []);
return useMemo(() => available, [available]);
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
export const useFollowElementDimensions = (elementId: string): DOMRectReadOnly => {
const [dimensions, setDimensions] = useState(new DOMRect());
useEffect(() => {
const element = document.getElementById(elementId);
if (!element) {
return undefined;
}
const observer = new ResizeObserver((entries) => {
if (entries.length > 0) {
setDimensions(entries[0].contentRect);
}
});
observer.observe(element);
return () => {
observer.unobserve(element);
};
}, [elementId]);
return dimensions;
};

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import throttle from 'lodash/throttle';
import {useLayoutEffect, useMemo, useState} from 'react';
import {useElementAvailable} from './useElementAvailable';
export type Coords = {
x?: string;
y?: string;
};
export type Props = Coords & {
width: string;
height: string;
};
type PunchOutOffset = {
x: number;
y: number;
width: number;
height: number;
};
export const useMeasurePunchouts = (elementIds: string[], additionalDeps: any[], offset?: PunchOutOffset): Props | null => {
const elementsAvailable = useElementAvailable(elementIds);
const [size, setSize] = useState({x: window.innerWidth, y: window.innerHeight});
const updateSize = throttle(() => {
setSize({x: window.innerWidth, y: window.innerHeight});
}, 100, {trailing: true});
useLayoutEffect(() => {
window.addEventListener('resize', updateSize);
return () =>
window.removeEventListener('resize', updateSize);
}, []);
const channelPunchout = useMemo(() => {
let minX = Number.MAX_SAFE_INTEGER;
let minY = Number.MAX_SAFE_INTEGER;
let maxX = Number.MIN_SAFE_INTEGER;
let maxY = Number.MIN_SAFE_INTEGER;
for (let i = 0; i < elementIds.length; i++) {
const rectangle = document.getElementById(elementIds[i])?.getBoundingClientRect();
if (!rectangle) {
return null;
}
if (rectangle.x < minX) {
minX = rectangle.x;
}
if (rectangle.y < minY) {
minY = rectangle.y;
}
if (rectangle.x + rectangle.width > maxX) {
maxX = rectangle.x + rectangle.width;
}
if (rectangle.y + rectangle.height > maxY) {
maxY = rectangle.y + rectangle.height;
}
}
return {
x: `${minX + (offset ? offset.x : 0)}px`,
y: `${minY + (offset ? offset.y : 0)}px`,
width: `${(maxX - minX) + (offset ? offset.width : 0)}px`,
height: `${(maxY - minY) + (offset ? offset.height : 0)}px`,
};
}, [...elementIds, ...additionalDeps, size, elementsAvailable]);
return channelPunchout;
};

View File

@@ -0,0 +1,170 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`LegacyGenericModal/FooterPagination should render default 1`] = `
{
"asFragment": [Function],
"baseElement": <body>
<div>
<div
class="footer-pagination"
>
<div
class="footer-pagination__legend"
/>
<div
class="footer-pagination__button-container"
>
<button
class="footer-pagination__button-container__button disabled"
disabled=""
type="button"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"
/>
</svg>
<span>
Previous
</span>
</button>
<button
class="footer-pagination__button-container__button disabled"
disabled=""
type="button"
>
<span>
Next
</span>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"
/>
</svg>
</button>
</div>
</div>
</div>
</body>,
"container": <div>
<div
class="footer-pagination"
>
<div
class="footer-pagination__legend"
/>
<div
class="footer-pagination__button-container"
>
<button
class="footer-pagination__button-container__button disabled"
disabled=""
type="button"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"
/>
</svg>
<span>
Previous
</span>
</button>
<button
class="footer-pagination__button-container__button disabled"
disabled=""
type="button"
>
<span>
Next
</span>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"
/>
</svg>
</button>
</div>
</div>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View File

@@ -0,0 +1,83 @@
// These mixins are from `src/sass/utils/_mixins.scss in the web app. They've been copied because rollup-plugin-sass
// that we use doesn't properly support @use.
@mixin button-small {
height: 32px;
padding: 0 16px;
font-size: 12px;
line-height: 10px;
}
@mixin tertiary-button {
display: inline-flex;
align-items: center;
border: 0;
border-radius: 4px;
background: rgba(var(--button-bg-rgb), 0.08);
color: var(--button-bg);
font-weight: 600;
transition: all 0.15s ease-out;
&:disabled {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.32);
}
&:hover:enabled {
background: rgba(var(--button-bg-rgb), 0.12);
}
&:active:enabled {
background: rgba(var(--button-bg-rgb), 0.16);
}
i {
display: flex;
font-size: 18px;
&:first-child::before {
margin: 0 7px 0 0;
}
&:last-child::before {
margin: 0 0 0 7px;
}
}
}
.footer-pagination {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
&__legend {
display: flex;
flex: 1;
align-items: center;
justify-content: flex-start;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
&__button-container {
display: flex;
align-items: center;
justify-content: center;
&__button {
@include tertiary-button;
@include button-small;
&:not(:first-child) {
margin-left: 8px;
}
> :not(:first-child) {
margin-left: 5px;
}
}
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, screen} from '@testing-library/react';
import React from 'react';
import {FooterPagination} from './footer_pagination';
import {wrapIntl} from '../testUtils';
describe('LegacyGenericModal/FooterPagination', () => {
const baseProps = {
page: 0,
total: 0,
itemsPerPage: 0,
onNextPage: jest.fn(),
onPreviousPage: jest.fn(),
};
test('should render default', () => {
const wrapper = render(wrapIntl(<FooterPagination {...baseProps}/>));
expect(wrapper).toMatchSnapshot();
});
test('should render pagination legend', () => {
const props = {
...baseProps,
page: 0,
total: 17,
itemsPerPage: 10,
};
render(wrapIntl(<FooterPagination {...props}/>));
expect(screen.getByText('Showing 1-10 of 17')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {useIntl} from 'react-intl';
import {ChevronLeftIcon, ChevronRightIcon} from '@mattermost/compass-icons/components';
import './footer_pagination.scss';
const BUTTON_ICON_SIZE = 16;
type Props = {
page: number;
total: number;
itemsPerPage: number;
onNextPage: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onPreviousPage: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
};
export const FooterPagination = ({
page,
total,
itemsPerPage,
onNextPage,
onPreviousPage,
}: Props) => {
const {formatMessage} = useIntl();
const startCount = page * itemsPerPage;
const endCount = Math.min(startCount + itemsPerPage, total);
const totalPages = Math.trunc((total - 1) / itemsPerPage);
const prevDisabled = page <= 0;
const nextDisabled = page >= totalPages;
return (
<div className='footer-pagination'>
<div className='footer-pagination__legend'>
{Boolean(total) && (
formatMessage(
{
id: 'footer_pagination.count',
defaultMessage: 'Showing {startCount, number}-{endCount, number} of {total, number}',
},
{
startCount: startCount + 1,
endCount,
total,
},
)
)}
</div>
<div className='footer-pagination__button-container'>
<button
type='button'
className={classNames(
'footer-pagination__button-container__button',
{disabled: prevDisabled},
)}
onClick={onPreviousPage}
disabled={prevDisabled}
>
<ChevronLeftIcon size={BUTTON_ICON_SIZE}/>
<span>
{formatMessage({
id: 'footer_pagination.prev',
defaultMessage: 'Previous',
})}
</span>
</button>
<button
type='button'
className={classNames(
'footer-pagination__button-container__button',
{disabled: nextDisabled},
)}
onClick={onNextPage}
disabled={nextDisabled}
>
<span>
{formatMessage({
id: 'footer_pagination.next',
defaultMessage: 'Next',
})}
</span>
<ChevronRightIcon size={BUTTON_ICON_SIZE}/>
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,135 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`GenericModal should match snapshot for base case 1`] = `
{
"asFragment": [Function],
"baseElement": <body
class="modal-open"
style="padding-right: 0px;"
>
<div
aria-hidden="true"
/>
<div
class="fade modal-backdrop in"
/>
<div
aria-labelledby="genericModalLabel"
aria-modal="true"
class="fade in modal"
id="genericModal"
role="dialog"
style="display: block; padding-right: 0px;"
tabindex="-1"
>
<div
class="GenericModal__location--center a11y__modal GenericModal modal-dialog"
>
<div
class="modal-content"
role="document"
>
<div
class="GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher"
>
<div
class="modal-header"
>
<div
class="GenericModal__header__text_container"
/>
<button
aria-label="Close"
class="close"
type="button"
>
<span
aria-hidden="true"
>
×
</span>
<span
class="sr-only"
>
Close
</span>
</button>
</div>
<div
class="modal-body"
>
<div
class="GenericModal__header"
>
<h1
class="modal-title"
id="genericModalLabel"
>
Modal Header Text
</h1>
</div>
<div
class="GenericModal__body padding"
/>
</div>
</div>
</div>
</div>
</div>
</body>,
"container": <div
aria-hidden="true"
/>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View File

@@ -0,0 +1,178 @@
.console__body .modal .GenericModal,
.app__body .modal .GenericModal {
&__location--top {
margin-top: 5vh;
}
&__location--center {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin-top: 0;
padding-bottom: 24px;
.modal-content {
flex: 1 1 auto;
}
}
&__location--bottom {
margin-top: calc(50vh + 240px);
}
&.modal-overflow {
.modal-body {
overflow: visible;
}
}
.no-header__img {
margin-top: -40px;
}
.modal-header {
.GenericModal__header__text_container {
margin-top: 8px;
width: 100%;
.modal-subheading-container {
color: rgba(var(--center-channel-color-rgb), 0.75);
}
}
div#genericModalSubheading {
font-size: 12px;
margin-block: 10px;
}
}
.modal-body {
padding: 0;
&.divider {
border-top: var(--border-light);
}
.form-control {
height: 40px;
box-sizing: border-box;
border: var(--border-default);
border-radius: 4px;
&.form-control--lg {
height: 48px;
font-size: 16px;
padding-left: 16px;
padding-right: 16px;
& + .input-clear {
top: 17px;
right: 16px;
}
}
&:focus {
border-color: var(--button-bg);
box-shadow: 0 0 0 1px var(--button-bg);
}
&.has-error {
border-color: var(--error-text);
}
}
.MaxLengthInput {
&.form-control.has-error {
padding-right: 66px;
}
&__validation {
position: absolute;
top: 12px;
right: 56px;
color: var(--error-text);
font-size: 14px;
font-style: normal;
font-weight: normal;
line-height: 16px;
}
}
.input-wrapper {
position: relative;
}
.input-clear {
top: 12px;
right: 12px;
width: 16px;
height: 16px;
color: var(--center-channel-color);
font-size: 18px;
font-style: normal;
font-weight: normal;
line-height: 16px;
opacity: 0.48;
}
}
&.GenericModal__compassDesign {
.modal-content {
max-height: 90vh;
.modal-body {
.GenericModal__body {
padding: 0;
&.padding {
padding: 8px 32px 0;
}
}
}
.genericModalError {
display: flex;
box-sizing: border-box;
flex: 1;
margin: 0 32px;
padding: 14px 16px;
border: 1px solid rgba(var(--dnd-indicator-rgb), 0.16);
margin-bottom: 24px;
background: rgba(var(--dnd-indicator-rgb), 0.08);
border-radius: var(--radius-s);
span {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
line-height: 24px;
}
i {
margin-right: 8px;
color: var(--error-text);
font-size: 24px;
line-height: 24px;
&::before {
margin: 0;
}
}
}
}
}
@media screen and (max-width: 640px) {
margin: 0 !important;
.modal-header {
box-shadow: var(--elevation-2);
}
}
&__wrapper-enter-key-press-catcher {
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,367 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, render, screen, waitFor} from '@testing-library/react';
import React from 'react';
import {GenericModal} from './generic_modal';
import {wrapIntl} from '../testUtils';
describe('GenericModal', () => {
const baseProps = {
onExited: jest.fn(),
modalHeaderText: 'Modal Header Text',
children: <></>,
};
test('should match snapshot for base case', () => {
const wrapper = render(
wrapIntl(<GenericModal {...baseProps}/>),
);
expect(wrapper).toMatchSnapshot();
});
test('should have confirm and cancels buttons when handlers are passed for both buttons', () => {
const props = {
...baseProps,
handleConfirm: jest.fn(),
handleCancel: jest.fn(),
};
render(
wrapIntl(<GenericModal {...props}/>),
);
expect(screen.getByText('Confirm')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
test('calls onExited when modal exits', async () => {
const onExitedMock = jest.fn();
const props = {
...baseProps,
onExited: onExitedMock,
};
render(
wrapIntl(<GenericModal {...props}/>),
);
// Find and click the close button to trigger modal exit
const closeButton = screen.getByLabelText('Close');
act(() => {
closeButton.click();
});
// Wait for onExited to be called
await waitFor(() => {
expect(onExitedMock).toHaveBeenCalled();
});
});
test('does not throw if onExited is undefined', async () => {
// Create props without onExited
const {onExited, ...propsWithoutOnExited} = baseProps; // eslint-disable-line @typescript-eslint/no-unused-vars
const props = {
...propsWithoutOnExited,
onHide: jest.fn(), // Ensure onHide is provided since it's mandatory
};
// This should not throw
render(
wrapIntl(<GenericModal {...props}/>),
);
// Find and click the close button to trigger modal exit
const closeButton = screen.getByLabelText('Close');
// This should not throw
expect(() => {
act(() => {
closeButton.click();
});
}).not.toThrow();
});
test('calls onEntered when modal enters', async () => {
const onEnteredMock = jest.fn();
const props = {
...baseProps,
onEntered: onEnteredMock,
show: false, // Start with modal hidden
};
const {rerender} = render(
wrapIntl(<GenericModal {...props}/>),
);
// Show the modal
rerender(
wrapIntl(<GenericModal {...props} show={true}/>),
);
// Wait for onEntered to be called
await waitFor(() => {
expect(onEnteredMock).toHaveBeenCalled();
});
});
test('does not throw if onEntered is undefined', async () => {
// Create props without onEntered
const props = {
...baseProps,
show: false, // Start with modal hidden
};
const {rerender} = render(
wrapIntl(<GenericModal {...props}/>),
);
// This should not throw
expect(() => {
rerender(
wrapIntl(<GenericModal {...props} show={true}/>),
);
}).not.toThrow();
});
test('calls onHide when modal is closed', async () => {
const onHideMock = jest.fn();
const props = {
...baseProps,
onHide: onHideMock,
};
render(
wrapIntl(<GenericModal {...props}/>),
);
// Find and click the close button to trigger modal exit
const closeButton = screen.getByLabelText('Close');
act(() => {
closeButton.click();
});
// Wait for onHide to be called
await waitFor(() => {
expect(onHideMock).toHaveBeenCalled();
});
});
describe('preventClose functionality', () => {
test('preventClose=true prevents modal from closing when close button is clicked', async () => {
const onHideMock = jest.fn();
const onExitedMock = jest.fn();
const props = {
...baseProps,
onHide: onHideMock,
onExited: onExitedMock,
preventClose: true,
show: true,
};
render(
wrapIntl(<GenericModal {...props}/>),
);
const modal = screen.getByRole('dialog');
expect(modal).toBeInTheDocument();
expect(modal).toHaveClass('in'); // Modal is visible
// Find and click the close button
const closeButton = screen.getByLabelText('Close');
closeButton.click();
// onHide should still be called (for error handling, etc.)
await waitFor(() => {
expect(onHideMock).toHaveBeenCalled();
});
// But modal should still be visible (preventClose prevents internal state change)
expect(modal).toBeInTheDocument();
expect(modal).toHaveClass('in'); // Still visible
// onExited should NOT be called since modal didn't actually close
expect(onExitedMock).not.toHaveBeenCalled();
});
test('preventClose=true prevents modal from closing when cancel button is clicked', async () => {
const onHideMock = jest.fn();
const handleCancelMock = jest.fn();
const props = {
...baseProps,
onHide: onHideMock,
handleCancel: handleCancelMock,
preventClose: true,
show: true,
autoCloseOnCancelButton: true, // Default behavior
};
render(
wrapIntl(<GenericModal {...props}/>),
);
const modal = screen.getByRole('dialog');
expect(modal).toHaveClass('in');
// Find and click the cancel button
const cancelButton = screen.getByText('Cancel');
cancelButton.click();
// Both callbacks should be called
await waitFor(() => {
expect(handleCancelMock).toHaveBeenCalled();
expect(onHideMock).toHaveBeenCalled();
});
// But modal should still be visible due to preventClose
expect(modal).toHaveClass('in');
});
test('preventClose=true prevents modal from closing when confirm button is clicked', async () => {
const onHideMock = jest.fn();
const handleConfirmMock = jest.fn();
const props = {
...baseProps,
onHide: onHideMock,
handleConfirm: handleConfirmMock,
preventClose: true,
show: true,
autoCloseOnConfirmButton: true, // Default behavior
};
render(
wrapIntl(<GenericModal {...props}/>),
);
const modal = screen.getByRole('dialog');
expect(modal).toHaveClass('in');
// Find and click the confirm button
const confirmButton = screen.getByText('Confirm');
confirmButton.click();
// Both callbacks should be called
await waitFor(() => {
expect(handleConfirmMock).toHaveBeenCalled();
expect(onHideMock).toHaveBeenCalled();
});
// But modal should still be visible due to preventClose
expect(modal).toHaveClass('in');
});
test('preventClose=false allows normal modal closing via close button', async () => {
const onHideMock = jest.fn();
const onExitedMock = jest.fn();
const props = {
...baseProps,
onHide: onHideMock,
onExited: onExitedMock,
preventClose: false,
show: true,
};
render(
wrapIntl(<GenericModal {...props}/>),
);
const modal = screen.getByRole('dialog');
expect(modal).toHaveClass('in');
// Find and click the close button
const closeButton = screen.getByLabelText('Close');
act(() => {
closeButton.click();
});
// onHide should be called
await waitFor(() => {
expect(onHideMock).toHaveBeenCalled();
});
// Modal should close (lose 'in' class)
await waitFor(() => {
expect(modal).not.toHaveClass('in');
});
});
test('preventClose=false allows normal modal closing via cancel button', async () => {
const onHideMock = jest.fn();
const handleCancelMock = jest.fn();
const props = {
...baseProps,
onHide: onHideMock,
handleCancel: handleCancelMock,
preventClose: false,
show: true,
};
render(
wrapIntl(<GenericModal {...props}/>),
);
const modal = screen.getByRole('dialog');
expect(modal).toHaveClass('in');
// Find and click the cancel button
const cancelButton = screen.getByText('Cancel');
act(() => {
cancelButton.click();
});
// Both callbacks should be called
await waitFor(() => {
expect(handleCancelMock).toHaveBeenCalled();
expect(onHideMock).toHaveBeenCalled();
});
// Modal should close normally
await waitFor(() => {
expect(modal).not.toHaveClass('in');
});
});
test('preventClose state can be toggled dynamically', async () => {
const onHideMock = jest.fn();
const props = {
...baseProps,
onHide: onHideMock,
preventClose: true,
show: true,
};
const {rerender} = render(
wrapIntl(<GenericModal {...props}/>),
);
const modal = screen.getByRole('dialog');
const closeButton = screen.getByLabelText('Close');
// First click - should be prevented
closeButton.click();
await waitFor(() => {
expect(onHideMock).toHaveBeenCalledTimes(1);
});
expect(modal).toHaveClass('in'); // Still visible
// Change preventClose to false
rerender(
wrapIntl(<GenericModal {...props} preventClose={false}/>),
);
// Second click - should close modal
act(() => {
closeButton.click();
});
await waitFor(() => {
expect(onHideMock).toHaveBeenCalledTimes(2);
});
// Modal should close now
await waitFor(() => {
expect(modal).not.toHaveClass('in');
});
});
});
});

View File

@@ -0,0 +1,385 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useState, useEffect, useCallback, useRef} from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage, useIntl} from 'react-intl';
import {Button, type ButtonVariant} from '@mattermost/shared/components/button';
import {useFocusTrap} from '../hooks/useFocusTrap';
import {useStackedModal} from '../hooks/useStackedModal';
import './generic_modal.scss';
export type ModalLocation = 'top' | 'center' | 'bottom';
export type Props = {
className?: string;
onExited?: () => void;
onEntered?: () => void;
onHide?: () => void;
preventClose?: boolean;
modalHeaderText?: React.ReactNode;
modalHeaderTextId?: string;
modalSubheaderText?: React.ReactNode;
show?: boolean;
handleCancel?: () => void;
handleConfirm?: () => void;
handleEnterKeyPress?: () => void;
handleKeydown?: (event?: React.KeyboardEvent<HTMLDivElement>) => void;
confirmButtonText?: React.ReactNode;
confirmButtonVariant?: ButtonVariant;
cancelButtonText?: React.ReactNode;
isConfirmDisabled?: boolean;
id?: string;
autoCloseOnCancelButton?: boolean;
autoCloseOnConfirmButton?: boolean;
enforceFocus?: boolean;
container?: React.ReactNode | readonly React.ReactNode[];
ariaLabel?: string;
ariaLabelledby?: string;
errorText?: string | React.ReactNode;
compassDesign?: boolean;
backdrop?: boolean | 'static';
backdropClassName?: string;
tabIndex?: number;
children: React.ReactNode;
autoFocusConfirmButton?: boolean;
keyboardEscape?: boolean;
headerInput?: React.ReactNode;
bodyPadding?: boolean;
bodyDivider?: boolean;
bodyOverflowVisible?: boolean;
footerContent?: React.ReactNode;
footerDivider?: boolean;
appendedContent?: React.ReactNode;
headerButton?: React.ReactNode;
showCloseButton?: boolean;
showHeader?: boolean;
/**
* Whether this modal is stacked on top of another modal.
* When true, the modal will not render its own backdrop and will
* adjust the z-index of the parent modal's backdrop.
*/
isStacked?: boolean;
/*
* Controls the vertical location of the modal.
* 'top' => margin-top: 5vh
* 'center' => margin-top: calc(50vh - 240px)
* 'bottom' => margin-top: calc(50vh + 240px) (example calculation)
*/
modalLocation?: ModalLocation;
/**
* Optionally set a test ID for the container, so that the modal can be easily referenced
* in tests (Cypress, Playwright, etc.)
*/
dataTestId?: string;
/**
* Whether to delay activating the focus trap.
*
* This is useful for modals with dynamic content that might not be fully
* rendered when the modal is opened. The delay allows the DOM to settle
* before the focus trap identifies focusable elements. ie. MultiSelect
*
* When true, applies a 500ms delay.
*/
delayFocusTrap?: boolean;
};
export const GenericModal: React.FC<Props> = ({
show = true,
id = 'genericModal',
autoCloseOnCancelButton = true,
autoCloseOnConfirmButton = true,
enforceFocus = true,
keyboardEscape = true,
bodyPadding = true,
showCloseButton = true,
showHeader = true,
modalLocation = 'center',
className,
onExited,
onEntered,
onHide,
preventClose = false,
modalHeaderText,
modalHeaderTextId,
modalSubheaderText,
handleCancel,
handleConfirm,
handleEnterKeyPress,
handleKeydown,
confirmButtonText,
confirmButtonVariant,
cancelButtonText,
isConfirmDisabled,
container,
ariaLabel,
ariaLabelledby,
errorText,
compassDesign,
backdrop,
backdropClassName,
children,
autoFocusConfirmButton,
headerInput,
bodyDivider,
bodyOverflowVisible,
footerContent,
footerDivider,
appendedContent,
headerButton,
dataTestId,
delayFocusTrap,
isStacked = false,
}) => {
const intl = useIntl();
// Create a ref for the modal container
const containerRef = useRef<HTMLDivElement>(null);
const [showState, setShowState] = useState(show);
const onHideCallback = useCallback(() => {
if (!preventClose) {
setShowState(false);
}
onHide?.();
}, [onHide, preventClose]);
// Use focus trap to keep focus within the modal when it's open
useFocusTrap(showState, containerRef, {
delayMs: delayFocusTrap ? 500 : undefined,
});
// Use stacked modal hook to manage backdrop and z-index. Pass the
// portal container (when it resolves to a DOM element) so backdrop
// discovery stays scoped to this modal's own stack rather than every
// backdrop in the document.
const stackedModalContainer = container instanceof HTMLElement ? container : undefined;
const {
shouldRenderBackdrop,
modalStyle,
backdropStyle,
} = useStackedModal(Boolean(isStacked), showState, stackedModalContainer);
useEffect(() => {
setShowState(show);
}, [show]);
const handleCancelCallback = useCallback((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
if (autoCloseOnCancelButton) {
onHideCallback();
}
handleCancel?.();
}, [autoCloseOnCancelButton, onHideCallback, handleCancel]);
const handleConfirmCallback = useCallback((event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
if (autoCloseOnConfirmButton) {
onHideCallback();
}
handleConfirm?.();
}, [autoCloseOnConfirmButton, onHideCallback, handleConfirm]);
const onEnterKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter') {
if (event.nativeEvent.isComposing) {
return;
}
if (handleConfirm && autoCloseOnConfirmButton) {
onHideCallback();
}
handleEnterKeyPress?.();
}
handleKeydown?.(event);
}, [handleConfirm, autoCloseOnConfirmButton, onHideCallback, handleEnterKeyPress, handleKeydown]);
// Build confirm button if provided.
let confirmButtonElement;
if (handleConfirm) {
const buttonTypeClass = confirmButtonVariant === 'destructive' ? 'delete' : 'confirm';
let confirmButtonTextContent: React.ReactNode = (
<FormattedMessage
id='generic_modal.confirm'
defaultMessage='Confirm'
/>
);
if (confirmButtonText) {
confirmButtonTextContent = confirmButtonText;
}
confirmButtonElement = (
<Button
autoFocus={autoFocusConfirmButton}
type='submit'
emphasis='primary'
variant={confirmButtonVariant}
className={classNames('GenericModal__button', buttonTypeClass, {
disabled: isConfirmDisabled,
})}
onClick={handleConfirmCallback}
disabled={isConfirmDisabled}
>
{confirmButtonTextContent}
</Button>
);
}
// Build cancel button if provided.
let cancelButtonElement;
if (handleCancel) {
let cancelButtonTextContent: React.ReactNode = (
<FormattedMessage
id='generic_modal.cancel'
defaultMessage='Cancel'
/>
);
if (cancelButtonText) {
cancelButtonTextContent = cancelButtonText;
}
cancelButtonElement = (
<Button
type='button'
emphasis='tertiary'
onClick={handleCancelCallback}
>
{cancelButtonTextContent}
</Button>
);
}
// Build header text if provided.
const headerText = modalHeaderText && (
<div className='GenericModal__header'>
<h1 id={modalHeaderTextId || 'genericModalLabel'} className='modal-title'>
{modalHeaderText}
</h1>
{headerButton}
</div>
);
// Map modalLocation to a CSS class.
const locationClassMapping: Record<ModalLocation, string> = {
top: 'GenericModal__location--top',
center: 'GenericModal__location--center',
bottom: 'GenericModal__location--bottom',
};
const modalLocationClass = locationClassMapping[modalLocation];
// Accessibility labeling strategy:
// 1. We always set aria-labelledby to ensure the modal has a proper label
// - First try to use the provided ariaLabeledBy prop
// - Fall back to 'genericModalLabel' which references the modal title
// 2. We also support aria-label as a secondary option
// - This will only be used by screen readers if the element referenced by aria-labelledby doesn't exist
// - This provides a fallback for accessibility in case the referenced element is missing
// Note: When both aria-labelledby and aria-label are present, aria-labelledby takes precedence
const ariaLabelledbyValue = ariaLabelledby || 'genericModalLabel';
return (
<Modal
id={id}
role='none'
aria-label={ariaLabel}
aria-labelledby={ariaLabelledbyValue}
aria-modal='true'
dialogClassName={classNames(
modalLocationClass,
'a11y__modal GenericModal',
{
GenericModal__compassDesign: compassDesign,
'modal--overflow': bodyOverflowVisible,
},
className,
)}
show={showState}
restoreFocus={true}
enforceFocus={enforceFocus}
onHide={onHideCallback}
onExited={onExited}
backdrop={shouldRenderBackdrop ? backdrop : false}
backdropStyle={backdropStyle}
backdropClassName={backdropClassName}
container={container}
keyboard={keyboardEscape}
onEntered={onEntered}
data-testid={dataTestId}
style={modalStyle}
>
<div
ref={containerRef}
onKeyDown={onEnterKeyDown}
className='GenericModal__wrapper GenericModal__wrapper-enter-key-press-catcher'
>
{showHeader && (
<Modal.Header closeButton={false}>
<div className='GenericModal__header__text_container'>
{compassDesign && (
<>
{headerText}
{headerInput}
</>
)}
{modalSubheaderText && (
<div className='modal-subheading-container'>
<div id='genericModalSubheading' className='modal-subheading'>
{modalSubheaderText}
</div>
</div>
)}
</div>
{showCloseButton && (
<button
type='button'
className='close'
onClick={onHideCallback}
aria-label={intl.formatMessage({id: 'generic_modal.close', defaultMessage: 'Close'})}
>
<span aria-hidden='true'>{'×'}</span>
<span className='sr-only'>
<FormattedMessage id='generic_modal.close' defaultMessage='Close' />
</span>
</button>
)}
</Modal.Header>
)}
<Modal.Body className={classNames({divider: bodyDivider, 'overflow-visible': bodyOverflowVisible})}>
{compassDesign ? (
errorText && (
<div className='genericModalError'>
<i className='icon icon-alert-outline'/>
<span>{errorText}</span>
</div>
)
) : (
headerText
)}
<div className={classNames('GenericModal__body', {padding: bodyPadding})}>
{children}
</div>
</Modal.Body>
{(cancelButtonElement || confirmButtonElement || footerContent) && (
<Modal.Footer className={classNames({divider: footerDivider})}>
{(cancelButtonElement || confirmButtonElement) ? (
<>
{cancelButtonElement}
{confirmButtonElement}
</>
) : (
footerContent
)}
</Modal.Footer>
)}
{Boolean(appendedContent) && appendedContent}
</div>
</Modal>
);
};
export default GenericModal;

View File

@@ -0,0 +1,308 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, screen} from '@testing-library/react';
import React, {useRef} from 'react';
import {useFocusTrap} from './useFocusTrap';
// Test component that uses the hook
function FocusTrapTestComponent({
isActive = true,
initialFocus = false,
restoreFocus = false,
delayMs = 0,
}: {
isActive?: boolean;
initialFocus?: boolean;
restoreFocus?: boolean;
delayMs?: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useFocusTrap(isActive, containerRef, {
initialFocus,
restoreFocus,
delayMs,
});
return (
<div ref={containerRef} data-testid='container'>
<button data-testid='button1'>Button 1</button>
<button data-testid='button2'>Button 2</button>
<button data-testid='button3'>Button 3</button>
</div>
);
}
// Test component with nested focus traps
function NestedFocusTrapsComponent() {
const outerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
useFocusTrap(true, outerRef);
useFocusTrap(true, innerRef);
return (
<div ref={outerRef} data-testid='outer-container'>
<button data-testid='outer-button1'>Outer Button 1</button>
<div ref={innerRef} data-testid='inner-container'>
<button data-testid='inner-button1'>Inner Button 1</button>
<button data-testid='inner-button2'>Inner Button 2</button>
</div>
<button data-testid='outer-button2'>Outer Button 2</button>
</div>
);
}
// Mimics the mobile menu modal, where an ancestor sets `pointer-events: none`
// and a descendant restores `pointer-events: auto` (see menu.scss `.menuModal`).
function PointerEventsFocusTrapComponent() {
const containerRef = useRef<HTMLDivElement>(null);
useFocusTrap(true, containerRef);
return (
<div style={{pointerEvents: 'none'}} data-testid='pe-modal'>
<div ref={containerRef} style={{pointerEvents: 'auto'}} data-testid='pe-container'>
<button data-testid='pe-button1'>Button 1</button>
<button data-testid='pe-button2'>Button 2</button>
</div>
</div>
);
}
describe('useFocusTrap', () => {
beforeEach(() => {
// Create a div to hold our rendered components
const container = document.createElement('div');
container.id = 'root';
document.body.appendChild(container);
// Create an element outside the focus trap for testing restoreFocus
const outsideButton = document.createElement('button');
outsideButton.setAttribute('data-testid', 'outside-button');
outsideButton.textContent = 'Outside Button';
document.body.appendChild(outsideButton);
});
afterEach(() => {
// Clean up
document.body.innerHTML = '';
jest.useRealTimers();
});
// Helper function to simulate Tab key press
const simulateTabKey = (shiftKey = false) => {
const tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
shiftKey,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(tabEvent);
};
// Helper function to simulate tab navigation with focus trap
const simulateTabWithFocusTrap = (container: HTMLElement, shiftKey = false) => {
const focusableElements = Array.from(
container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
) as HTMLElement[];
if (focusableElements.length === 0) {
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const currentElement = document.activeElement as HTMLElement;
// Find the current element index
const currentIndex = focusableElements.indexOf(currentElement);
if (currentIndex === -1) {
// If not found, focus the first element
firstElement.focus();
return;
}
if (shiftKey) {
// Backward navigation
if (currentElement === firstElement) {
lastElement.focus();
} else {
const prevIndex = ((currentIndex - 1) + focusableElements.length) % focusableElements.length;
focusableElements[prevIndex].focus();
}
} else if (currentElement === lastElement) {
// Forward navigation - if at last element, go to first
firstElement.focus();
} else {
// Forward navigation - go to next element
const nextIndex = (currentIndex + 1) % focusableElements.length;
focusableElements[nextIndex].focus();
}
};
test('should trap focus within the container', async () => {
const {container} = render(<FocusTrapTestComponent />);
// Focus the first button
const button1 = screen.getByTestId('button1');
button1.focus();
expect(document.activeElement).toBe(button1);
// Tab to the next button
simulateTabWithFocusTrap(container);
expect(document.activeElement).toBe(screen.getByTestId('button2'));
// Tab to the last button
simulateTabWithFocusTrap(container);
expect(document.activeElement).toBe(screen.getByTestId('button3'));
// Tab again should cycle back to the first button
simulateTabWithFocusTrap(container);
expect(document.activeElement).toBe(button1);
// Shift+Tab should go to the last button
simulateTabWithFocusTrap(container, true);
expect(document.activeElement).toBe(screen.getByTestId('button3'));
});
test('should set initial focus when initialFocus is true', () => {
render(<FocusTrapTestComponent initialFocus={true} />);
// The first focusable element should be focused automatically
// We need to wait for the focus to be set
setTimeout(() => {
expect(document.activeElement).toBe(screen.getByTestId('button1'));
}, 0);
});
test('should restore focus when restoreFocus is true', () => {
// Focus the outside button first
const outsideButton = screen.getByTestId('outside-button');
outsideButton.focus();
expect(document.activeElement).toBe(outsideButton);
// Render the component with restoreFocus=true
const {unmount} = render(<FocusTrapTestComponent restoreFocus={true} />);
// Unmount the component
unmount();
// Focus should be restored to the outside button
expect(document.activeElement).toBe(outsideButton);
});
test('should handle delay option', () => {
jest.useFakeTimers();
const {container} = render(<FocusTrapTestComponent delayMs={500} />);
// Focus the first button
const button1 = screen.getByTestId('button1');
button1.focus();
expect(document.activeElement).toBe(button1);
// Tab to the next button - should not be trapped yet due to delay
// We'll use simulateTabKey here to simulate what happens without the trap
simulateTabKey();
// Advance timers
jest.advanceTimersByTime(500);
// Now focus the first button again and try tabbing
button1.focus();
simulateTabWithFocusTrap(container);
// Now the focus trap should be active
expect(document.activeElement).toBe(screen.getByTestId('button2'));
});
test('should not activate when isActive is false', () => {
render(<FocusTrapTestComponent isActive={false} />);
// Focus the first button
const button1 = screen.getByTestId('button1');
button1.focus();
expect(document.activeElement).toBe(button1);
// Tab to the next button - should not be trapped
// We'll use simulateTabKey here to simulate what happens without the trap
simulateTabKey();
// Focus should not be trapped within the container
expect(document.activeElement).not.toBe(screen.getByTestId('button2'));
});
test('should handle nested focus traps', () => {
render(<NestedFocusTrapsComponent />);
// Focus the first inner button
const innerButton1 = screen.getByTestId('inner-button1');
innerButton1.focus();
expect(document.activeElement).toBe(innerButton1);
// Find the inner container
const innerContainer = screen.getByTestId('inner-container');
// Tab to the next button in the inner trap
simulateTabWithFocusTrap(innerContainer);
expect(document.activeElement).toBe(screen.getByTestId('inner-button2'));
// Tab again should cycle back to the first inner button
simulateTabWithFocusTrap(innerContainer);
expect(document.activeElement).toBe(innerButton1);
// The outer trap should not interfere with the inner trap
});
test('should handle empty containers gracefully', () => {
// Create a component with no focusable elements
function EmptyComponent() {
const containerRef = useRef<HTMLDivElement>(null);
useFocusTrap(true, containerRef);
return <div ref={containerRef} data-testid='empty-container'></div>;
}
render(<EmptyComponent />);
// No errors should be thrown
const container = screen.getByTestId('empty-container');
expect(container).toBeInTheDocument();
});
// Regression test for MM-69557: the mobile menu fix sets `pointer-events: none`
// on an ancestor (`.menuModal`) and restores it on a descendant. The trap must
// keep treating the still-focusable descendants as focusable rather than
// filtering them out because an ancestor has `pointer-events: none`.
test('should trap focus for elements under an ancestor with pointer-events: none', () => {
// jsdom reports zero-size rects by default, which would mark every element
// hidden; give the buttons a non-zero size so visibility hinges on styles.
const rectSpy = jest.
spyOn(HTMLElement.prototype, 'getBoundingClientRect').
mockReturnValue({width: 100, height: 20, x: 0, y: 0, top: 0, left: 0, right: 100, bottom: 20, toJSON: () => ({})} as DOMRect);
// Restore the global spy in finally so a failed render or assertion
// cannot leak the mock into subsequent tests.
try {
render(<PointerEventsFocusTrapComponent />);
const button1 = screen.getByTestId('pe-button1');
const button2 = screen.getByTestId('pe-button2');
// Focus the last button, then Tab. The trap must cycle back to the first
// button, which only happens if both buttons were detected as focusable.
button2.focus();
expect(document.activeElement).toBe(button2);
document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab', code: 'Tab', bubbles: true, cancelable: true}));
expect(document.activeElement).toBe(button1);
} finally {
rectSpy.mockRestore();
}
});
});

View File

@@ -0,0 +1,214 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useRef} from 'react';
// A global stack to hold active focus trap containers.
// This ensures that only the topmost trap processes Tab events.
const activeFocusTraps: HTMLElement[] = [];
type FocusTrapOptions = {
initialFocus?: boolean;
restoreFocus?: boolean;
delayMs?: number; // Delay in milliseconds before activating the focus trap
};
/**
* A hook that traps focus within a container element.
* When multiple focus traps are active, only the topmost one will process Tab key events.
* @param isActive Whether the focus trap is active
* @param containerRef A ref to the container element
* @param options FocusTrapOptions Options for the focus trap
* @returns void
*/
export function useFocusTrap(
isActive: boolean,
containerRef: React.RefObject<HTMLElement | null>,
options: FocusTrapOptions = {initialFocus: false, restoreFocus: false},
): void {
const previousFocusRef = useRef<HTMLElement | null>(null);
// Add a ref to store the cached focusable elements
const focusableElementsRef = useRef<HTMLElement[]>([]);
useEffect(() => {
const container = containerRef.current;
if (!isActive || !container) {
return;
}
// Store the previously focused element for restoration if needed
if (options.restoreFocus) {
previousFocusRef.current = document.activeElement as HTMLElement;
}
let timeoutId: NodeJS.Timeout | null = null;
let trapActive = false;
// Function to cache focusable elements and activate the trap
const activateFocusTrap = () => {
// Cache the focusable elements
focusableElementsRef.current = getFocusableElements(container);
// Register this focus trap (push it onto the global stack)
activeFocusTraps.push(container);
trapActive = true;
if (focusableElementsRef.current.length === 0) {
return;
}
// Set initial focus if needed
if (options.initialFocus && focusableElementsRef.current.length > 0) {
focusableElementsRef.current[0].focus();
}
};
// Function to refresh the cached elements if needed
const refreshFocusableElements = () => {
focusableElementsRef.current = getFocusableElements(container);
};
// Delay the activation if delayMs is specified
if (options.delayMs && options.delayMs > 0) {
timeoutId = setTimeout(activateFocusTrap, options.delayMs);
} else {
// Activate immediately if no delay
activateFocusTrap();
}
// Handle tab key navigation - only trap Tab key, let other keys propagate
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle Tab key for focus trapping
if (e.key !== 'Tab') {
return;
}
// Only process if this container is the top-most active focus trap
// AND if the focus trap has been activated (after delay)
if (!trapActive || activeFocusTraps[activeFocusTraps.length - 1] !== container) {
return;
}
// Use the cached focusable elements
const elements = focusableElementsRef.current;
if (elements.length === 0) {
return;
}
const firstElement = elements[0];
const lastElement = elements[elements.length - 1];
// If shift+tab on first element, move to last element
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) { // If tab on last element, move to first element
e.preventDefault();
firstElement.focus();
}
};
// Set up a MutationObserver to detect DOM changes that might affect focusable elements
const observer = new MutationObserver(() => {
// Only refresh if the trap is active
if (trapActive) {
refreshFocusableElements();
}
});
// Start observing the container for changes that might affect focusability
observer.observe(container, {
childList: true, // Watch for changes to child elements
subtree: true, // Watch the entire subtree
attributes: true, // Watch for attribute changes
attributeFilter: ['tabindex', 'disabled'], // Only care about attributes that affect focusability
});
document.addEventListener('keydown', handleKeyDown);
// Cleanup function
// eslint-disable-next-line consistent-return
return () => {
// Clear the timeout if component unmounts during delay
if (timeoutId) {
clearTimeout(timeoutId);
}
// Stop the observer
observer.disconnect();
document.removeEventListener('keydown', handleKeyDown);
// Only remove from stack if it was actually added
if (trapActive) {
const index = activeFocusTraps.indexOf(container);
if (index > -1) {
activeFocusTraps.splice(index, 1);
}
}
// Restore focus when trap is deactivated
if (options.restoreFocus && previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}, [isActive, containerRef, options.initialFocus, options.restoreFocus, options.delayMs]);
}
/**
* Helper function to get all focusable elements within a container
* @param container The container element
* @returns An array of focusable elements
*/
function getFocusableElements(container: HTMLElement): HTMLElement[] {
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
const elements = Array.from(container.querySelectorAll(selector)) as HTMLElement[];
// Filter out hidden elements
return elements.filter((element) => isElementVisible(element));
}
/**
* Checks if an element is visible in the DOM
* @param element The element to check
* @returns true if the element is visible, false otherwise
*/
function isElementVisible(element: HTMLElement): boolean {
// Check if the element has zero dimensions
const rect = element.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
return false;
}
// Check computed styles for this element and its ancestors
let currentElement: HTMLElement | null = element;
while (currentElement) {
const style = window.getComputedStyle(currentElement);
// Check common ways elements can be hidden. Note: pointer-events is
// intentionally not treated as a visibility signal here. It only affects
// mouse/touch hit-testing, not keyboard focusability, and an ancestor's
// `pointer-events: none` can be re-enabled on descendants, so walking the
// ancestor chain for it wrongly excludes still-focusable elements.
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
currentElement.hasAttribute('hidden')
) {
return false;
}
currentElement = currentElement.parentElement;
}
return true;
}

View File

@@ -0,0 +1,453 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, render, screen} from '@testing-library/react';
import React, {useState} from 'react';
import {useStackedModal} from './useStackedModal';
import {GenericModal} from '../generic_modal/generic_modal';
import {wrapIntl} from '../testUtils';
// Z-index constants from the hook implementation
const BASE_MODAL_Z_INDEX = 1050;
const Z_INDEX_INCREMENT = 10;
// The mock below installs two `.modal-backdrop` elements, so a stacked
// modal rendered under it sits at stacking depth 2.
const MOCK_STACK_DEPTH = 2;
// Mock component that directly uses the useStackedModal hook
const TestComponent = ({
isStacked = false,
isOpen = true,
container,
}: {
isStacked?: boolean;
isOpen?: boolean;
container?: HTMLElement | null;
}) => {
const {shouldRenderBackdrop, modalStyle, backdropStyle} = useStackedModal(isStacked, isOpen, container);
return (
<div data-testid='test-component'>
<div data-testid='should-render-backdrop'>{shouldRenderBackdrop.toString()}</div>
<div data-testid='modal-z-index'>{modalStyle.zIndex || 'none'}</div>
<div data-testid='backdrop-z-index'>{backdropStyle?.zIndex ?? 'none'}</div>
<div>Modal Content</div>
</div>
);
};
describe('useStackedModal', () => {
// Mock document.querySelectorAll for backdrop tests
let originalQuerySelectorAll: typeof document.querySelectorAll;
let mockBackdrop1: HTMLElement;
let mockBackdrop2: HTMLElement;
beforeEach(() => {
// Save original implementation
originalQuerySelectorAll = document.querySelectorAll;
// Create mock backdrop elements
mockBackdrop1 = document.createElement('div');
mockBackdrop1.className = 'modal-backdrop';
mockBackdrop1.style.zIndex = '1040'; // Bootstrap default
mockBackdrop1.style.opacity = '0.5'; // Bootstrap default
mockBackdrop2 = document.createElement('div');
mockBackdrop2.className = 'modal-backdrop';
mockBackdrop2.style.zIndex = '1045'; // Higher z-index for the second backdrop
mockBackdrop2.style.opacity = '0.5'; // Bootstrap default
document.querySelectorAll = jest.fn().mockImplementation((selector: string) => {
if (selector === '.modal-backdrop') {
return [mockBackdrop1, mockBackdrop2]; // Return multiple backdrops to simulate stacked modals
}
return [];
});
});
afterEach(() => {
// Restore original implementation
document.querySelectorAll = originalQuerySelectorAll;
});
describe('Integration Tests', () => {
test('does not affect regular modals', () => {
const props = {
show: true,
onHide: jest.fn(),
modalHeaderText: 'Regular Modal',
children: <div>Regular Modal Content</div>,
};
render(
wrapIntl(<GenericModal {...props}/>),
);
// The modal should be in the document
expect(screen.getByText('Regular Modal')).toBeInTheDocument();
expect(screen.getByText('Regular Modal Content')).toBeInTheDocument();
// Regular modals should have a backdrop
// We can't directly test the backdrop since it's controlled by react-bootstrap
// But we can verify the modal displayed correctly and has the expected aria attributes
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
test('stacked modals have shouldRenderBackdrop=true but pass backdrop=false to Modal', () => {
const props = {
show: true,
onHide: jest.fn(),
modalHeaderText: 'Stacked Modal',
isStacked: true,
children: <div>Stacked Modal Content</div>,
};
render(
wrapIntl(<GenericModal {...props}/>),
);
// The modal should be in the document
expect(screen.getByText('Stacked Modal')).toBeInTheDocument();
expect(screen.getByText('Stacked Modal Content')).toBeInTheDocument();
// We can't directly test the backdrop since it's controlled by react-bootstrap
// But we can verify the modal displayed correctly and has the expected aria attributes
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
test('stacked modals do not render their own backdrop', () => {
// This test verifies that stacked modals don't render their own backdrop through GenericModal
const stackedProps = {
show: true,
onHide: jest.fn(),
modalHeaderText: 'Stacked Modal',
id: 'stackedModal',
isStacked: true,
children: <div>Stacked Modal Content</div>,
};
render(
wrapIntl(<GenericModal {...stackedProps}/>),
);
// The modal should be in the document
expect(screen.getByText('Stacked Modal')).toBeInTheDocument();
expect(screen.getByText('Stacked Modal Content')).toBeInTheDocument();
// The modal should have aria-modal="true"
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
});
describe('Direct Hook Tests - Basic Functionality', () => {
test('regular modals should render their own backdrop', () => {
render(<TestComponent isStacked={false}/>);
expect(screen.getByTestId('should-render-backdrop')).toHaveTextContent('true');
expect(screen.getByTestId('modal-z-index')).toHaveTextContent('none');
expect(screen.getByTestId('backdrop-z-index')).toHaveTextContent('none');
});
test('stacked modals should have increased z-index scaled by stacking depth', () => {
render(<TestComponent isStacked={true}/>);
// z-index scales with the number of modals beneath this one
// so each level sits above the previous stacked modal.
const expectedZIndex = BASE_MODAL_Z_INDEX + (MOCK_STACK_DEPTH * Z_INDEX_INCREMENT);
expect(screen.getByTestId('modal-z-index')).toHaveTextContent(expectedZIndex.toString());
});
});
describe('Direct Hook Tests - Backdrop Manipulation', () => {
test('stacked modals should modify parent backdrop opacity', () => {
render(<TestComponent isStacked={true}/>);
// The hook should have modified the most recent backdrop (mockBackdrop2)
expect(mockBackdrop2.style.opacity).toBe('0');
});
test('stacked modals should set transition property on parent backdrop', () => {
render(<TestComponent isStacked={true}/>);
// Verify the transition property is set correctly
expect(mockBackdrop2.style.transition).toBe('opacity 150ms ease-in-out');
});
test('stacked modals place their backdrop just below their own modal', () => {
render(<TestComponent isStacked={true}/>);
// The backdrop must sit one below the stacked modal: high
// enough to dim the modal directly beneath it, low enough to
// stay behind this modal's own content.
const stackedModalZIndex = BASE_MODAL_Z_INDEX + (MOCK_STACK_DEPTH * Z_INDEX_INCREMENT);
expect(screen.getByTestId('modal-z-index')).toHaveTextContent(stackedModalZIndex.toString());
expect(screen.getByTestId('backdrop-z-index')).toHaveTextContent((stackedModalZIndex - 1).toString());
});
test('cleanup should restore original backdrop properties', () => {
const {unmount} = render(<TestComponent isStacked={true}/>);
// The hook should have modified the parent backdrop
expect(mockBackdrop2.style.opacity).toBe('0');
// Unmount to trigger cleanup
unmount();
// Original opacity should be restored
expect(mockBackdrop2.style.opacity).toBe('0.5');
// The standard transition is re-enabled after the restore so any
// LATER opacity change (e.g. the parent modal's own close) still
// animates. It is not used for the restore itself — that snaps
// instantly; see the "snaps the parent backdrop opacity" test.
expect(mockBackdrop2.style.transition).toBe('opacity 150ms ease-in-out');
});
test('cleanup snaps the parent backdrop opacity back without animating (no close-time flash)', () => {
// Regression guard for the stacked-modal close flash: when a
// stacked modal closes, its own backdrop is removed instantly.
// If the parent backdrop opacity is animated back up from 0 over
// 150ms, there is a window with no opaque overlay and the whole
// screen flashes bright. The restore must therefore be a snap
// (transition disabled) so the dimming stays continuous.
//
// Use a non-default original opacity so the assertions also prove
// the restore reuses the STORED value rather than a hardcoded one.
const style = mockBackdrop2.style;
style.opacity = '0.7';
// Record every style write in order, tagging each opacity write
// with the transition value in effect at that instant. A correct
// fix restores the original opacity while transitions are
// disabled; the buggy version restored it while a 150ms opacity
// transition was live.
let opacityVal = style.opacity;
let transitionVal = style.transition;
const writes: Array<{prop: 'opacity' | 'transition'; value: string; transitionInEffect?: string}> = [];
Object.defineProperty(style, 'opacity', {
configurable: true,
get: () => opacityVal,
set: (v: string) => {
writes.push({prop: 'opacity', value: v, transitionInEffect: transitionVal});
opacityVal = v;
},
});
Object.defineProperty(style, 'transition', {
configurable: true,
get: () => transitionVal,
set: (v: string) => {
writes.push({prop: 'transition', value: v});
transitionVal = v;
},
});
const {unmount} = render(<TestComponent isStacked={true}/>);
unmount();
// The final restore writes the stored original opacity back (0.7),
// proving the hook reuses the captured value, not a default.
const restoreWrite = [...writes].reverse().find((w) => w.prop === 'opacity' && w.value === '0.7');
expect(restoreWrite).toBeDefined();
// Critically, that restore happens with transitions disabled so it
// snaps instead of fading in (the fade-in is what caused the flash).
expect(restoreWrite?.transitionInEffect).toBe('none');
// Lock in the cleanup ordering: transitions are disabled, THEN the
// opacity is snapped back, THEN the standard transition is
// re-enabled for future changes. (The forced reflow between the
// snap and the re-enable is a no-op in jsdom, so this ordering
// assertion is the closest available proxy for "no fade-in".)
const cleanupWrites = writes.slice(writes.findIndex((w) => w.prop === 'transition' && w.value === 'none'));
expect(cleanupWrites.map((w) => `${w.prop}:${w.value}`)).toEqual([
'transition:none',
'opacity:0.7',
'transition:opacity 150ms ease-in-out',
]);
expect(style.opacity).toBe('0.7');
expect(style.transition).toBe('opacity 150ms ease-in-out');
});
});
});
// Rendered against the real DOM (no querySelectorAll mock) so the hook
// sees the actual backdrop elements each stacked modal creates. This
// covers stacks deeper than one level, which is where a fixed z-index
// offset silently broke: the deepest modal's backdrop landed below the
// modal beneath it and left that modal undimmed (regression: Channel
// Settings → Simulate access → Decision details flashed the middle
// modal to full brightness while the details modal was open).
describe('useStackedModal - multi-level stacking (integration)', () => {
const flush = async () => {
await act(async () => {
await Promise.resolve();
});
};
function StackHarness() {
const [showMiddle, setShowMiddle] = useState(false);
const [showDeepest, setShowDeepest] = useState(false);
return (
<>
<button onClick={() => setShowMiddle(true)}>{'open-middle'}</button>
<button onClick={() => setShowDeepest(true)}>{'open-deepest'}</button>
<GenericModal
show={true}
onHide={jest.fn()}
modalHeaderText='Base'
id='baseModal'
>
<div>base body</div>
</GenericModal>
{showMiddle ? (
<GenericModal
show={true}
onHide={jest.fn()}
modalHeaderText='Middle'
id='middleModal'
isStacked={true}
>
<div>middle body</div>
</GenericModal>
) : null}
{showDeepest ? (
<GenericModal
show={true}
onHide={jest.fn()}
modalHeaderText='Deepest'
id='deepestModal'
isStacked={true}
>
<div>deepest body</div>
</GenericModal>
) : null}
</>
);
}
const zIndexOf = (id: string): number => {
const el = document.getElementById(id) as HTMLElement | null;
return el ? parseInt(el.style.zIndex || '1050', 10) : NaN;
};
const topBackdrop = (): HTMLElement => {
const backdrops = document.querySelectorAll<HTMLElement>('.modal-backdrop');
return backdrops[backdrops.length - 1];
};
test('a third-level stacked modal dims the modal directly beneath it', async () => {
render(wrapIntl(<StackHarness/>));
await flush();
act(() => {
screen.getByText('open-middle').click();
});
await flush();
// Second level: the middle modal sits above the base modal and
// its backdrop dims the base modal.
expect(zIndexOf('middleModal')).toBeGreaterThan(zIndexOf('baseModal'));
act(() => {
screen.getByText('open-deepest').click();
});
await flush();
const deepestModalZ = zIndexOf('deepestModal');
const middleModalZ = zIndexOf('middleModal');
const deepestBackdrop = topBackdrop();
const deepestBackdropZ = parseInt(deepestBackdrop.style.zIndex || '0', 10);
// The deepest modal stacks above the middle modal...
expect(deepestModalZ).toBeGreaterThan(middleModalZ);
// ...and its backdrop is sandwiched between them, so the middle
// modal is dimmed instead of showing through at full brightness.
expect(deepestBackdropZ).toBeGreaterThan(middleModalZ);
expect(deepestBackdropZ).toBeLessThan(deepestModalZ);
// The dimming layer is actually visible (not the transparent
// backdrop the close-flash fix parks the covered modal at).
expect(deepestBackdrop.style.opacity).not.toBe('0');
});
});
// A document-wide `.modal-backdrop` query can't tell two independent
// modal stacks apart: a second stack's backdrops would inflate this
// stack's computed depth (pushing its z-index too high) and its most
// recent backdrop could be grabbed as this stack's parent and wrongly
// dimmed. Passing each modal's own portal container scopes discovery to
// that stack so the two never interfere. (CodeRabbit review: scope
// backdrop discovery to the current stack/container.)
describe('useStackedModal - independent simultaneous stacks (scoped discovery)', () => {
const BACKDROP_Z_INDEX = 1040;
const addBackdrops = (container: HTMLElement, count: number): HTMLElement[] => {
const created: HTMLElement[] = [];
for (let i = 0; i < count; i++) {
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.style.zIndex = String(BACKDROP_Z_INDEX);
backdrop.style.opacity = '0.5';
container.appendChild(backdrop);
created.push(backdrop);
}
return created;
};
let containerA: HTMLElement;
let containerB: HTMLElement;
let backdropsA: HTMLElement[];
let backdropsB: HTMLElement[];
beforeEach(() => {
containerA = document.createElement('div');
containerB = document.createElement('div');
document.body.appendChild(containerA);
document.body.appendChild(containerB);
// Stack A has one modal beneath it (depth 1); stack B has two
// (depth 2). If discovery were document-wide, BOTH stacked modals
// would see all three backdrops and wrongly compute depth 3.
backdropsA = addBackdrops(containerA, 1);
backdropsB = addBackdrops(containerB, 2);
});
afterEach(() => {
containerA.remove();
containerB.remove();
});
test('each stack derives its depth from only its own container backdrops', () => {
const {rerender} = render(<TestComponent isStacked={true} container={containerA}/>);
// Depth 1 from container A's single backdrop → not depth 3.
expect(screen.getByTestId('modal-z-index')).toHaveTextContent(
(BASE_MODAL_Z_INDEX + Z_INDEX_INCREMENT).toString(),
);
rerender(<TestComponent isStacked={true} container={containerB}/>);
// Depth 2 from container B's two backdrops → still isolated from A.
expect(screen.getByTestId('modal-z-index')).toHaveTextContent(
(BASE_MODAL_Z_INDEX + (2 * Z_INDEX_INCREMENT)).toString(),
);
});
test('a stacked modal only dims the parent backdrop in its own container', () => {
render(<TestComponent isStacked={true} container={containerA}/>);
// Stack A's most recent backdrop is dimmed...
expect(backdropsA[backdropsA.length - 1].style.opacity).toBe('0');
// ...while the unrelated stack B's backdrops are left untouched.
for (const backdrop of backdropsB) {
expect(backdrop.style.opacity).toBe('0.5');
}
});
});

View File

@@ -0,0 +1,206 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useLayoutEffect, useMemo, useRef, useState} from 'react';
const BASE_MODAL_Z_INDEX = 1050; // Bootstrap default modal z-index
const BASE_BACKDROP_Z_INDEX = 1040; // Bootstrap default backdrop z-index
const Z_INDEX_INCREMENT = 10; // Increment for each stacked modal level
// No options needed since delayMs is not used by any consumers
type StackedModalResult = {
/**
* Whether the modal should render its own backdrop
*/
shouldRenderBackdrop: boolean;
/**
* Style object for the modal element
*/
modalStyle: React.CSSProperties;
/**
* Style object for the modal's backdrop element. Positions a
* stacked modal's backdrop just above the modal directly beneath
* it so that modal is dimmed while this one is open.
*/
backdropStyle?: React.CSSProperties;
/**
* Reference to the parent modal element (if this is a stacked modal)
*/
parentModalRef: React.RefObject<HTMLElement | null>;
};
/**
* A hook that manages stacked modals, controlling backdrop visibility and z-index values.
*
* @param isStacked Whether this modal is stacked on top of another modal
* @param isOpen Whether the modal is currently open
* @param container The DOM element the modal is portaled into. Backdrop
* discovery is scoped to this element so an independent modal stack in a
* different container can't inflate this modal's stacking depth or have
* its backdrop mistaken for this stack's parent. Defaults to the whole
* document, which is the single shared stack for the common case where
* modals render into `document.body`.
* @returns An object with properties to control modal and backdrop rendering
*/
export function useStackedModal(
isStacked: boolean,
isOpen: boolean,
container?: HTMLElement | null,
): StackedModalResult {
// State to track whether this modal should render its own backdrop
const [shouldRenderBackdrop, setShouldRenderBackdrop] = useState(!isStacked);
// State to track z-index values
const [zIndexes, setZIndexes] = useState({
modal: BASE_MODAL_Z_INDEX,
backdrop: BASE_BACKDROP_Z_INDEX,
});
// Ref to store the parent modal element
const parentModalRef = useRef<HTMLElement | null>(null);
// Ref to store the original z-index of the parent modal's backdrop
const originalBackdropZIndexRef = useRef<string | null>(null);
// Ref to store the parent modal's backdrop element
const backdropRef = useRef<HTMLElement | null>(null);
// Ref to store the original opacity of the parent modal's backdrop
const originalBackdropOpacityRef = useRef<string | null>(null);
useLayoutEffect(() => {
// If this is not a stacked modal, do nothing
if (!isStacked) {
return;
}
// If modal is closed, reset state and do cleanup
if (!isOpen) {
setShouldRenderBackdrop(false);
setZIndexes({
modal: BASE_MODAL_Z_INDEX,
backdrop: BASE_BACKDROP_Z_INDEX,
});
return;
}
// No timeout needed since we're not using delay
// Function to adjust the backdrop for stacked modals
const adjustBackdrop = () => {
// For stacked modals, we want to render our own backdrop
setShouldRenderBackdrop(true);
// The stacking depth is the number of backdrops already in
// the DOM from the modals beneath this one. Each level must
// sit a full increment above the previous stacked modal:
// using a fixed offset broke stacks 3+ deep (e.g. Channel
// Settings → Simulate access → Decision details), where the
// deepest modal shared the middle modal's z-index and its
// backdrop landed *below* that modal, leaving it undimmed.
//
// Scope the lookup to this modal's own container so a second,
// independent stack rendered elsewhere in the document can't
// be counted as part of this stack (which would over-count the
// depth) or have its backdrop picked as this stack's parent.
const root: ParentNode | null = container ?? (typeof document === 'undefined' ? null : document);
const backdrops = root === null ?
[] :
Array.from(root.querySelectorAll<HTMLElement>('.modal-backdrop'));
const depth = Math.max(backdrops.length, 1);
// The backdrop sits one below the modal so it dims the modal
// directly beneath this one but stays behind this modal.
const stackedModalZIndex = BASE_MODAL_Z_INDEX + (depth * Z_INDEX_INCREMENT);
setZIndexes({
modal: stackedModalZIndex,
backdrop: stackedModalZIndex - 1,
});
// Adjust the parent backdrop so we don't stack two dim
// layers over the same content.
if (backdrops.length > 0) {
// Get the most recent backdrop (the one with the highest z-index)
// This should be the backdrop of the parent modal
const parentBackdrop = backdrops[backdrops.length - 1];
backdropRef.current = parentBackdrop;
originalBackdropZIndexRef.current = parentBackdrop.style.zIndex || String(BASE_BACKDROP_Z_INDEX);
originalBackdropOpacityRef.current = parentBackdrop.style.opacity || '0.5'; // Default Bootstrap backdrop opacity
// Add a transition for smooth opacity change
parentBackdrop.style.transition = 'opacity 150ms ease-in-out';
parentBackdrop.style.opacity = '0';
}
};
// Adjust the backdrop immediately (no delay option)
adjustBackdrop();
// Cleanup function
// eslint-disable-next-line consistent-return
return () => {
// Restore original backdrop properties
if (backdropRef.current) {
if (originalBackdropZIndexRef.current) {
// Restore original z-index if it was stored
backdropRef.current.style.zIndex = originalBackdropZIndexRef.current;
}
if (originalBackdropOpacityRef.current) {
const backdrop = backdropRef.current;
// Snap the parent backdrop back to its original opacity
// WITHOUT a fade-in. The stacked modal's own backdrop is
// removed instantly when it closes, so animating the
// parent backdrop up from 0 over 150ms leaves a window
// with no opaque overlay — the whole screen flashes
// bright for that fraction of a second. Restoring the
// opacity synchronously keeps the visible dimming
// continuous as the stacked backdrop disappears.
backdrop.style.transition = 'none';
backdrop.style.opacity = originalBackdropOpacityRef.current;
// Reading a layout property forces the snap above to
// commit before transitions are re-enabled; otherwise the
// browser batches both writes and animates the opacity
// change, bringing the flash back. offsetHeight is always
// non-negative, so this also consumes the forced read.
if (backdrop.offsetHeight >= 0) {
backdrop.style.transition = 'opacity 150ms ease-in-out';
}
}
// Clear refs
backdropRef.current = null;
originalBackdropZIndexRef.current = null;
originalBackdropOpacityRef.current = null;
}
};
}, [isOpen, isStacked, container]);
const modalStyle = useMemo(() => {
return isStacked ? {
zIndex: zIndexes.modal,
} : {};
}, [isStacked, zIndexes.modal]);
const backdropStyle = useMemo(() => {
return isStacked ? {
zIndex: zIndexes.backdrop,
} : undefined;
}, [isStacked, zIndexes.backdrop]);
return {
shouldRenderBackdrop,
modalStyle,
backdropStyle,
parentModalRef,
};
}
export default useStackedModal;

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// type
export type {Props as GenericModalProps} from './generic_modal/generic_modal';
export type {CircleSkeletonLoaderProps, RectangleSkeletonLoaderProps} from './skeleton_loader';
export type {Props as PunchOutCoordsHeightAndWidth} from './common/hooks/useMeasurePunchouts';
// components
export {GenericModal} from './generic_modal/generic_modal';
export {FooterPagination} from './footer_pagination/footer_pagination';
export {CircleSkeletonLoader, RectangleSkeletonLoader} from './skeleton_loader';
export {TourTip} from './tour_tip/tour_tip';
export {TourTipBackdrop} from './tour_tip/tour_tip_backdrop';
export {PulsatingDot} from './pulsating_dot';
// hooks
export {useElementAvailable} from './common/hooks/useElementAvailable';
export {useFocusTrap} from './hooks/useFocusTrap';
export {useFollowElementDimensions} from './common/hooks/useFollowElementDimensions';
export {useMeasurePunchouts} from './common/hooks/useMeasurePunchouts';

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Coords} from '../common/hooks/useMeasurePunchouts';
import './pulsating_dot.scss';
type Props = {
targetRef?: React.RefObject<HTMLImageElement | null>;
className?: string;
onClick?: (e: React.MouseEvent) => void;
coords?: Coords;
};
export class PulsatingDot extends React.PureComponent<Props> {
public render() {
let customStyles = {};
if (this.props?.coords) {
customStyles = {
transform: `translate(${this.props.coords?.x}px, ${this.props.coords?.y}px)`,
};
}
let effectiveClassName = 'pulsating_dot';
if (this.props.onClick) {
effectiveClassName += ' pulsating_dot-clickable';
}
if (this.props.className) {
effectiveClassName = effectiveClassName + ' ' + this.props.className;
}
return (
<span
className={effectiveClassName}
onClick={this.props.onClick}
ref={this.props.targetRef}
style={{...customStyles}}
data-testid={'pulsating_dot'}
/>
);
}
}

View File

@@ -0,0 +1,71 @@
.pulsating_dot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
&-clickable {
cursor: pointer;
}
&,
&::before,
&::after {
width: 12px;
height: 12px;
background-color: var(--online-indicator);
border-radius: 50%;
}
&::before,
&::after {
position: absolute;
top: 0;
left: 0;
display: block;
content: "";
}
&::after {
animation: pulse1 2s ease 0s infinite;
}
&::before {
animation: pulse2 2s ease 0s infinite;
}
}
@keyframes pulse1 {
0% {
opacity: 1;
transform: scale(1);
}
80% {
opacity: 0;
transform: scale(2.5);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}
@keyframes pulse2 {
0% {
opacity: 1;
transform: scale(1);
}
30% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import styled, {keyframes} from 'styled-components';
const skeletonFade = keyframes`
0% {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
50% {
background-color: rgba(var(--center-channel-color-rgb), 0.16);
}
100% {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
`;
const BaseLoader = styled.div`
animation-duration: 1500ms;
animation-iteration-count: infinite;
animation-name: ${skeletonFade};
animation-timing-function: ease-in-out;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
`;
export interface CircleSkeletonLoaderProps {
size: string | number;
}
/**
* CircleSkeletonLoader is a component that renders a filled circle with a loading animation.
* It is used to indicate that the content is loading.
* @param props.size - The size of the circle. When in number, it is treated as pixels.
* @example
* <CircleSkeletonLoader size={20}/>
* <CircleSkeletonLoader size="50%"/>
*/
export const CircleSkeletonLoader = styled(BaseLoader)<CircleSkeletonLoaderProps>`
display: block;
border-radius: 50%;
height: ${(props) => getCorrectSizeDimension(props.size)};
width: ${(props) => getCorrectSizeDimension(props.size)};
`;
export interface RectangleSkeletonLoaderProps {
height: string | number;
width?: string | number;
borderRadius?: number;
margin?: string;
flex?: string;
}
/**
* RectangleSkeletonLoader is a component that renders a filled rectangle with a loading animation.
* It is used to indicate that the content is loading.
* @param props.height - The height of the rectangle eg. 20, "20em", "20%". When in number, it is treated as pixels.
* @param props.width - The width of the rectangle eg. 30, '100%'. When in number, it is treated as pixels.
* @param props.borderRadius - The border radius of the rectangle eg. 4
* @param props.margin - The margin of the rectangle eg. '0 10px', '10px 0 0 10px'
* @param props.flex - The flex short hand of flex grow, shrink, basis of the rectangle, under flex parent css eg. '1 1 auto'
* @default
* width: 100% , borderRadius: 8px
* @example
* <RectangleSkeletonLoader height='100px' />
* <RectangleSkeletonLoader height={40} width={100} borderRadius={4} margin='0 10px 0 0' flex='1' />
*/
export const RectangleSkeletonLoader = styled(BaseLoader)<RectangleSkeletonLoaderProps>`
height: ${(props) => getCorrectSizeDimension(props.height)};
width: ${(props) => getCorrectSizeDimension(props.width, '100%')};
border-radius: ${(props) => props?.borderRadius ?? 8}px;
margin: ${(props) => props?.margin ?? null};
flex: ${(props) => props?.flex ?? null};
`;
function getCorrectSizeDimension(size: number | string | undefined, fallback: string | null = null) {
if (size) {
return (typeof size === 'string') ? size : `${size}px`;
}
return fallback;
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ReactNode} from 'react';
import React from 'react';
import {IntlProvider, createIntl} from 'react-intl';
export const defaultIntl = createIntl({
locale: 'en',
defaultLocale: 'en',
messages: {},
});
export const wrapIntl = (children?: ReactNode) => <IntlProvider {...defaultIntl}>{children}</IntlProvider>;

View File

@@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export * from './tour_tip';
export * from './tour_tip_backdrop';

View File

@@ -0,0 +1,389 @@
$z-index-tour-tips-backdrop: 1250;
.tour-tip {
display: flex;
&__box {
&.tippy-box {
padding: 18px 24px 24px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
background: var(--button-bg);
border-radius: var(--radius-s);
color: var(--button-color);
filter: drop-shadow(0 12px 32px rgba(0, 0, 0, 0.12));
a {
color: rgba(var(--button-color-rgb), 0.8);
&:hover {
color: var(--button-color);
text-decoration: underline;
}
}
.tippy-content {
padding: 0;
}
.tippy-arrow {
width: 12px;
height: 12px;
border-color: rgba(var(--center-channel-color-rgb), 0.16);
color: var(--center-channel-bg);
color: var(--center-channel-bg);
}
.tippy-arrow::before {
width: 12px;
height: 12px;
border-color: rgba(var(--center-channel-color-rgb), 0.16);
background: var(--button-bg);
color: var(--button-color);
transform-origin: center;
}
button[class^='StyledChip-'],
button[class*='StyledChip-'] {
color: var(--button-color);
background-color: rgba(var(--button-color-rgb), 0.08);
box-shadow: none;
margin: 0 8px 8px 0;
&:hover {
background-color: rgba(var(--button-color-rgb), 0.16);
}
}
.btn {
&.btn-primary {
background: var(--button-color);
color: var(--button-bg) !important;
&:hover:not(.disabled) {
background:
linear-gradient(
0deg,
rgba(var(--button-bg-rgb), 0.08) 0%,
rgba(var(--button-bg-rgb), 0.08) 100%),
var(--button-color);
}
&:active {
background:
linear-gradient(
0deg,
rgba(var(--button-bg-rgb), 0.16) 0%,
rgba(var(--button-bg-rgb), 0.16) 100%),
var(--button-color);
}
&:focus {
box-shadow: inset 0 0 0 2px var(--sidebar-text-active-border);
}
}
&.btn-tertiary {
background: rgba(var(--button-color-rgb), 0.08);
color: var(--button-color);
&:hover {
background: rgba(var(--button-color-rgb), 0.12);
}
&:active {
background: rgba(var(--button-color-rgb), 0.16);
}
&:focus {
box-shadow: inset 0 0 0 2px var(--sidebar-text-active-border);
}
}
&.btn-icon {
margin-right: -8px;
margin-left: 1.2rem;
color: rgba(var(--button-color-rgb), 0.64);
&:hover {
background: rgba(var(--button-color-rgb), 0.08);
color: rgba(var(--button-color-rgb), 0.8);
}
&.active {
background: rgba(var(--button-color-rgb), 0.16);
}
}
}
// fix for https://mattermost.atlassian.net/browse/MM-41711. This covers the current placements we use for the channels and other tools tour
&[data-placement^=right] > .tippy-arrow {
transform: translate3d(0, 14px, 0) !important;
}
&[data-placement^=top] > .tippy-arrow {
transform: translate3d(14px, 0, 0) !important;
}
&[data-placement^=bottom] > .tippy-arrow {
transform: translate3d(14px, 0, 0) !important;
}
&[data-placement=bottom-end] > .tippy-arrow {
transform: translate3d(317px, 0, 0) !important;
}
}
}
&__pulsating-dot-ctr {
position: absolute;
z-index: 3;
width: 14px;
height: 14px;
cursor: pointer;
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=top] {
top: 0;
left: calc(50% - 6px);
transform: translate(0, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=top-start] {
top: 0;
left: 0;
transform: translate(6px, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=top-end] {
top: 0;
right: 0;
transform: translate(-6px, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=bottom] {
bottom: 0;
left: calc(50% - 6px);
transform: translate(0, -6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=bottom-start] {
bottom: 0;
left: 0;
transform: translate(6px, -6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=bottom-end] {
right: 0;
bottom: 0;
transform: translate(-6px, -6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=left] {
top: calc(50% - 6px);
left: 0;
transform: translate(6px, 0);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=left-start] {
top: 0;
left: 0;
transform: translate(6px, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=left-end] {
bottom: 0;
left: 0;
transform: translate(6px, -6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=right] {
top: calc(50% - 6px);
right: 0;
transform: translate(-6px, 0);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=right-start] {
top: 0;
right: 0;
transform: translate(-6px, 6px);
}
&__pulsating-dot-ctr[data-pulsating-dot-placement=right-end] {
right: 0;
bottom: 0;
transform: translate(-6px, -6px);
}
&__overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
&__header {
display: flex;
align-items: center;
justify-content: flex-start;
&__title {
flex: none;
flex-grow: 1;
order: 0;
margin: 0;
font-family: inherit;
font-size: 1.4rem;
font-style: normal;
font-weight: 600;
line-height: 2rem;
}
.Tag {
margin-left: 4px;
}
}
&__body {
display: flex;
flex-direction: column;
margin-top: 0.6rem;
p,
div {
margin: 0 0 0.8rem;
font-size: 1.4rem;
line-height: 2rem;
&:last-child {
margin-bottom: 0;
}
}
}
&__body:last-child {
margin-bottom: 0;
}
&__image {
display: flex;
align-items: center;
justify-content: center;
margin-top: 2.4rem;
border-radius: var(--radius-s);
img {
width: 100%;
height: 136px;
border-radius: var(--radius-s);
object-fit: cover;
}
}
&__btn-ctr {
display: flex;
flex-grow: 1;
justify-content: flex-end;
}
&__dot-ctr {
display: flex;
align-items: center;
justify-content: flex-start;
}
&__dot-ring {
position: relative;
display: flex;
width: 12px;
height: 12px;
align-items: center;
justify-content: center;
margin-right: 4px;
background: transparent;
border-radius: var(--radius-full);
&:last-child {
margin-right: 0;
}
}
&__dot-ring-active {
background: rgba(var(--button-color-rgb), 0.16);
}
&__dot {
position: absolute;
top: 3px;
left: 3px;
width: 6px;
height: 6px;
background: rgba(var(--button-color-rgb), 0.32);
border-radius: var(--radius-full);
&.active {
background: rgba(var(--button-color-rgb), 1);
}
}
&__footer {
display: flex;
flex-direction: column;
margin-top: 2.4rem;
&-buttons {
display: flex;
align-items: center;
justify-content: start;
}
}
&__opt {
align-self: flex-end;
margin-top: 1.2rem;
font-size: 12px;
span {
opacity: 0.9;
}
}
&__backdrop {
position: absolute;
z-index: $z-index-tour-tips-backdrop;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
&--transparent {
background: none !important;
}
}
}
// adding important as temporary fix, will be removing tippy very soon (WIP)
.tippy-box[data-placement^=right] > .tippy-arrow::before {
top: -1px !important;
border-width: 1px 0 0 1px !important;
transform: rotate(-45deg) !important;
}
.tippy-box[data-placement^=left] > .tippy-arrow::before {
top: -1px !important;
border-width: 1px 1px 0 0 !important;
transform: rotate(45deg) !important;
}
.tippy-box[data-placement^=bottom] > .tippy-arrow::before {
left: 1px !important;
border-width: 1px 0 0 1px !important;
transform: rotate(45deg) !important;
}
.tippy-box[data-placement^=top] > .tippy-arrow::before {
left: 1px !important;
border-width: 0 0 1px 1px !important;
transform: rotate(-45deg) !important;
}

View File

@@ -0,0 +1,253 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Tippy from '@tippyjs/react';
import classNames from 'classnames';
import React, {useRef, type JSX} from 'react';
import {FormattedMessage} from 'react-intl';
import type {Placement} from 'tippy.js';
import {Button} from '@mattermost/shared/components/button';
import {TourTipBackdrop} from './tour_tip_backdrop';
import type {Props as PunchOutCoordsHeightAndWidth} from '../common/hooks/useMeasurePunchouts';
import {PulsatingDot} from '../pulsating_dot';
import 'tippy.js/dist/tippy.css';
import 'tippy.js/themes/light-border.css';
import 'tippy.js/animations/scale-subtle.css';
import 'tippy.js/animations/perspective-subtle.css';
import './tour_tip.scss';
export type TourTipEventSource = 'next' | 'prev' | 'dismiss' | 'jump' | 'skipped' | 'open' | 'punchOut';
// If this needs to alter, change in _variables $z-index-tour-tips-popover as well
const DEFAULT_Z_INDEX_TOUR_TIPS_POPOVER = 1300;
type Props = {
show: boolean;
screen: JSX.Element;
title: JSX.Element;
step: number;
tourSteps?: Record<string, number>;
nextBtn?: JSX.Element;
prevBtn?: JSX.Element;
imageURL?: string;
singleTip?: boolean;
showOptOut?: boolean;
placement?: Placement;
pulsatingDotPlacement?: Omit<Placement, 'auto' | 'auto-end'>;
pulsatingDotTranslate?: {x: number; y: number};
offset?: [number, number];
width?: string | number;
zIndex?: number;
className?: string;
hideBackdrop?: boolean;
tippyBlueStyle?: boolean;
// if you don't want punchOut just assign null, keep null as hook may return null first than actual value
overlayPunchOut: PunchOutCoordsHeightAndWidth | null;
// if we want to interact with element visible via punchOut
interactivePunchOut?: boolean;
handleOpen?: (e: React.MouseEvent) => void;
handleNext?: (e: React.MouseEvent) => void;
handlePrevious?: (e: React.MouseEvent) => void;
handleJump?: (e: React.MouseEvent, jumpToStep: number) => void;
handleSkip?: (e: React.MouseEvent) => void;
handleDismiss?: (e: React.MouseEvent) => void;
handlePunchOut?: (e: React.MouseEvent) => void;
};
export const TourTip = ({
title,
screen,
imageURL,
overlayPunchOut,
singleTip,
step,
show,
interactivePunchOut,
tourSteps,
handleOpen,
handleDismiss,
handleNext,
handlePrevious,
handleSkip,
handleJump,
handlePunchOut,
pulsatingDotTranslate,
pulsatingDotPlacement,
nextBtn,
prevBtn,
className,
offset = [-18, 4],
placement = 'right-start',
showOptOut = true,
width = 352,
zIndex = DEFAULT_Z_INDEX_TOUR_TIPS_POPOVER,
hideBackdrop = false,
tippyBlueStyle = false,
}: Props) => {
const FIRST_STEP_INDEX = 0;
const triggerRef = useRef<HTMLDivElement>(null);
const onJump = (event: React.MouseEvent, jumpToStep: number) => {
handleJump?.(event, jumpToStep);
};
// This needs to be changed if root-portal node isn't available to maybe body
const rootPortal = document.getElementById('root-portal');
const dots = [];
if (!singleTip && tourSteps) {
for (let dot = FIRST_STEP_INDEX; dot < (Object.values(tourSteps).length - 1); dot++) {
let className = 'tour-tip__dot';
let circularRing = 'tour-tip__dot-ring';
if (dot === step) {
className += ' active';
circularRing += ' tour-tip__dot-ring-active';
}
dots.push(
<div className={circularRing}>
<a
href='#'
key={'dotactive' + dot}
className={className}
data-screen={dot}
onClick={(e) => onJump(e, dot)}
/>
</div>,
);
}
}
const content = (
<>
<div
className='tour-tip__header'
data-testid={'current_tutorial_tip'}
>
<h4 className='tour-tip__header__title'>
{title}
</h4>
<button
className='btn btn-sm btn-icon'
onClick={handleDismiss}
data-testid={'close_tutorial_tip'}
>
<i className='icon icon-close'/>
</button>
</div>
<div className='tour-tip__body'>
{screen}
</div>
{imageURL && (
<div className='tour-tip__image'>
<img
src={imageURL}
alt={'tutorial tour tip product image'}
/>
</div>
)}
{(nextBtn || prevBtn || showOptOut) && (<div className='tour-tip__footer'>
<div className='tour-tip__footer-buttons'>
<div className='tour-tip__dot-ctr'>{dots}</div>
<div className={'tour-tip__btn-ctr'}>
{step !== 0 && prevBtn && (
<Button
id='tipPreviousButton'
emphasis='tertiary'
size='sm'
onClick={handlePrevious}
>
{prevBtn}
</Button>
)}
{nextBtn && (
<Button
id='tipNextButton'
emphasis='primary'
size='sm'
onClick={handleNext}
>
{nextBtn}
</Button>
)}
</div>
</div>
{showOptOut && (
<div className='tour-tip__opt'>
<FormattedMessage
id='tutorial_tip.seen'
defaultMessage='Seen this before? '
/>
<a
href='#'
onClick={handleSkip}
>
<FormattedMessage
id='tutorial_tip.out'
defaultMessage='Opt out of these tips.'
/>
</a>
</div>
)}
</div>
)}
</>
);
return (
<>
<div
id='tipButton'
ref={triggerRef}
onClick={handleOpen}
className='tour-tip__pulsating-dot-ctr'
data-pulsating-dot-placement={pulsatingDotPlacement || 'right'}
style={{
transform: `translate(${pulsatingDotTranslate?.x}px, ${pulsatingDotTranslate?.y}px)`,
}}
>
<PulsatingDot/>
</div>
<TourTipBackdrop
show={show}
onDismiss={handleDismiss}
onPunchOut={handlePunchOut}
interactivePunchOut={interactivePunchOut}
overlayPunchOut={overlayPunchOut}
appendTo={rootPortal!}
transparent={hideBackdrop}
/>
{show && (
<Tippy
showOnCreate={show}
content={content}
animation='scale-subtle'
trigger='click'
duration={[250, 150]}
maxWidth={width}
aria={{content: 'labelledby'}}
allowHTML={true}
zIndex={zIndex}
reference={triggerRef as React.RefObject<Element>}
interactive={true}
appendTo={rootPortal!}
offset={offset}
className={classNames(
'tour-tip__box',
className,
{'tippy-blue-style': tippyBlueStyle},
)}
placement={placement}
/>
)}
</>
);
};

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import ReactDOM from 'react-dom';
import type {Props as PunchOutCoordsHeightAndWidth} from '../common/hooks/useMeasurePunchouts';
type Props = {
overlayPunchOut: PunchOutCoordsHeightAndWidth | null;
show: boolean;
interactivePunchOut?: boolean;
onDismiss?: (e: React.MouseEvent) => void;
onPunchOut?: (e: React.MouseEvent) => void;
appendTo: HTMLElement;
transparent?: boolean;
};
const TourTipRootPortal = ({children, show, element}: {children: React.ReactNode; show: boolean; element: Element}) =>
(show ? ReactDOM.createPortal(
children,
element,
) : null);
export const TourTipBackdrop = ({
show,
overlayPunchOut,
interactivePunchOut,
onDismiss,
onPunchOut,
appendTo,
transparent,
}: Props) => {
const vertices = [];
if (overlayPunchOut) {
const {x, y, width, height} = overlayPunchOut;
// draw to top left of punch out
vertices.push('0% 0%');
vertices.push('0% 100%');
vertices.push('100% 100%');
vertices.push('100% 0%');
vertices.push(`${x} 0%`);
vertices.push(`${x} ${y}`);
// draw punch out
vertices.push(`calc(${x} + ${width}) ${y}`);
vertices.push(`calc(${x} + ${width}) calc(${y} + ${height})`);
vertices.push(`${x} calc(${y} + ${height})`);
vertices.push(`${x} ${y}`);
// close off punch out
vertices.push(`${x} 0%`);
vertices.push('0% 0%');
}
const backdrop = (
<div
onClick={onDismiss}
className={`tour-tip__backdrop ${transparent ? 'tour-tip__backdrop--transparent' : ''}`}
style={{
clipPath: vertices.length ? `polygon(${vertices.join(', ')})` : undefined,
}}
/>
);
const overlay = interactivePunchOut ? backdrop : (
<>
<div
className={'tour-tip__overlay'}
onClick={onPunchOut || onDismiss}
/>
{backdrop}
</>
);
return (
<TourTipRootPortal
show={show}
element={appendTo}
>
{overlay}
</TourTipRootPortal>
);
};

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"strictNullChecks": true,
"types": ["node", "@testing-library/jest-dom"],
"isolatedModules": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"composite": true
},
"include": [
"./src/**/*"
]
}

View File

@@ -0,0 +1,203 @@
Copyright 2015-present Mattermost, Inc.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,103 @@
# @mattermost/eslint-plugin
An ESLint plugin containing the configuration used by Mattermost as well as support for custom rules specific to the Mattermost code base.
## Custom Rules
### no-dispatch-getstate
Prevents passing a [redux](https://redux.js.org/) store's `getState` into its `dispatch` as an unnecessary second argument.
We started doing this accidentally at some point because of a misunderstanding about how [redux-thunk](https://github.com/reduxjs/redux-thunk) worked, so this stops anyone from making that same mistake again.
Examples of **incorrect** code for this rule:
```javascript
export function someAction() {
return (dispatch, getState) => {
dispatch(doSomething(), getState);
};
}
```
Examples of **correct** code for this rule:
```javascript
export function someAction() {
return (dispatch) => {
dispatch(doSomething());
};
}
```
### no-redundant-admin-config-deps
Enforces that System Console settings in `admin_definition` files do not repeat `isDisabled` config/state checks already implied by a parent setting they depend on.
When setting B disables itself with `it.stateIsFalse('A')`, A is a bool setting, and A already includes condition C (for example `it.configIsTrue('ClusterSettings', 'Enable')`), repeating C on B is redundant and tends to drift. Keep the dependency on A and omit the duplicated checks. Inheritance only follows bool parents (disabled bools are forced false on save). Permission and license helpers are not treated as config dependencies.
The rule builds the full bool-parent dependency tree before reporting. If a setting lists both a parent and a grandparent, redundant conditions are attributed to the closest (most specific) parent — not whichever ancestor appears first in `isDisabled`.
Examples of **incorrect** code for this rule:
```javascript
{
type: 'bool',
key: 'EmailSettings.EnableEmailBatching',
isDisabled: it.any(
it.stateIsFalse('EmailSettings.SendEmailNotifications'),
it.configIsTrue('ClusterSettings', 'Enable'),
),
},
{
type: 'number',
key: 'EmailSettings.EmailBatchingBufferSize',
isDisabled: it.any(
it.stateIsFalse('EmailSettings.SendEmailNotifications'),
it.stateIsFalse('EmailSettings.EnableEmailBatching'),
it.configIsTrue('ClusterSettings', 'Enable'),
),
},
```
Examples of **correct** code for this rule:
```javascript
{
type: 'bool',
key: 'EmailSettings.EnableEmailBatching',
isDisabled: it.any(
it.stateIsFalse('EmailSettings.SendEmailNotifications'),
it.configIsTrue('ClusterSettings', 'Enable'),
),
},
{
type: 'number',
key: 'EmailSettings.EmailBatchingBufferSize',
isDisabled: it.any(
it.stateIsFalse('EmailSettings.EnableEmailBatching'),
),
},
```
### use-external-link
Ensures that any link which opens a URL outside of Mattermost using `target="_blank"` uses the `ExternalLink` component.
Examples of **incorrect** code for this rule:
```javascript
export function SomeLink() {
return (
<a
href="https://example.com"
target="_blank"
rel="noopener noreferrer"
/>
);
}
```
Examples of **correct** code for this rule:
```javascript
import ExternalLink from 'components/external_link';
export function SomeLink() {
return <ExternalLink href="https://example.com"/>;
}
```

View File

@@ -0,0 +1,636 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'path';
import {fileURLToPath} from 'url';
import {FlatCompat} from '@eslint/eslintrc';
import js from '@eslint/js';
import stylisticPlugin from '@stylistic/eslint-plugin';
import typescriptPlugin from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import headersPlugin from 'eslint-plugin-headers';
import importPlugin from 'eslint-plugin-import';
import globals from 'globals';
import rules from '../rules/index.js';
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
const compat = new FlatCompat({
baseDirectory: dirname,
});
const base = {
files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.ts', '**/*.tsx'],
languageOptions: {
ecmaVersion: 8,
globals: {
...globals.browser,
...globals.node,
...globals.es6,
},
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
impliedStrict: true,
modules: true,
},
},
sourceType: 'module',
},
plugins: {
'@mattermost': {
rules,
},
'@stylistic': stylisticPlugin,
typescript: typescriptPlugin,
headers: headersPlugin,
import: importPlugin,
},
rules: {
'@mattermost/no-dispatch-getstate': 2,
'@mattermost/no-redundant-admin-config-deps': 2,
'@mattermost/use-external-link': 2,
'@stylistic/array-bracket-spacing': [
2,
'never',
],
'@stylistic/arrow-parens': [
2,
'always',
],
'@stylistic/arrow-spacing': [
2,
{
before: true,
after: true,
},
],
'@stylistic/brace-style': [
2,
'1tbs',
{
allowSingleLine: false,
},
],
'@stylistic/comma-dangle': [
2,
'always-multiline',
],
'@stylistic/comma-spacing': [
2,
{
before: false,
after: true,
},
],
'@stylistic/comma-style': [
2,
'last',
],
'@stylistic/computed-property-spacing': [
2,
'never',
],
'@stylistic/dot-location': [
2,
'object',
],
'@stylistic/eol-last': ['error', 'always'],
'@stylistic/function-call-spacing': [
2,
'never',
],
'@stylistic/generator-star-spacing': [
2,
{
before: false,
after: true,
},
],
'@stylistic/indent': [
2,
4,
{
SwitchCase: 0,
},
],
'@stylistic/jsx-quotes': [
2,
'prefer-single',
],
'@stylistic/key-spacing': [
2,
{
beforeColon: false,
afterColon: true,
mode: 'strict',
},
],
'@stylistic/keyword-spacing': [
2,
{
before: true,
after: true,
overrides: {},
},
],
'@stylistic/linebreak-style': 2,
'@stylistic/lines-around-comment': [
2,
{
beforeBlockComment: true,
beforeLineComment: true,
allowBlockStart: true,
allowBlockEnd: true,
},
],
'@stylistic/max-statements-per-line': [
2,
{
max: 1,
},
],
'@stylistic/member-delimiter-style': 2,
'@stylistic/multiline-ternary': [
1,
'never',
],
'@stylistic/new-parens': 2,
'@stylistic/no-confusing-arrow': 2,
'@stylistic/no-extra-parens': 0,
'@stylistic/no-extra-semi': 2,
'@stylistic/no-floating-decimal': 2,
'@stylistic/no-mixed-operators': [
2,
{
allowSamePrecedence: false,
},
],
'@stylistic/no-mixed-spaces-and-tabs': 2,
'@stylistic/no-multi-spaces': [
2,
{
exceptions: {
Property: false,
},
},
],
'@stylistic/no-multiple-empty-lines': [
2,
{
max: 1,
},
],
'@stylistic/no-tabs': 0,
'@stylistic/no-trailing-spaces': [
2,
{
skipBlankLines: false,
},
],
'@stylistic/no-whitespace-before-property': 2,
'@stylistic/object-curly-newline': 0,
'@stylistic/object-curly-spacing': [
2,
'never',
],
'@stylistic/object-property-newline': [
2,
{
allowAllPropertiesOnSameLine: true,
},
],
'@stylistic/one-var-declaration-per-line': 0,
'@stylistic/operator-linebreak': [
2,
'after',
],
'@stylistic/padded-blocks': [
2,
'never',
],
'@stylistic/quote-props': [
2,
'as-needed',
],
'@stylistic/quotes': [
2,
'single',
{
avoidEscape: true,
},
],
'@stylistic/rest-spread-spacing': [
2,
'never',
],
'@stylistic/semi': [
2,
'always',
],
'@stylistic/semi-spacing': [
2,
{
before: false,
after: true,
},
],
'@stylistic/space-before-blocks': [
2,
'always',
],
'@stylistic/space-before-function-paren': [
2,
{
anonymous: 'never',
named: 'never',
asyncArrow: 'always',
},
],
'@stylistic/space-in-parens': [
2,
'never',
],
'@stylistic/space-infix-ops': 2,
'@stylistic/space-unary-ops': [
2,
{
words: true,
nonwords: false,
},
],
'@stylistic/template-curly-spacing': [
2,
'never',
],
'@stylistic/type-annotation-spacing': 2,
'@stylistic/wrap-iife': [
2,
'outside',
],
'@stylistic/wrap-regex': 2,
'@typescript-eslint/array-type': [2, {default: 'array-simple'}],
'@typescript-eslint/consistent-type-imports': ['error', {disallowTypeAnnotations: false}],
'@typescript-eslint/explicit-function-return-type': 0,
'@typescript-eslint/explicit-module-boundary-types': 0,
'@typescript-eslint/naming-convention': [
2,
{
selector: 'function',
format: ['camelCase', 'PascalCase'],
},
{
selector: 'variable',
format: ['camelCase', 'PascalCase', 'UPPER_CASE'],
},
{
selector: 'parameter',
format: ['camelCase', 'PascalCase'],
leadingUnderscore: 'allow',
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
],
'@typescript-eslint/no-dupe-class-members': 2,
'@typescript-eslint/no-empty-function': 0,
'@typescript-eslint/no-empty-object-type': [
2,
{
allowInterfaces: 'with-single-extends',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
2,
{
vars: 'all',
args: 'after-used',
},
],
'@typescript-eslint/no-use-before-define': [
2,
{
classes: false,
functions: false,
variables: false,
},
],
'@typescript-eslint/no-var-requires': 0,
'array-callback-return': 2,
'arrow-body-style': 0,
'block-scoped-var': 2,
'capitalized-comments': 0,
'class-methods-use-this': 0,
complexity: [
0,
10,
],
'consistent-return': 2,
'consistent-this': [
2,
'self',
],
'constructor-super': 2,
curly: [
2,
'all',
],
'dot-notation': 2,
eqeqeq: [
2,
'smart',
],
'func-name-matching': 0,
'func-names': 2,
'func-style': [
2,
'declaration',
{
allowArrowFunctions: true,
},
],
'global-require': 2,
'guard-for-in': 2,
'headers/header-format': [
'error',
{
source: 'string',
style: 'line',
content: 'Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.\nSee LICENSE.txt for license information.',
trailingNewlines: 2,
},
],
'id-blacklist': 0,
'import/no-duplicates': 2,
'import/no-unresolved': 0, // Handled better by TS
'import/order': [
2,
{
'newlines-between': 'always',
groups: [
'builtin',
'external',
'internal',
'sibling',
'parent',
'index',
],
pathGroups: [
{
pattern: '@mattermost/**',
group: 'external',
position: 'after',
},
{
pattern: 'mattermost-redux/**',
group: 'external',
position: 'after',
},
{
pattern: '@(selectors|actions|stores|store|reducers){,/**}',
group: 'external',
position: 'after',
},
{
pattern: 'components/**',
group: 'external',
position: 'after',
},
{
pattern: 'types{,/**}',
group: 'internal',
position: 'after',
},
],
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
distinctGroup: true,
pathGroupsExcludedImportTypes: ['builtin'],
},
],
'line-comment-position': 0,
'max-lines': [
'warn',
{
max: 800,
skipBlankLines: true,
skipComments: false,
},
],
'max-nested-callbacks': ['error', 10],
'new-cap': 2,
'newline-before-return': 0,
'newline-per-chained-call': 0,
'no-alert': 2,
'no-array-constructor': 2,
'no-await-in-loop': 2,
'no-caller': 2,
'no-case-declarations': 2,
'no-class-assign': 2,
'no-compare-neg-zero': 2,
'no-cond-assign': [
2,
'except-parens',
],
'no-console': 2,
'no-const-assign': 2,
'no-constant-binary-expression': 2,
'no-constant-condition': 2,
'no-debugger': 2,
'no-div-regex': 2,
'no-dupe-args': 2,
'no-dupe-class-members': 0, // Handled by @typescript-eslint/no-dupe-class-members
'no-dupe-keys': 2,
'no-duplicate-case': 2,
'no-duplicate-imports': 0, // Handled by import/no-duplicates
'no-else-return': 2,
'no-empty': 2,
'no-empty-function': 0,
'no-empty-pattern': 2,
'no-eval': 2,
'no-ex-assign': 2,
'no-extend-native': 2,
'no-extra-bind': 2,
'no-extra-label': 2,
'no-fallthrough': 2,
'no-func-assign': 2,
'no-global-assign': 2,
'no-implicit-coercion': 2,
'no-implicit-globals': 0,
'no-implied-eval': 2,
'no-inner-declarations': 0,
'no-invalid-regexp': 2,
'no-irregular-whitespace': 2,
'no-iterator': 2,
'no-labels': 2,
'no-lone-blocks': 2,
'no-lonely-if': 2,
'no-loop-func': 2,
'no-magic-numbers': 0,
'no-multi-assign': 2,
'no-multi-str': 0,
'no-native-reassign': 2,
'no-negated-condition': 2,
'no-nested-ternary': 2,
'no-new': 2,
'no-new-func': 2,
'no-new-object': 2,
'no-new-symbol': 2,
'no-new-wrappers': 2,
'no-octal-escape': 2,
'no-param-reassign': 2,
'no-process-env': 2,
'no-process-exit': 2,
'no-proto': 2,
'no-prototype-builtins': 2,
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'redux',
importNames: ['DeepPartial'],
message: 'Use DeepPartial from @mattermost/types/utilities instead.',
},
{
name: 'lodash',
message: 'Import individual functions from lodash/<function> instead.',
},
],
},
],
'no-return-assign': [
2,
'always',
],
'no-return-await': 2,
'no-script-url': 2,
'no-self-assign': [
2,
{
props: true,
},
],
'no-self-compare': 2,
'no-sequences': 2,
'no-shadow': 0, // This isn't currently enabled, but it probably should be
'no-shadow-restricted-names': 2,
'no-template-curly-in-string': 2,
'no-ternary': 0,
'no-this-before-super': 2,
'no-throw-literal': 2,
'no-undef-init': 2,
'no-undefined': 0,
'no-underscore-dangle': 2,
'no-unexpected-multiline': 2,
'no-unmodified-loop-condition': 2,
'no-unneeded-ternary': [
2,
{
defaultAssignment: false,
},
],
'no-unreachable': 2,
'no-unsafe-finally': 2,
'no-unsafe-negation': 2,
'no-unused-expressions': 2,
'no-unused-vars': 0, // Handled by @typescript-eslint/no-unused-vars
'no-use-before-define': 0, // Handled by @typescript-eslint/no-use-before-define
'no-useless-computed-key': 2,
'no-useless-concat': 2,
'no-useless-constructor': 2,
'no-useless-escape': 2,
'no-useless-rename': 2,
'no-useless-return': 2,
'no-var': 0,
'no-void': 2,
'no-warning-comments': 1,
'no-with': 2,
'object-shorthand': [
2,
'always',
],
'one-var': [
2,
'never',
],
'operator-assignment': [
2,
'always',
],
'prefer-arrow-callback': 2,
'prefer-const': 2,
'prefer-destructuring': 0,
'prefer-numeric-literals': 2,
'prefer-promise-reject-errors': 2,
'prefer-rest-params': 2,
'prefer-spread': 2,
'prefer-template': 0,
radix: 2,
'require-yield': 2,
'sort-imports': 0,
'sort-keys': 0,
'symbol-description': 2,
'valid-typeof': [
2,
{
requireStringLiterals: false,
},
],
'vars-on-top': 0,
yoda: [
2,
'never',
{
exceptRange: false,
onlyEquality: false,
},
],
},
};
const testOverrides = {
files: ['**/*.test.js', '**/*.test.jsx', '**/*.test.ts', '**/*.test.tsx', 'src/tests/**'],
languageOptions: {
globals: {
after: true,
afterAll: true,
afterEach: true,
before: true,
beforeAll: true,
beforeEach: true,
describe: true,
expect: true,
it: true,
jest: true,
test: true,
},
},
rules: {
'func-names': 0,
'global-require': 0,
'no-console': 0,
'no-import-assign': 0,
'max-lines': 0,
'max-nested-callbacks': 0,
'new-cap': 0,
'prefer-arrow-callback': 0,
'@typescript-eslint/no-require-imports': 0,
},
};
export default [
js.configs.recommended,
...compat.extends('plugin:@typescript-eslint/recommended'),
stylisticPlugin.configs['disable-legacy'],
base,
testOverrides,
];

View File

@@ -0,0 +1,200 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import base from './base.js';
const react = {
files: ['**/*.jsx', '**/*.tsx'],
rules: {
'@stylistic/jsx-closing-bracket-location': [
2,
{
location: 'tag-aligned',
},
],
'@stylistic/jsx-curly-spacing': [
2,
'never',
],
'@stylistic/jsx-equals-spacing': [
2,
'never',
],
'@stylistic/jsx-first-prop-new-line': [
2,
'multiline',
],
'@stylistic/jsx-indent-props': [
2,
4,
],
'@stylistic/jsx-max-props-per-line': [
2,
{
maximum: 1,
},
],
'@stylistic/jsx-pascal-case': 2,
'@stylistic/jsx-tag-spacing': [
2,
{
closingSlash: 'never',
beforeSelfClosing: 'never',
afterOpening: 'never',
},
],
'@stylistic/jsx-wrap-multilines': 2,
'jsx-a11y/alt-text': 'warn',
'jsx-a11y/anchor-has-content': 'error',
'jsx-a11y/anchor-is-valid': 'warn',
'jsx-a11y/aria-activedescendant-has-tabindex': 'error',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/aria-proptypes': 'error',
'jsx-a11y/aria-role': 'error',
'jsx-a11y/aria-unsupported-elements': 'error',
'jsx-a11y/autocomplete-valid': 'error',
'jsx-a11y/click-events-have-key-events': 'warn',
'jsx-a11y/heading-has-content': 'error',
'jsx-a11y/html-has-lang': 'error',
'jsx-a11y/iframe-has-title': 'error',
'jsx-a11y/img-redundant-alt': 'warn',
'jsx-a11y/interactive-supports-focus': 'warn',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/media-has-caption': 'warn',
'jsx-a11y/mouse-events-have-key-events': 'off',
'jsx-a11y/no-access-key': 'error',
'jsx-a11y/no-aria-hidden-on-focusable': 'error',
'jsx-a11y/no-autofocus': 'warn',
'jsx-a11y/no-distracting-elements': 'error',
'jsx-a11y/no-interactive-element-to-noninteractive-role': 'error',
'jsx-a11y/no-noninteractive-element-interactions': 'warn',
'jsx-a11y/no-noninteractive-element-to-interactive-role': 'warn',
'jsx-a11y/no-noninteractive-tabindex': 'warn',
'jsx-a11y/no-redundant-roles': 'warn',
'jsx-a11y/no-static-element-interactions': 'warn',
'jsx-a11y/role-has-required-aria-props': 'warn',
'jsx-a11y/role-supports-aria-props': 'warn',
'jsx-a11y/scope': 'error',
'jsx-a11y/tabindex-no-positive': 'warn',
'react/display-name': [
0,
{
ignoreTranspilerName: false,
},
],
'react/forbid-component-props': 0,
'react/forbid-elements': [
2,
{
forbid: [
'embed',
],
},
],
'react/jsx-boolean-value': [
2,
'always',
],
'react/jsx-filename-extension': [
2,
{
extensions: [
'.jsx',
'.tsx',
],
},
],
'react/jsx-handler-names': 0,
'react/jsx-key': 2,
'react/jsx-no-bind': 0,
'react/jsx-no-comment-textnodes': 2,
'react/jsx-no-duplicate-props': [
2,
{
ignoreCase: false,
},
],
'react/jsx-no-literals': 2,
'react/jsx-no-target-blank': 2,
'react/jsx-no-undef': 2,
'react/jsx-uses-react': 2,
'react/jsx-uses-vars': 2,
'react/no-array-index-key': 1,
'react/no-children-prop': 2,
'react/no-danger': 0,
'react/no-danger-with-children': 2,
'react/no-deprecated': 1,
'react/no-did-mount-set-state': 2,
'react/no-did-update-set-state': 2,
'react/no-direct-mutation-state': 2,
'react/no-find-dom-node': 1,
'react/no-is-mounted': 2,
'react/no-multi-comp': [
2,
{
ignoreStateless: true,
},
],
'react/no-render-return-value': 2,
'react/no-set-state': 0,
'react/no-string-refs': 2,
'react/no-unescaped-entities': 2,
'react/no-unknown-property': [
2,
{
ignore: ['mask-type'],
},
],
'react/no-unused-prop-types': [
1,
{
skipShapeProps: true,
},
],
'react/prefer-es6-class': 2,
'react/prefer-stateless-function': 0,
'react/prop-types': 2,
'react/require-default-props': 0,
'react/require-optimization': 1,
'react/require-render-return': 2,
'react/self-closing-comp': 2,
'react/sort-comp': 0,
'react/style-prop-object': 2,
},
};
export default [
...base,
jsxA11yPlugin.flatConfigs.recommended,
reactPlugin.configs.flat.recommended,
reactHooksPlugin.configs.flat.recommended,
react,
{
// Disable new rules that are primarily needed to enable React Compiler because we're not using that yet
rules: {
'react-hooks/immutability': 0,
'react-hooks/preserve-manual-memoization': 0,
'react-hooks/purity': 0,
'react-hooks/refs': 0,
'react-hooks/set-state-in-effect': 0,
// This rule can be enabled once https://github.com/mattermost/mattermost/pull/36575 is merged because it
// should be done by converting getChannelIconComponent and getArchiveIconComponent to proper components
// instead of functions that return component constructors.
'react-hooks/static-components': 0,
},
},
{
settings: {
react: {
pragma: 'React',
version: 'detect',
},
},
},
];

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import eslintPlugin from '@mattermost/eslint-plugin';
export default [
...eslintPlugin.configs.base,
];

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import base from './configs/base.js';
import react from './configs/react.js';
import rules from './rules/index.js';
export default {
configs: {
base,
react,
},
rules,
};

View File

@@ -0,0 +1,47 @@
{
"name": "@mattermost/eslint-plugin",
"version": "2.0.0",
"description": "ESLint configuration and custom rules used by Mattermost",
"repository": {
"type": "git",
"url": "git+https://github.com/mattermost/mattermost.git",
"directory": "webapp/platform/eslint-plugin"
},
"author": "Mattermost, Inc.",
"license": "Apache 2.0",
"homepage": "https://github.com/mattermost/mattermost/tree/master/webapp/platform/eslint-plugin#readme",
"type": "module",
"exports": {
".": {
"import": "./index.js"
}
},
"dependencies": {
"@stylistic/eslint-plugin": "^5.10.0",
"@typescript-eslint/eslint-plugin": "^8.61.0",
"@typescript-eslint/parser": "^8.61.0",
"eslint-plugin-headers": "^1.3.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"jsx-ast-utils": "^3.3.3"
},
"peerDependencies": {
"eslint": "^9.0.0"
},
"peerDependenciesMeta": {
"eslint-plugin-react": {
"optional": true
},
"eslint-plugin-react-hooks": {
"optional": true
}
},
"scripts": {
"check": "eslint . --quiet",
"fix": "eslint . --quiet --fix",
"test": "node --test rules/*.test.js",
"clean": "rm -rf node_modules"
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import noDispatchGetState from './no-dispatch-getstate.js';
import noRedundantAdminConfigDeps from './no-redundant-admin-config-deps.js';
import useExternalLink from './use-external-link.js';
export default {
'no-dispatch-getstate': noDispatchGetState,
'no-redundant-admin-config-deps': noRedundantAdminConfigDeps,
'use-external-link': useExternalLink,
};

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export default {
meta: {
type: 'problem',
fixable: 'code',
messages: {
unexpected: 'Unexpected second argument passed to dispatch. getState does not need to be passed into dispatch.',
},
},
create(context) {
function checkDispatch(node) {
const args = node.arguments;
if (args.length === 2) {
context.report({
node,
messageId: 'unexpected',
fix(fixer) {
const sourceCode = context.getSourceCode();
const before = sourceCode.getTokenBefore(args[1]);
const after = sourceCode.getTokenAfter(args[1]);
// Remove the getState argument and the preceding comma
const fixes = [
fixer.remove(before),
fixer.remove(args[1]),
];
// And remove the trailing comma if one exists
if (after.type === 'Punctuator' && after.value === ',') {
fixes.push(fixer.remove(after));
}
// Note that this sometimes mangles the whitespace slightly
return fixes;
},
});
}
}
return {
"CallExpression[callee.name='dispatch']": checkDispatch,
"CallExpression[callee.name='doDispatch']": checkDispatch,
};
},
};

View File

@@ -0,0 +1,439 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Detects redundant System Console isDisabled conditions that are already
* implied by a parent config setting dependency.
*
* When setting B declares `it.stateIsFalse('A')` (or equivalent) and A is a bool
* setting that already disables itself under condition C, repeating C on B is
* redundant and can drift when A's dependencies change.
*
* Inheritance only follows bool parents: disabled bools are forced false on
* save (see getSettingValue), so stateIsFalse(parent) covers the parent's
* config checks. File/text parents can remain truthy while disabled, so their
* checks are not treated as transitive.
*
* The dependency tree is built fully before reporting. When a setting lists
* both a parent and a grandparent, redundant conditions are attributed to the
* closest (most specific) bool parent — not whichever ancestor appears first
* in the isDisabled list.
*
* Only config/state helpers are considered (stateIsFalse/True/Equals/...,
* configIsFalse/True, …). Permission and license checks are left alone —
* non-bool children still need their own permission gates.
*/
const CONFIG_HELPERS = new Set([
'stateIsFalse',
'stateIsTrue',
'stateEquals',
'stateEqualsOrDefault',
'stateMatches',
'configIsFalse',
'configIsTrue',
'configContains',
'clientConfigIsTrue',
'clientConfigIsFalse',
]);
function isItMemberCall(node, name) {
return (
node?.type === 'CallExpression' &&
node.callee?.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.object?.type === 'Identifier' &&
node.callee.object.name === 'it' &&
node.callee.property?.type === 'Identifier' &&
(name === undefined || node.callee.property.name === name)
);
}
function literalValue(node) {
if (!node) {
return undefined;
}
if (node.type === 'Literal') {
return node.value;
}
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
return node.quasis[0]?.value?.cooked;
}
return undefined;
}
function serializeArg(node) {
// Regex literals must be keyed by pattern+flags. literalValue returns a
// RegExp whose JSON.stringify is "{}", which would collapse distinct patterns.
if (node?.type === 'Literal' && node.regex) {
return `re:${JSON.stringify(node.regex.pattern)}:${JSON.stringify(node.regex.flags)}`;
}
const value = literalValue(node);
if (value === undefined) {
if (node?.type === 'Identifier') {
return `id:${node.name}`;
}
if (node?.type === 'MemberExpression') {
return `mem:${serializeArg(node.object)}.${node.property?.name ?? '?'}`;
}
if (node?.type === 'UnaryExpression' && node.operator === '!' && node.argument?.type === 'Literal') {
return `!${JSON.stringify(node.argument.value)}`;
}
return null;
}
return JSON.stringify(value);
}
function canonicalize(node) {
if (!node) {
return null;
}
if (isItMemberCall(node, 'not') && node.arguments.length === 1) {
const inner = canonicalize(node.arguments[0]);
return inner ? `not:${inner}` : null;
}
if (!isItMemberCall(node) || node.arguments.length === 0) {
return null;
}
const name = node.callee.property.name;
if (!CONFIG_HELPERS.has(name)) {
return null;
}
const args = node.arguments.map(serializeArg);
if (args.some((a) => a === null)) {
return null;
}
return `${name}:${args.join(',')}`;
}
function collectTopLevelConditions(node, out) {
if (!node) {
return;
}
if (isItMemberCall(node, 'any')) {
for (const arg of node.arguments) {
collectTopLevelConditions(arg, out);
}
return;
}
const key = canonicalize(node);
if (key) {
out.push({key, node});
}
}
function getProperty(objectExpression, name) {
if (objectExpression?.type !== 'ObjectExpression') {
return null;
}
for (const prop of objectExpression.properties) {
if (prop.type !== 'Property' || prop.computed) {
continue;
}
const keyName = getPropertyKeyName(prop.key);
if (keyName === name) {
return prop;
}
}
return null;
}
function getPropertyKeyName(keyNode) {
if (keyNode.type === 'Identifier') {
return keyNode.name;
}
if (keyNode.type === 'Literal') {
return keyNode.value;
}
return null;
}
function getSettingKey(objectExpression) {
const keyProp = getProperty(objectExpression, 'key');
if (!keyProp) {
return null;
}
return literalValue(keyProp.value);
}
function getSettingType(objectExpression) {
const typeProp = getProperty(objectExpression, 'type');
if (!typeProp) {
return null;
}
return literalValue(typeProp.value);
}
function parseSerializedStringArg(serialized) {
try {
const value = JSON.parse(serialized);
return typeof value === 'string' ? value : undefined;
} catch {
return undefined;
}
}
function dependencyParentKeys(conditions) {
const parents = [];
for (const {key} of conditions) {
// stateIsFalse:"Section.Setting" or not:stateIsTrue:"Section.Setting"
let match = (/^stateIsFalse:(.*)$/).exec(key);
if (match) {
const value = parseSerializedStringArg(match[1]);
if (value !== undefined) {
parents.push(value);
}
continue;
}
match = (/^not:stateIsTrue:(.*)$/).exec(key);
if (match) {
const value = parseSerializedStringArg(match[1]);
if (value !== undefined) {
parents.push(value);
}
}
}
return parents;
}
function isBoolSetting(key, settingsByKey) {
const defs = settingsByKey.get(key) || [];
return defs.some((def) => def.settingType === 'bool');
}
/**
* Build the full bool-parent dependency tree once, then derive inherited
* condition sets. Reporting uses this snapshot so parent attribution does not
* depend on isDisabled declaration order.
*
* @returns {Map<string, {
* isBool: boolean,
* directConditions: Set<string>,
* boolParents: string[],
* inheritedConditions: Set<string>,
* ancestors: Set<string>,
* }>}
*/
function buildDependencyTree(settingsByKey) {
const tree = new Map();
for (const [key, defs] of settingsByKey) {
const directConditions = new Set();
const parentKeys = [];
let isBool = false;
for (const def of defs) {
if (def.settingType === 'bool') {
isBool = true;
}
for (const {key: conditionKey} of def.conditions) {
directConditions.add(conditionKey);
}
for (const parentKey of def.parentKeys) {
if (!parentKeys.includes(parentKey)) {
parentKeys.push(parentKey);
}
}
}
tree.set(key, {
isBool,
directConditions,
boolParents: parentKeys.filter((parentKey) => isBoolSetting(parentKey, settingsByKey)),
inheritedConditions: new Set(),
ancestors: new Set(),
});
}
const walked = new Set();
function walk(settingKey, visiting) {
const node = tree.get(settingKey);
if (!node || walked.has(settingKey)) {
return node;
}
if (visiting.has(settingKey)) {
return node;
}
visiting.add(settingKey);
const inheritedConditions = new Set();
const ancestors = new Set();
for (const parentKey of node.boolParents) {
ancestors.add(parentKey);
const parentNode = walk(parentKey, visiting);
if (!parentNode) {
continue;
}
for (const conditionKey of parentNode.directConditions) {
inheritedConditions.add(conditionKey);
}
for (const conditionKey of parentNode.inheritedConditions) {
inheritedConditions.add(conditionKey);
}
for (const ancestorKey of parentNode.ancestors) {
ancestors.add(ancestorKey);
}
}
node.inheritedConditions = inheritedConditions;
node.ancestors = ancestors;
walked.add(settingKey);
visiting.delete(settingKey);
return node;
}
for (const key of tree.keys()) {
walk(key, new Set());
}
return tree;
}
function nodeImpliesCondition(node, conditionKey) {
return node.directConditions.has(conditionKey) || node.inheritedConditions.has(conditionKey);
}
/**
* Among immediate bool parents that imply the condition, pick the closest /
* most specific one: a parent that is a descendant of another candidate wins
* over that ancestor. Declaration order is ignored.
*/
function closestImplyingParent(settingKey, conditionKey, tree) {
const node = tree.get(settingKey);
if (!node) {
return null;
}
const candidates = node.boolParents.filter((parentKey) => {
const parentNode = tree.get(parentKey);
return parentNode && nodeImpliesCondition(parentNode, conditionKey);
});
if (candidates.length === 0) {
return null;
}
return candidates.reduce((best, candidate) => {
const bestNode = tree.get(best);
const candidateNode = tree.get(candidate);
// Prefer the candidate that is under the current best (more specific).
if (candidateNode.ancestors.has(best)) {
return candidate;
}
// Keep best when it is under the candidate.
if (bestNode.ancestors.has(candidate)) {
return best;
}
// Disjoint parents that both imply the condition: stable tie-break by key.
return candidate < best ? candidate : best;
});
}
export default {
meta: {
type: 'problem',
docs: {
description: 'Disallow System Console isDisabled conditions already implied by a parent config dependency',
},
schema: [],
messages: {
redundant:
"Redundant isDisabled condition '{{condition}}' on '{{setting}}' — already implied by dependency on '{{parent}}'. Depend on the parent setting and omit duplicated config checks.",
},
},
create(context) {
const filename = context.filename || context.getFilename();
if (!(/admin_definition/).test(filename)) {
return {};
}
const settings = [];
return {
ObjectExpression(node) {
const settingKey = getSettingKey(node);
if (typeof settingKey !== 'string' || !settingKey.includes('.')) {
return;
}
const settingType = getSettingType(node);
if (typeof settingType !== 'string') {
return;
}
const isDisabledProp = getProperty(node, 'isDisabled');
if (!isDisabledProp) {
return;
}
const conditions = [];
collectTopLevelConditions(isDisabledProp.value, conditions);
if (conditions.length === 0) {
return;
}
settings.push({
key: settingKey,
settingType,
conditions,
parentKeys: dependencyParentKeys(conditions),
node,
});
},
'Program:exit'() {
const settingsByKey = new Map();
for (const setting of settings) {
if (!settingsByKey.has(setting.key)) {
settingsByKey.set(setting.key, []);
}
settingsByKey.get(setting.key).push(setting);
}
// Build the complete tree before attributing any parent.
const tree = buildDependencyTree(settingsByKey);
for (const setting of settings) {
const node = tree.get(setting.key);
if (!node || node.boolParents.length === 0 || node.inheritedConditions.size === 0) {
continue;
}
for (const {key, node: conditionNode} of setting.conditions) {
if (!node.inheritedConditions.has(key)) {
continue;
}
const implyingParent = closestImplyingParent(setting.key, key, tree);
if (!implyingParent) {
continue;
}
context.report({
node: conditionNode,
messageId: 'redundant',
data: {
condition: key,
setting: setting.key,
parent: implyingParent,
},
});
}
}
},
};
},
};

View File

@@ -0,0 +1,339 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {RuleTester} from 'eslint';
import rule from './no-redundant-admin-config-deps.js';
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});
const filename = 'admin_definition.tsx';
ruleTester.run('no-redundant-admin-config-deps', rule, {
valid: [
{
filename,
code: `
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'EmailSettings.EnableEmailBatching',
isDisabled: it.any(
it.stateIsFalse('EmailSettings.SendEmailNotifications'),
it.configIsTrue('ClusterSettings', 'Enable'),
),
},
{
type: 'number',
key: 'EmailSettings.EmailBatchingBufferSize',
isDisabled: it.any(
it.stateIsFalse('EmailSettings.EnableEmailBatching'),
),
},
],
};
`,
},
{
filename,
code: `
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'ServiceSettings.EnableOAuthServiceProvider',
isDisabled: it.not(it.userHasWritePermissionOnResource('integrations')),
},
{
type: 'bool',
key: 'ServiceSettings.EnableDynamicClientRegistration',
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource('integrations')),
it.stateIsFalse('ServiceSettings.EnableOAuthServiceProvider'),
),
},
{
type: 'text',
key: 'ServiceSettings.DCRRedirectURIAllowlist',
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource('integrations')),
it.stateIsFalse('ServiceSettings.EnableDynamicClientRegistration'),
),
},
],
};
`,
},
{
// Non-bool parents (fileupload) do not imply their isDisabled conditions,
// so repeating Encrypt alongside PrivateKeyFile is intentional.
filename,
code: `
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'SamlSettings.Encrypt',
isDisabled: it.stateIsFalse('SamlSettings.Enable'),
},
{
type: 'fileupload',
key: 'SamlSettings.PrivateKeyFile',
isDisabled: it.stateIsFalse('SamlSettings.Encrypt'),
},
{
type: 'bool',
key: 'SamlSettings.SignRequest',
isDisabled: it.any(
it.stateIsFalse('SamlSettings.Encrypt'),
it.stateIsFalse('SamlSettings.PrivateKeyFile'),
),
},
],
};
`,
},
{
// Distinct regex patterns on the same helper+key are not the same condition.
filename,
code: `
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'SupportSettings.Enable',
isDisabled: it.stateMatches('SupportSettings.ReportAProblemType', /link/),
},
{
type: 'text',
key: 'SupportSettings.Mail',
isDisabled: it.any(
it.stateIsFalse('SupportSettings.Enable'),
it.stateMatches('SupportSettings.ReportAProblemType', /email/),
),
},
],
};
`,
},
{
// Identifier parent keys must not throw during canonicalize/JSON.parse.
filename,
code: `
const PARENT_KEY = 'ServiceSettings.EnableOAuthServiceProvider';
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'ServiceSettings.EnableDynamicClientRegistration',
isDisabled: it.stateIsFalse(PARENT_KEY),
},
{
type: 'text',
key: 'ServiceSettings.DCRRedirectURIAllowlist',
isDisabled: it.stateIsFalse('ServiceSettings.EnableDynamicClientRegistration'),
},
],
};
`,
},
{
// Non-admin_definition files are ignored
filename: 'other_file.tsx',
code: `
const x = {
type: 'number',
key: 'EmailSettings.EmailBatchingBufferSize',
isDisabled: it.any(
it.stateIsFalse('EmailSettings.SendEmailNotifications'),
it.stateIsFalse('EmailSettings.EnableEmailBatching'),
),
};
const y = {
type: 'bool',
key: 'EmailSettings.EnableEmailBatching',
isDisabled: it.stateIsFalse('EmailSettings.SendEmailNotifications'),
};
`,
},
],
invalid: [
{
filename,
code: `
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'EmailSettings.EnableEmailBatching',
isDisabled: it.any(
it.stateIsFalse('EmailSettings.SendEmailNotifications'),
it.configIsTrue('ClusterSettings', 'Enable'),
it.configIsFalse('ServiceSettings', 'SiteURL'),
),
},
{
type: 'number',
key: 'EmailSettings.EmailBatchingBufferSize',
isDisabled: it.any(
it.stateIsFalse('EmailSettings.SendEmailNotifications'),
it.stateIsFalse('EmailSettings.EnableEmailBatching'),
it.configIsTrue('ClusterSettings', 'Enable'),
it.configIsFalse('ServiceSettings', 'SiteURL'),
),
},
],
};
`,
errors: [
{messageId: 'redundant', data: {condition: 'stateIsFalse:"EmailSettings.SendEmailNotifications"', setting: 'EmailSettings.EmailBatchingBufferSize', parent: 'EmailSettings.EnableEmailBatching'}},
{messageId: 'redundant', data: {condition: 'configIsTrue:"ClusterSettings","Enable"', setting: 'EmailSettings.EmailBatchingBufferSize', parent: 'EmailSettings.EnableEmailBatching'}},
{messageId: 'redundant', data: {condition: 'configIsFalse:"ServiceSettings","SiteURL"', setting: 'EmailSettings.EmailBatchingBufferSize', parent: 'EmailSettings.EnableEmailBatching'}},
],
},
{
filename,
code: `
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'ServiceSettings.EnableOAuthServiceProvider',
isDisabled: it.not(it.userHasWritePermissionOnResource('integrations')),
},
{
type: 'bool',
key: 'ServiceSettings.EnableDynamicClientRegistration',
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource('integrations')),
it.stateIsFalse('ServiceSettings.EnableOAuthServiceProvider'),
),
},
{
type: 'text',
key: 'ServiceSettings.DCRRedirectURIAllowlist',
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource('integrations')),
it.stateIsFalse('ServiceSettings.EnableOAuthServiceProvider'),
it.stateIsFalse('ServiceSettings.EnableDynamicClientRegistration'),
),
},
],
};
`,
errors: [
{messageId: 'redundant', data: {condition: 'stateIsFalse:"ServiceSettings.EnableOAuthServiceProvider"', setting: 'ServiceSettings.DCRRedirectURIAllowlist', parent: 'ServiceSettings.EnableDynamicClientRegistration'}},
],
},
{
// Bool parent Encrypt implies Enable; PrivateKeyFile should not repeat Enable
filename,
code: `
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'SamlSettings.Encrypt',
isDisabled: it.stateIsFalse('SamlSettings.Enable'),
},
{
type: 'fileupload',
key: 'SamlSettings.PrivateKeyFile',
isDisabled: it.any(
it.stateIsFalse('SamlSettings.Enable'),
it.stateIsFalse('SamlSettings.Encrypt'),
),
},
],
};
`,
errors: [
{messageId: 'redundant', data: {condition: 'stateIsFalse:"SamlSettings.Enable"', setting: 'SamlSettings.PrivateKeyFile', parent: 'SamlSettings.Encrypt'}},
],
},
{
// Grandparent listed before parent: still attribute to the closest parent (Mid),
// not Root, after the full tree is built.
filename,
code: `
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'FeatureSettings.Root',
isDisabled: it.configIsTrue('ClusterSettings', 'Enable'),
},
{
type: 'bool',
key: 'FeatureSettings.Mid',
isDisabled: it.any(
it.stateIsFalse('FeatureSettings.Root'),
it.configIsTrue('ClusterSettings', 'Enable'),
),
},
{
type: 'number',
key: 'FeatureSettings.Leaf',
isDisabled: it.any(
it.stateIsFalse('FeatureSettings.Root'),
it.stateIsFalse('FeatureSettings.Mid'),
it.configIsTrue('ClusterSettings', 'Enable'),
),
},
],
};
`,
errors: [
{messageId: 'redundant', data: {condition: 'configIsTrue:"ClusterSettings","Enable"', setting: 'FeatureSettings.Mid', parent: 'FeatureSettings.Root'}},
{messageId: 'redundant', data: {condition: 'stateIsFalse:"FeatureSettings.Root"', setting: 'FeatureSettings.Leaf', parent: 'FeatureSettings.Mid'}},
{messageId: 'redundant', data: {condition: 'configIsTrue:"ClusterSettings","Enable"', setting: 'FeatureSettings.Leaf', parent: 'FeatureSettings.Mid'}},
],
},
{
// Same regex on parent and child is redundant; distinct patterns are not.
filename,
code: `
const AdminDefinition = {
settings: [
{
type: 'bool',
key: 'SupportSettings.Enable',
isDisabled: it.stateMatches('SupportSettings.ReportAProblemType', /link/),
},
{
type: 'text',
key: 'SupportSettings.Mail',
isDisabled: it.any(
it.stateIsFalse('SupportSettings.Enable'),
it.stateMatches('SupportSettings.ReportAProblemType', /link/),
),
},
],
};
`,
errors: [
{messageId: 'redundant', data: {condition: 'stateMatches:"SupportSettings.ReportAProblemType",re:"link":""', setting: 'SupportSettings.Mail', parent: 'SupportSettings.Enable'}},
],
},
],
});

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import astUtils from 'jsx-ast-utils';
export default {
meta: {
docs: {
description: 'Enforce all anchors with target="_blank" to use ExternalLink component',
},
},
create: (context) => {
return {
JSXOpeningElement: (node) => {
const typeCheck = 'a';
const nodeType = astUtils.elementType(node);
// Only check anchor elements
if (!nodeType || typeCheck !== nodeType) {
return;
}
const propsToValidate = ['target'];
const values = propsToValidate.map((prop) => astUtils.getPropValue(astUtils.getProp(node.attributes, prop)));
// Checks if the target attribute is set to _blank (ie, is an external link)
const hasBlankTarget = values.some((value) => value != null && value === '_blank');
// When there is no target value at all, this rule does not apply:
if (!hasBlankTarget) {
return;
}
context.report({
node,
message: 'Use ExternalLink component (components/external_link) for _blank target link-outs',
});
},
};
},
};

View File

@@ -0,0 +1,11 @@
# mattermost-redux
This package contains a large part of the [Redux](http://redux.js.org) code and some utilities used by [the Mattermost web app](https://github.com/mattermost/mattermost/tree/master/webapp/channels) for usage in Mattermost plugins and other related projects.
This code doesn't strictly follow semantic versioning as it is mostly internal code to Mattermost, and it will feature breaking changes outside of major releases, but it can be considered stable for the most part.
## Installation
```sh
$ npm install mattermost-redux @mattermost/types @mattermost/client
```

View File

@@ -0,0 +1,67 @@
{
"name": "mattermost-redux",
"version": "12.0.0",
"description": "Common code (API client, Redux stores, logic, utility functions) for building a Mattermost client",
"keywords": [
"mattermost"
],
"homepage": "https://github.com/mattermost/mattermost/tree/master/webapp/platform/mattermost-redux#readme",
"license": "MIT",
"type": "commonjs",
"files": [
"lib"
],
"exports": {
"./action_types": "./lib/action_types/index.js",
"./actions": "./lib/actions/index.js",
"./client": "./lib/client/index.js",
"./constants": "./lib/constants/index.js",
"./reducers": "./lib/reducers/index.js",
"./reducers/entities": "./lib/reducers/entities/index.js",
"./reducers/entities/threads": "./lib/reducers/entities/threads/index.js",
"./reducers/errors": "./lib/reducers/errors/index.js",
"./reducers/requests": "./lib/reducers/requests/index.js",
"./selectors/create_selector": "./lib/selectors/create_selector/index.js",
"./store": "./lib/store/index.js",
"./*": "./lib/*.js"
},
"typesVersions": {
">=3.1": {
"*": [
"./lib/*.d.ts",
"./lib/*/index.d.ts"
]
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/mattermost/mattermost.git",
"directory": "webapp/platform/mattermost-redux"
},
"dependencies": {
"@mattermost/client": "12.0.0",
"@mattermost/types": "12.0.0",
"@redux-devtools/extension": "3.3.0",
"lodash": "^4.17.21",
"moment-timezone": "^0.5.38",
"redux": "^5.0.0",
"redux-batched-actions": "0.5.0",
"redux-thunk": "^3.0.0",
"serialize-error": "^11.0.3",
"shallow-equals": "^1.0.0",
"timezones.json": "^1.7.1"
},
"peerDependencies": {
"typescript": "^4.3.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"scripts": {
"build": "tsc --build --verbose",
"postbuild": "sh postbuild.sh",
"clean": "rm -rf lib node_modules *.tsbuildinfo"
}
}

View File

@@ -0,0 +1,3 @@
cp ../../channels/src/packages/mattermost-redux/src/selectors/create_selector/index.d.ts lib/selectors/create_selector/index.d.ts
cp ../../channels/src/packages/mattermost-redux/src/types/extend_redux.d.ts lib/types/extend_redux.d.ts
cp ../../channels/src/packages/mattermost-redux/src/types/extend_react_redux.d.ts lib/types/extend_react_redux.d.ts

View File

@@ -0,0 +1,38 @@
{
"compilerOptions": {
"allowJs": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "ES2022",
"lib": [
"ES2022"
],
"declaration": true,
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react",
"preserveConstEnums": true,
"outDir": "./lib",
"rootDir": "../../channels/src/packages/mattermost-redux/src",
"composite": true,
"useUnknownInCatchVariables": false,
"skipLibCheck": true,
"tsBuildInfoFile": "tsconfig.tsbuildinfo",
"paths": {
"mattermost-redux/*": ["../../channels/src/packages/mattermost-redux/src/*"]
}
},
"include": [
"../../channels/src/packages/mattermost-redux/src/**/*"
],
"exclude": [
"../../**/*.test.*"
],
"references": [
{"path": "../types"},
{"path": "../client"}
]
}

View File

@@ -0,0 +1,5 @@
{
"extends": "@parcel/config-default",
"bundler": "@parcel/bundler-library",
"namers": ["./build/parcel-namer-shared", "..."]
}

View File

@@ -0,0 +1 @@
dist/

View File

@@ -0,0 +1,42 @@
{
"extends": [
"stylelint-config-idiomatic-order",
"stylelint-config-recommended-scss"
],
"plugins": [
"@stylistic/stylelint-plugin"
],
"rules": {
"@stylistic/indentation": 4,
"@stylistic/no-missing-end-of-source-newline": true,
"no-descending-specificity": null,
"font-family-no-missing-generic-family-keyword": null,
"property-no-unknown": [
true,
{
"ignoreProperties": ["scrollbar-3dlight-color"]
}
],
"block-no-empty": [
true,
{
"ignore": ["comments"]
}
],
"declaration-property-value-disallowed-list": [
{
"color": ["/--denim-button-bg/"],
"background-color": ["/--denim-button-bg/"],
"border-color": ["/--denim-button-bg/"],
"background": ["/--denim-button-bg/"],
"border": ["/--denim-button-bg/"],
"fill": ["/--denim-button-bg/"]
},
{
"message": "The --denim-button-bg and --denim-button-bg-rgb variables are deprecated. Please use --button-bg or --button-bg-rgb instead."
}
],
"scss/load-no-partial-leading-underscore": null,
"scss/at-extend-no-missing-placeholder": null
}
}

View File

@@ -0,0 +1,32 @@
# Mattermost Shared Package
[![npm version](https://img.shields.io/npm/v/@mattermost/shared?style=flat)](https://www.npmjs.com/package/@mattermost/shared)
This package contains shared components and other utilities for use by the Mattermost web app and its plugins.
> [!CAUTION]
> This is a pre-release package in active development. It is currently for internal use only, and it may change significantly between now and when it is fully released.
## Installation
This package requires a matching version of the `@mattermost/types` package. It also requires [React](https://react.dev/) and [React Intl](https://formatjs.github.io/docs/react-intl/).
```sh
$ npm install @mattermost/shared @mattermost/types
```
Additionally, if you're writing unit tests involving these components, [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) is also required.
```sh
$ npm install @testing-library/react
```
## Usage
TODO
## Development
### Compilation and Packaging
TODO

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as path from 'node:path';
import {Namer} from '@parcel/plugin';
import type {FilePath} from '@parcel/types';
/**
* This Namer changes how Parcel outputs its files to put them into subfolders based on where they were originally in
* the source folder.
*
* By default, files output by Parcel are not put into subfolders of dist, and they instead rely on hashes to
* differentiate between them. We want to be able to import those directly, so
*/
export default new Namer({
async name(opts): Promise<FilePath | null | undefined> {
const {bundle} = opts;
const mainEntry = bundle.getMainEntry();
if (!mainEntry) {
return null;
}
// Get the relative file path within the source folder
const relativeDir = path.posix.relative('./src', path.dirname(mainEntry.filePath));
let filename;
if (bundle.type === 'js') {
// Rename generated JS files from FILE.js to FILE.TARGET.js or FILE.TARGET.js to fix naming conflict
// between CommonJS and ESM files
filename = path.basename(mainEntry.filePath, path.extname(mainEntry.filePath));
filename += '.' + bundle.target.name + '.js';
} else if (bundle.type === 'ts') {
filename = path.basename(mainEntry.filePath, path.extname(mainEntry.filePath)) + '.d.ts';
} else {
filename = bundle.target.name + path.extname(mainEntry.filePath);
}
const newPath = path.posix.join(relativeDir, filename);
return newPath;
},
});

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This script is intended to be used by Mattermost plugins to set up their Webpack externals to share their
// dependencies with the web app. It includes both third party dependencies (React, etc) and the MM Shared package.
const windowExternals = {
react: 'React',
'react-dom': 'ReactDOM',
redux: 'Redux',
luxon: 'Luxon',
'react-redux': 'ReactRedux',
'prop-types': 'PropTypes',
'react-bootstrap': 'ReactBootstrap',
'react-router-dom': 'ReactRouterDom',
'react-intl': 'ReactIntl',
};
function webAppExternals() {
return [
windowExternals,
({request}, callback) => {
if ((/^@mattermost\/shared\//).test(request)) {
return callback(null, `promise globalThis.loadSharedDependency('${request}')`);
}
return callback();
},
];
}
module.exports = webAppExternals;

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import formatjsPlugin from 'eslint-plugin-formatjs';
import noOnlyTestsPlugin from 'eslint-plugin-no-only-tests';
import eslintPlugin from '@mattermost/eslint-plugin';
export default [
...eslintPlugin.configs.react,
{
files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'],
plugins: {
formatjs: formatjsPlugin,
},
},
{
files: ['**/*.test.js', '**/*.test.jsx', '**/*.test.ts', '**/*.test.tsx'],
plugins: {
'no-only-tests': noOnlyTestsPlugin,
},
rules: {
'no-only-tests/no-only-tests': ['error', {focus: ['only', 'skip']}],
},
},
];

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/** @type {import('jest').Config} */
export default {
moduleDirectories: ['src', 'node_modules'],
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
clearMocks: true,
moduleNameMapper: {
'^.+\\.css$': 'identity-obj-proxy',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
transform: {
'^.+\\.(t|j)sx?$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
importAssertions: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
},
},
],
},
setupFilesAfterEnv: ['<rootDir>/setup_jest.ts'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
],
coveragePathIgnorePatterns: [
'/node_modules/',
'/dist/',
],
coverageReporters: ['json', 'lcov', 'text-summary'],
};

View File

@@ -0,0 +1,112 @@
{
"name": "@mattermost/shared",
"version": "12.0.0",
"description": "Shared components and utilities for use by the Mattermost web app and its plugins",
"keywords": [
"mattermost"
],
"homepage": "https://github.com/mattermost/mattermost/tree/master/webapp/platform/shared#readme",
"license": "MIT",
"files": [
"build/webpack-web-app-externals.cjs",
"dist"
],
"main": "dist/main.js",
"module": "dist/module.js",
"source": "src/**/index.ts",
"types": "dist/types.d.ts",
"typesVersions": {
">=3.1": {
"*": [
"./dist/*/index.d.ts"
]
}
},
"exports": {
".": {
"types": [
"./src/index.ts",
"./dist/types.d.ts"
],
"source": "./src/index.ts",
"import": "./dist/module.js",
"require": "./dist/main.js"
},
"./build/webpack-web-app-externals": {
"source": "./build/webpack-web-app-externals.cjs",
"require": "./build/webpack-web-app-externals.cjs"
},
"./*": {
"types": [
"./src/*/index.ts",
"./dist/*/index.d.ts"
],
"source": "./src/*/index.ts",
"import": "./dist/*/index.module.js",
"require": "./dist/*/index.main.js"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/mattermost/mattermost.git",
"directory": "webapp/platform/shared"
},
"dependencies": {
"@floating-ui/react": "^0.27.20",
"classnames": "^2.3.1"
},
"devDependencies": {
"@mattermost/eslint-plugin": "*",
"@parcel/bundler-library": "^2.16.3",
"@parcel/packager-ts": "^2.16.3",
"@parcel/transformer-typescript-types": "^2.16.3",
"@stylistic/stylelint-plugin": "^3.1.2",
"@swc/core": "^1.3.36",
"@swc/jest": "^0.2.36",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.4",
"jest": "^30.1.3",
"jest-environment-jsdom": "^30.1.0",
"parcel": "^2.16.3",
"react": "^19.2.8",
"stylelint": "^16.10.0",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-recommended": "^14.0.1",
"stylelint-order": "^6.0.4",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@mattermost/types": "12.0.0",
"@testing-library/react": "^16",
"react": ">=17",
"react-intl": ">=7",
"typescript": "^4.3.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"@testing-library/react": {
"optional": true
},
"typescript": {
"optional": true
}
},
"scripts": {
"build": "parcel build --no-optimize",
"check": "npm run check:eslint && npm run check:stylelint",
"check:eslint": "eslint ./src eslint.config.mjs --quiet",
"check:stylelint": "stylelint \"**/*.{css,scss}\"",
"check-types": "tsc -b",
"fix": "eslint ./src eslint.config.mjs --quiet --fix && stylelint \"**/*.{css,scss}\" --fix",
"run": "parcel watch --watch-dir .",
"test": "jest",
"test-ci": "jest --ci --forceExit --detectOpenHandles --maxWorkers=100% --logHeapUsage",
"clean": "rm -rf dist node_modules *.tsbuildinfo .parcel-cache"
},
"@parcel/resolver-default": {
"packageExports": true
}
}

View File

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {buttonClassNames, type ButtonEmphasis, type ButtonSize, type ButtonVariant} from './button_classes';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
/**
* The emphasis level of the button. Controls the colors of the button.
*
* Defaults to "primary".
*/
emphasis?: ButtonEmphasis;
/**
* The size of the button as defined in our Compass Design System. Controls the height and font size of the button.
*
* Defaults to "md".
*/
size?: ButtonSize;
/**
* The variant of the button. Modifies the color defined by the emphasis level.
*
* Defaults to "".
*/
variant?: ButtonVariant;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
children,
className,
emphasis,
size,
variant,
...otherProps
}, ref) => {
return (
<button
ref={ref}
className={buttonClassNames({emphasis, size, variant}, className)}
{...otherProps}
>
{children}
</button>
);
});
Button.displayName = 'Button';

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
export type ButtonEmphasis = 'primary' | 'secondary' | 'tertiary' | 'quaternary';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';
export type ButtonVariant = '' | 'destructive' | 'inverted';
const emphasisClasses = {
primary: 'btn-primary',
secondary: 'btn-secondary',
tertiary: 'btn-tertiary',
quaternary: 'btn-quaternary',
};
const sizeClasses = {
xs: 'btn-xs',
sm: 'btn-sm',
md: '',
lg: 'btn-lg',
};
const variantClasses = {
'': '',
destructive: 'btn-danger',
inverted: 'btn-inverted',
};
export interface ButtonStyles {
emphasis?: ButtonEmphasis;
size?: ButtonSize;
variant?: ButtonVariant;
}
export function buttonClassNames({emphasis = 'primary', size = 'md', variant = ''}: ButtonStyles, ...other: classNames.ArgumentArray) {
let emphasisClass = emphasisClasses[emphasis];
const sizeClass = sizeClasses[size];
const variantClass = variantClasses[variant];
if (emphasis === 'primary' && variant === 'destructive') {
// TODO in the current CSS, btn-primary overrides btn-danger
emphasisClass = '';
}
return classNames('btn', emphasisClass, sizeClass, variantClass, other);
}

View File

@@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {Button} from './button';
export type {ButtonProps} from './button';
export {buttonClassNames} from './button_classes';
export type {ButtonEmphasis, ButtonSize, ButtonVariant} from './button_classes';

View File

@@ -0,0 +1 @@
/* The styles for this component are still defined in the web app */

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import {Emoji} from './emoji';
import {renderWithContext} from '../../testing';
import '@testing-library/jest-dom';
describe('Emoji', () => {
test('should render nothing when no emoji name is provided', () => {
renderWithContext(
<Emoji emojiName=''/>,
);
expect(document.querySelector('.emoticon')).not.toBeInTheDocument();
});
test('should render the provided system emoji', () => {
renderWithContext(
<Emoji emojiName='smiley'/>,
);
expect(document.querySelector('.emoticon')).toBe(screen.getByLabelText(':smiley:'));
expect(screen.getByLabelText(':smiley:')).toBeInTheDocument();
expect(screen.getByLabelText(':smiley:')).toHaveStyle({
backgroundImage: '/static/emoji/1F603.png',
});
});
test('should render the provided custom emoji', () => {
renderWithContext(
<Emoji emojiName='custom-emoji-1'/>,
);
expect(document.querySelector('.emoticon')).toBe(screen.getByLabelText(':custom-emoji-1:'));
expect(screen.getByLabelText(':custom-emoji-1:')).toBeInTheDocument();
expect(screen.getByLabelText(':custom-emoji-1:')).toHaveStyle({
backgroundImage: '/api/v4/emojis/custom-emoji-id-1/image',
});
});
});

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {KeyboardEvent, MouseEvent} from 'react';
import React from 'react';
import {useEmojiByName} from '../../context/useEmojiByName';
import {useEmojiUrl} from '../../context/useEmojiUrl';
import './emoji.css';
const emptyEmojiStyle = {};
export interface EmojiProps {
emojiName: string;
size?: number;
emojiStyle?: React.CSSProperties;
// TODO remove this prop and move the click handler a proper button
onClick?: (event: MouseEvent<HTMLSpanElement> | KeyboardEvent<HTMLSpanElement>) => void;
}
export function Emoji({
emojiName,
emojiStyle = emptyEmojiStyle,
size = 16,
onClick,
}: EmojiProps) {
const emoji = useEmojiByName(emojiName);
const emojiImageUrl = useEmojiUrl(emoji);
if (!emoji || !emojiImageUrl) {
return null;
}
return (
<span
onClick={onClick}
className='emoticon'
aria-label={`:${emojiName}:`}
data-emoticon={emojiName}
style={{
backgroundImage: `url(${emojiImageUrl})`,
backgroundSize: 'contain',
height: size,
width: size,
maxHeight: size,
maxWidth: size,
minHeight: size,
minWidth: size,
overflow: 'hidden',
...emojiStyle,
}}
/>
);
}

View File

@@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {Emoji} from './emoji';
export type {EmojiProps} from './emoji';

View File

@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`components/ShortcutKey should match snapshot for contrast key 1`] = `
<div>
<mark
class="shortcut-key shortcut-key--contrast"
>
Shift
</mark>
</div>
`;
exports[`components/ShortcutKey should match snapshot for regular key 1`] = `
<div>
<mark
class="shortcut-key"
>
Shift
</mark>
</div>
`;

View File

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {ShortcutKeys} from './keys';
export {ShortcutKey, ShortcutKeyVariant} from './shortcut_key';
export type {ShortcutKeyProps} from './shortcut_key';

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {defineMessage} from 'react-intl';
export const ShortcutKeys = {
alt: defineMessage({
id: 'shortcuts.generic.alt',
defaultMessage: 'Alt',
}),
cmd: '⌘',
ctrl: defineMessage({
id: 'shortcuts.generic.ctrl',
defaultMessage: 'Ctrl',
}),
enter: defineMessage({
id: 'shortcuts.generic.enter',
defaultMessage: 'Enter',
}),
escape: defineMessage({
id: 'general_button.esc',
defaultMessage: 'Esc',
}),
option: '⌥',
shift: defineMessage({
id: 'shortcuts.generic.shift',
defaultMessage: 'Shift',
}),
};

View File

@@ -0,0 +1,52 @@
.shortcut-key {
display: inline-block;
padding: 10px 9px 8px 8px;
border-radius: 4px;
background: var(--center-channel-color);
color: var(--center-channel-bg);
font-size: 14px;
font-weight: 500;
line-height: 1;
&.shortcut-key--contrast {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
color: var(--center-channel-color);
}
&.shortcut-key--tooltip {
padding: 2px 5px;
background-color: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.75);
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
&.shortcut-key--tutorial-tip {
padding: 2px 5px;
background-color: rgba(var(--button-color-rgb), 0.08);
color: rgba(var(--button-color-rgb), 0.75);
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
&.shortcut-key--shortcut-modal {
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
&.shortcut-key--inline-content {
padding: 2px 5px;
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.75);
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {ShortcutKey, ShortcutKeyVariant} from './shortcut_key';
import {renderWithContext} from '../../testing/react_testing_utils';
describe('components/ShortcutKey', () => {
test('should match snapshot for regular key', () => {
const {container} = renderWithContext(<ShortcutKey>{'Shift'}</ShortcutKey>);
expect(container).toMatchSnapshot();
});
test('should match snapshot for contrast key', () => {
const {container} = renderWithContext(<ShortcutKey variant={ShortcutKeyVariant.Contrast}>{'Shift'}</ShortcutKey>);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import './shortcut_key.css';
export enum ShortcutKeyVariant {
Contrast = 'contrast',
Tooltip = 'tooltip',
TutorialTip = 'tutorialTip',
ShortcutModal = 'shortcut',
InlineContent = 'inline-content',
}
export interface ShortcutKeyProps {
variant?: ShortcutKeyVariant;
children: React.ReactNode;
}
export function ShortcutKey({children, variant}: ShortcutKeyProps) {
return (
<mark
className={classNames('shortcut-key', {
'shortcut-key--contrast': variant === ShortcutKeyVariant.Contrast,
'shortcut-key--tooltip': variant === ShortcutKeyVariant.Tooltip,
'shortcut-key--tutorial-tip': variant === ShortcutKeyVariant.TutorialTip,
'shortcut-key--shortcut-modal': variant === ShortcutKeyVariant.ShortcutModal,
'shortcut-key--inline-content': variant === ShortcutKeyVariant.InlineContent,
})}
>
{children}
</mark>
);
}

View File

@@ -0,0 +1,140 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`TooltipContent have correct structure with just title 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Title
</span>
</span>
</div>
</div>
`;
exports[`TooltipContent have correct structure with title and emoji 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentEmoji"
>
<span
aria-label=":smile:"
class="emoticon"
data-emoticon="smile"
style="background-image: url("/static/emoji/1F604.png"); background-size: contain; height: 16px; width: 16px; max-height: 16px; max-width: 16px; min-height: 16px; min-width: 16px; overflow: hidden;"
/>
</span>
<span
class="tooltipContentTitle"
>
Title with Emoji
</span>
</span>
</div>
</div>
`;
exports[`TooltipContent have correct structure with title and large emoji 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer isEmojiLarge"
>
<span
class="tooltipContentEmoji"
>
<span
aria-label=":smile:"
class="emoticon"
data-emoticon="smile"
style="background-image: url("/static/emoji/1F604.png"); background-size: contain; height: 48px; width: 48px; max-height: 48px; max-width: 48px; min-height: 48px; min-width: 48px; overflow: hidden;"
/>
</span>
<span
class="tooltipContentTitle"
>
Title with Large Emoji
</span>
</span>
</div>
</div>
`;
exports[`TooltipContent have correct structure with title and shortcut 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentTitle"
>
Title with Shortcut
</span>
</span>
<span
class="tooltipContentShortcut"
>
<mark
class="shortcut-key shortcut-key--tooltip"
>
Ctrl
</mark>
<mark
class="shortcut-key shortcut-key--tooltip"
>
K
</mark>
</span>
</div>
</div>
`;
exports[`TooltipContent have correct structure with title, emoji and hint 1`] = `
<div>
<div
class="tooltipContent"
>
<span
class="tooltipContentTitleContainer"
>
<span
class="tooltipContentEmoji"
>
<span
aria-label=":smile:"
class="emoticon"
data-emoticon="smile"
style="background-image: url("/static/emoji/1F604.png"); background-size: contain; height: 16px; width: 16px; max-height: 16px; max-width: 16px; min-height: 16px; min-width: 16px; overflow: hidden;"
/>
</span>
<span
class="tooltipContentTitle"
>
Title with Hint
</span>
</span>
<span
class="tooltipContentHint"
>
This is a hint
</span>
</div>
</div>
`;

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Some of these constants duplicate ones in utils/constants.tsx in the web app.
export const RootHtmlPortalId = 'root-portal';
export const OverlaysTimings = {
CURSOR_REST_TIME_BEFORE_OPEN: 400, // in ms
CURSOR_MOUSEOVER_TO_OPEN: 400, // in ms
CURSOR_MOUSEOUT_TO_CLOSE: 0,
CURSOR_MOUSEOUT_TO_CLOSE_WITH_DELAY: 200, // in ms
FADE_IN_DURATION: 250, // in ms
FADE_OUT_DURATION: 150, // in ms
};
export const OverlayTransitionStyles = {
START: {
opacity: 0,
},
};

View File

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type {ShortcutDefinition} from './tooltip_shortcut';
export {WithTooltip} from './with_tooltip';
export type {WithTooltipProps} from './with_tooltip';

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import TooltipContent from './tooltip_content';
import {renderWithContext} from '../../testing';
describe('TooltipContent', () => {
test('have correct structure with just title', () => {
const {container} = renderWithContext(
<TooltipContent title='Title'/>,
);
expect(container).toMatchSnapshot();
});
test('have correct structure with title and emoji', () => {
const {container} = renderWithContext(
<TooltipContent
title='Title with Emoji'
emoji='smile'
/>,
);
expect(container).toMatchSnapshot();
});
test('have correct structure with title and large emoji', () => {
const {container} = renderWithContext(
<TooltipContent
title='Title with Large Emoji'
emoji='smile'
isEmojiLarge={true}
/>,
);
expect(container).toMatchSnapshot();
});
test('have correct structure with title, emoji and hint', () => {
const {container} = renderWithContext(
<TooltipContent
title='Title with Hint'
hint='This is a hint'
emoji='smile'
/>,
);
expect(container).toMatchSnapshot();
});
test('have correct structure with title and shortcut', () => {
const shortcut = {
default: ['Ctrl', 'K'],
};
const {container} = renderWithContext(
<TooltipContent
title='Title with Shortcut'
shortcut={shortcut}
/>,
);
expect(container).toMatchSnapshot();
});
});

Some files were not shown because too many files have changed in this diff Show More