mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
MM-70151/MM-70152: fix slash commands in the WYSIWYG composer (#37880)
Co-authored-by: Nevyana Angelova <[email protected]>
This commit is contained in:
committed by
GitHub
co-authored by
Nevyana Angelova
parent
bd753cd18f
commit
747e429378
+7
-7
@@ -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 artifacts the @tiptap/markdown serializer leaves around
|
||||
// empty paragraphs at doc start/end.
|
||||
const md = editor.getMarkdown().trimEnd().
|
||||
replace(/\n\n \n/g, '\n').
|
||||
replace(/\n\n $/g, '').
|
||||
replace(/^ $/, '');
|
||||
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>
|
||||
);
|
||||
|
||||
+142
@@ -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)');
|
||||
});
|
||||
});
|
||||
+71
@@ -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 artifacts the @tiptap/markdown serializer leaves around
|
||||
// empty paragraphs at doc start/end.
|
||||
return markdown.trimEnd().
|
||||
replace(/\n\n \n/g, '\n').
|
||||
replace(/\n\n $/g, '').
|
||||
replace(/^ $/, '');
|
||||
};
|
||||
+175
@@ -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();
|
||||
});
|
||||
});
|
||||
+44
-4
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user