MM-70151/MM-70152: fix slash commands in the WYSIWYG composer (#37880)

Co-authored-by: Nevyana Angelova <[email protected]>
This commit is contained in:
nang2049
2026-08-12 13:18:06 +03:00
committed by GitHub
co-authored by Nevyana Angelova
parent bd753cd18f
commit 747e429378
5 changed files with 439 additions and 11 deletions
@@ -29,6 +29,7 @@ import {editLatestPost} from 'actions/views/create_comment';
import {useDebounce} from 'hooks/useDebounce';
import {useLatest} from 'hooks/useLatest';
import {serializeToMarkdown} from './wysiwyg_markdown';
import WysiwygSuggestionList from './wysiwyg_suggestion_list';
import './wysiwyg_editor.scss';
@@ -175,19 +176,17 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
onChangeRef.current(md);
}, SERIALIZE_DEBOUNCE_MS);
const handleSuggestionSubmit = useCallback(() => {
onSubmitRef.current();
}, [onSubmitRef]);
const handleUpdate = useCallback(({editor}: {editor: Editor}) => {
if (jsonMode) {
debouncedOnChange(JSON.stringify(editor.getJSON()));
return;
}
// Strip &nbsp; artifacts the @tiptap/markdown serializer leaves around
// empty paragraphs at doc start/end.
const md = editor.getMarkdown().trimEnd().
replace(/\n\n&nbsp;\n/g, '\n').
replace(/\n\n&nbsp;$/g, '').
replace(/^&nbsp;$/, '');
debouncedOnChange(md);
debouncedOnChange(serializeToMarkdown(editor));
}, [debouncedOnChange, jsonMode]);
const baseExtensions: Extensions = [
@@ -537,6 +536,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
editor={editor}
channelId={channelId}
rootId={rootId}
onSubmit={handleSuggestionSubmit}
/>
</div>
);
@@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {JSONContent} from '@tiptap/core';
import {Editor} from '@tiptap/core';
import Link from '@tiptap/extension-link';
import {Markdown} from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import {isRedundantLinkText, serializeToMarkdown, stripRedundantLinkMarks} from './wysiwyg_markdown';
const paragraph = (...content: JSONContent[]): JSONContent => ({
type: 'doc',
content: [{type: 'paragraph', content}],
});
const linkText = (text: string, href: string): JSONContent => ({
type: 'text',
text,
marks: [{type: 'link', attrs: {href}}],
});
describe('isRedundantLinkText', () => {
it.each([
['https://mmtest.atlassian.net/', 'https://mmtest.atlassian.net/'],
['www.example.com', 'http://www.example.com'],
['example.com', 'https://example.com'],
['[email protected]', 'mailto:[email protected]'],
])('treats %s -> %s as redundant', (text, href) => {
expect(isRedundantLinkText(text, href)).toBe(true);
});
it.each([
['Mattermost', 'https://mattermost.com'],
['https://example.com', 'https://example.com/other'],
['', 'https://example.com'],
['https://example.com', ''],
])('keeps %s -> %s', (text, href) => {
expect(isRedundantLinkText(text, href)).toBe(false);
});
});
describe('stripRedundantLinkMarks', () => {
it('drops the mark when the text is the URL', () => {
const stripped = stripRedundantLinkMarks(paragraph(linkText('https://example.com', 'https://example.com')));
expect(stripped).toEqual(paragraph({type: 'text', text: 'https://example.com', marks: undefined}));
});
it('keeps the mark when the text is a label', () => {
const doc = paragraph(linkText('Mattermost', 'https://mattermost.com'));
expect(stripRedundantLinkMarks(doc)).toEqual(doc);
});
it('keeps other marks on the same text node', () => {
const stripped = stripRedundantLinkMarks(paragraph({
type: 'text',
text: 'https://example.com',
marks: [{type: 'bold'}, {type: 'link', attrs: {href: 'https://example.com'}}],
}));
expect(stripped).toEqual(paragraph({
type: 'text',
text: 'https://example.com',
marks: [{type: 'bold'}],
}));
});
it('compares the whole run when a link spans several text nodes', () => {
const href = 'https://example.com/a_b';
const stripped = stripRedundantLinkMarks(paragraph(
linkText('https://example.com', href),
linkText('/a_b', href),
));
expect(stripped).toEqual(paragraph(
{type: 'text', text: 'https://example.com', marks: undefined},
{type: 'text', text: '/a_b', marks: undefined},
));
});
it('recurses into nested content', () => {
const stripped = stripRedundantLinkMarks({
type: 'doc',
content: [{
type: 'bulletList',
content: [{
type: 'listItem',
content: [{
type: 'paragraph',
content: [linkText('https://example.com', 'https://example.com')],
}],
}],
}],
});
const listItemParagraph = stripped.content?.[0].content?.[0].content?.[0];
expect(listItemParagraph?.content?.[0].marks).toBeUndefined();
});
});
describe('serializeToMarkdown', () => {
let editor: Editor;
beforeEach(() => {
editor = new Editor({
extensions: [
StarterKit.configure({link: false}),
Link.configure({openOnClick: false, autolink: true, linkOnPaste: true}),
Markdown.configure({markedOptions: {gfm: true}}),
],
content: '',
contentType: 'markdown',
});
});
afterEach(() => {
editor.destroy();
});
it('serializes an autolinked URL as a bare URL', () => {
editor.commands.setContent(paragraph(linkText('https://mmtest.atlassian.net/', 'https://mmtest.atlassian.net/')));
expect(serializeToMarkdown(editor)).toBe('https://mmtest.atlassian.net/');
});
it('keeps a slash command intact', () => {
editor.commands.setContent(paragraph(
{type: 'text', text: '/jira instance install cloud-oauth '},
linkText('https://mmtest.atlassian.net/', 'https://mmtest.atlassian.net/'),
));
expect(serializeToMarkdown(editor)).toBe('/jira instance install cloud-oauth https://mmtest.atlassian.net/');
});
it('still serializes labelled links as markdown', () => {
editor.commands.setContent(paragraph(linkText('Mattermost', 'https://mattermost.com')));
expect(serializeToMarkdown(editor)).toBe('[Mattermost](https://mattermost.com)');
});
});
@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Editor, JSONContent} from '@tiptap/core';
export const isRedundantLinkText = (text: string, href: string): boolean => {
if (!text || !href) {
return false;
}
return text === href ||
href === `mailto:${text}` ||
href === `http://${text}` ||
href === `https://${text}`;
};
const getLinkHref = (node?: JSONContent): string | undefined => {
if (node?.type !== 'text') {
return undefined;
}
const href = node.marks?.find((mark) => mark.type === 'link')?.attrs?.href;
return typeof href === 'string' ? href : undefined;
};
const withoutLinkMark = (node: JSONContent): JSONContent => {
const marks = node.marks?.filter((mark) => mark.type !== 'link');
return {...node, marks: marks?.length ? marks : undefined};
};
export const stripRedundantLinkMarks = (node: JSONContent): JSONContent => {
const content = node.content;
if (!Array.isArray(content) || content.length === 0) {
return node;
}
const next: JSONContent[] = [];
for (let i = 0; i < content.length; i++) {
const href = getLinkHref(content[i]);
if (!href) {
next.push(stripRedundantLinkMarks(content[i]));
continue;
}
let end = i;
let text = '';
while (end < content.length && getLinkHref(content[end]) === href) {
text += content[end].text ?? '';
end++;
}
const run = content.slice(i, end);
next.push(...(isRedundantLinkText(text, href) ? run.map(withoutLinkMark) : run));
i = end - 1;
}
return {...node, content: next};
};
export const serializeToMarkdown = (editor: Editor): string => {
const markdown = editor.markdown ?
editor.markdown.serialize(stripRedundantLinkMarks(editor.getJSON())) :
editor.getMarkdown();
// Strip &nbsp; artifacts the @tiptap/markdown serializer leaves around
// empty paragraphs at doc start/end.
return markdown.trimEnd().
replace(/\n\n&nbsp;\n/g, '\n').
replace(/\n\n&nbsp;$/g, '').
replace(/^&nbsp;$/, '');
};
@@ -0,0 +1,175 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type {Editor} from '@tiptap/react';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import WysiwygSuggestionList from './wysiwyg_suggestion_list';
const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command';
const OPEN_COMMAND_IN_MODAL_ITEM_ID = '_open_command_in_modal';
const mockTerms: string[] = [];
jest.mock('components/suggestion/command_provider/command_provider', () => ({
__esModule: true,
default: class {
handlePretextChanged(pretext: string, resultCallback: (results: any) => void) {
if (!pretext.startsWith('/')) {
return false;
}
resultCallback({
matchedPretext: pretext,
terms: mockTerms,
items: mockTerms.map((term) => ({suggestion: term})),
component: () => null,
});
return true;
}
},
}));
jest.mock('components/suggestion/at_mention_provider', () => ({
__esModule: true,
default: class {
handlePretextChanged() {
return false;
}
},
}));
jest.mock('components/suggestion/channel_mention_provider', () => ({
__esModule: true,
default: class {
handlePretextChanged() {
return false;
}
},
}));
jest.mock('components/suggestion/emoticon_provider', () => ({
__esModule: true,
default: class {
handlePretextChanged() {
return false;
}
},
}));
jest.mock('components/suggestion/suggestion_list', () => ({
__esModule: true,
default: ({open, pretext, results, onCompleteWord}: any) => (open ? (
<div>
{results.terms.map((term: string) => (
<button
key={term}
onClick={() => onCompleteWord(term, pretext)}
>
{term}
</button>
))}
</div>
) : null),
}));
const setup = (terms: string[]) => {
mockTerms.length = 0;
mockTerms.push(...terms);
const dom = document.createElement('div');
const handlers: Record<string, () => void> = {};
const chainCalls: string[] = [];
const inserted: string[] = [];
let text = '';
const chain: any = {
focus: () => chain,
deleteRange: () => chain,
clearContent: () => {
chainCalls.push('clearContent');
return chain;
},
insertContent: (content: string) => {
inserted.push(content);
return chain;
},
run: () => true,
};
const editor = {
isDestroyed: false,
view: {dom},
commands: {focus: jest.fn()},
chain: () => chain,
get state() {
return {
selection: {from: text.length, $from: {start: () => 0}},
doc: {textBetween: () => text},
};
},
on: (event: string, handler: () => void) => {
handlers[event] = handler;
},
off: () => undefined,
} as unknown as Editor;
const onSubmit = jest.fn();
renderWithContext(
<WysiwygSuggestionList
editor={editor}
channelId='channel1'
onSubmit={onSubmit}
/>,
);
return {
onSubmit,
inserted,
chainCalls,
type: (next: string) => act(() => {
text = next;
handlers.update?.();
}),
};
};
describe('WysiwygSuggestionList', () => {
const command = '/jira instance install cloud-oauth ';
test('executes the command instead of inserting the sentinel', async () => {
const {type, onSubmit, inserted} = setup([command + EXECUTE_CURRENT_COMMAND_ITEM_ID]);
type(command);
await userEvent.click(screen.getByRole('button'));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(inserted).toEqual([]);
});
test('completes a regular suggestion as text', async () => {
const {type, onSubmit, inserted} = setup(['/jira instance install cloud-oauth']);
type('/jira instance install ');
await userEvent.click(screen.getByRole('button'));
expect(inserted).toEqual(['/jira instance install cloud-oauth ']);
expect(onSubmit).not.toHaveBeenCalled();
});
test('does not insert the open-in-modal sentinel when no app provider can handle it', async () => {
const {type, onSubmit, inserted, chainCalls} = setup([command + OPEN_COMMAND_IN_MODAL_ITEM_ID]);
type(command);
await userEvent.click(screen.getByRole('button'));
expect(inserted).toEqual([]);
expect(chainCalls).toEqual([]);
expect(onSubmit).not.toHaveBeenCalled();
});
});
@@ -9,6 +9,7 @@ import type {Channel} from '@mattermost/types/channels';
import type {ServerError} from '@mattermost/types/errors';
import type {Group} from '@mattermost/types/groups';
import {addMessageIntoHistory} from 'mattermost-redux/actions/posts';
import Permissions from 'mattermost-redux/constants/permissions';
import {getDefaultAgent} from 'mattermost-redux/selectors/entities/agents';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
@@ -31,14 +32,27 @@ import SuggestionList from 'components/suggestion/suggestion_list';
import type {ProviderResults, SuggestionResults} from 'components/suggestion/suggestion_results';
import {normalizeResultsFromProvider, countResults} from 'components/suggestion/suggestion_results';
import Constants from 'utils/constants';
import type {GlobalState} from 'types/store';
const EXECUTE_CURRENT_COMMAND_ITEM_ID = Constants.Integrations.EXECUTE_CURRENT_COMMAND_ITEM_ID;
const OPEN_COMMAND_IN_MODAL_ITEM_ID = Constants.Integrations.OPEN_COMMAND_IN_MODAL_ITEM_ID;
interface Props {
editor: Editor | null;
channelId: string;
rootId?: string;
onSubmit?: () => void;
}
type AppsModalProvider = {
openAppsModalFromCommand: (command: string) => void;
};
const canOpenAppsModal = <T extends object>(provider: T): provider is T & AppsModalProvider =>
typeof (provider as T & AppsModalProvider).openAppsModalFromCommand === 'function';
const EMPTY_RESULTS: SuggestionResults = {
matchedPretext: '',
terms: [],
@@ -65,7 +79,7 @@ function getTextBeforeCursor(editor: Editor): string {
return state.doc.textBetween(startOfLine, from, '\n');
}
const WysiwygSuggestionList = ({editor, channelId, rootId}: Props) => {
const WysiwygSuggestionList = ({editor, channelId, rootId, onSubmit}: Props) => {
const dispatch = useDispatch();
const [results, setResults] = useState<SuggestionResults>(EMPTY_RESULTS);
@@ -171,11 +185,38 @@ const WysiwygSuggestionList = ({editor, channelId, rootId}: Props) => {
};
}, [editor, providers, handleReceivedSuggestions]);
const closeSuggestions = useCallback(() => {
setIsOpen(false);
setResults(EMPTY_RESULTS);
}, []);
const handleCompleteWord = useCallback((term: string, matchedPretext: string) => {
if (!editor || editor.isDestroyed) {
return false;
}
if (term.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) {
closeSuggestions();
editor.commands.focus();
onSubmit?.();
return true;
}
if (term.endsWith(OPEN_COMMAND_IN_MODAL_ITEM_ID)) {
closeSuggestions();
const command = term.slice(0, -OPEN_COMMAND_IN_MODAL_ITEM_ID.length);
const appProvider = providers.find(canOpenAppsModal);
if (!appProvider) {
return false;
}
appProvider.openAppsModalFromCommand(command);
dispatch(addMessageIntoHistory(command));
editor.chain().focus().clearContent().run();
return false;
}
const {state} = editor;
const {from} = state.selection;
const cursorNode = state.selection.$from;
@@ -195,10 +236,9 @@ const WysiwygSuggestionList = ({editor, channelId, rootId}: Props) => {
editor.chain().focus().deleteRange({from: deleteFrom, to: deleteTo}).insertContent(completedText).run();
setIsOpen(false);
setResults(EMPTY_RESULTS);
closeSuggestions();
return true;
}, [editor]);
}, [editor, providers, dispatch, onSubmit, closeSuggestions]);
const handleItemHover = useCallback((term: string) => {
setSelection(term);