Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-07-05 12:07:09 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-07-05 12:07:09 +0300
commit26d28ba1597a9ce3018206475fa3dfdb42829656 (patch)
tree81349a0dc45d0f5a752673361c44902f869b4663 /spec/frontend/content_editor
parentafcff137096463cd55bf4776c83a76d648b03dc5 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend/content_editor')
-rw-r--r--spec/frontend/content_editor/components/formatting_toolbar_spec.js52
-rw-r--r--spec/frontend/content_editor/components/wrappers/code_block_spec.js239
-rw-r--r--spec/frontend/content_editor/extensions/code_suggestion_spec.js134
-rw-r--r--spec/frontend/content_editor/services/code_suggestion_utils_spec.js53
4 files changed, 459 insertions, 19 deletions
diff --git a/spec/frontend/content_editor/components/formatting_toolbar_spec.js b/spec/frontend/content_editor/components/formatting_toolbar_spec.js
index bff53375340..6562cb517cd 100644
--- a/spec/frontend/content_editor/components/formatting_toolbar_spec.js
+++ b/spec/frontend/content_editor/components/formatting_toolbar_spec.js
@@ -13,7 +13,13 @@ describe('content_editor/components/formatting_toolbar', () => {
let wrapper;
let trackingSpy;
- const buildWrapper = ({ props = {}, provide = {} } = {}) => {
+ const contentEditor = {
+ codeSuggestionsConfig: {
+ canSuggest: true,
+ },
+ };
+
+ const buildWrapper = ({ props = {}, provide = { contentEditor } } = {}) => {
wrapper = shallowMountExtended(FormattingToolbar, {
stubs: {
GlTabs,
@@ -29,21 +35,22 @@ describe('content_editor/components/formatting_toolbar', () => {
});
describe.each`
- testId | controlProps
- ${'text-styles'} | ${{}}
- ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold (Ctrl+B)', editorCommand: 'toggleBold' }}
- ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic (Ctrl+I)', editorCommand: 'toggleItalic' }}
- ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough (Ctrl+Shift+X)', editorCommand: 'toggleStrike' }}
- ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
- ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
- ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link (Ctrl+K)', editorCommand: 'editLink' }}
- ${'link'} | ${{}}
- ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
- ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }}
- ${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a checklist', editorCommand: 'toggleTaskList' }}
- ${'attachment'} | ${{}}
- ${'table'} | ${{}}
- ${'more'} | ${{}}
+ testId | controlProps
+ ${'text-styles'} | ${{}}
+ ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold (Ctrl+B)', editorCommand: 'toggleBold' }}
+ ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic (Ctrl+I)', editorCommand: 'toggleItalic' }}
+ ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough (Ctrl+Shift+X)', editorCommand: 'toggleStrike' }}
+ ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
+ ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
+ ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link (Ctrl+K)', editorCommand: 'editLink' }}
+ ${'link'} | ${{}}
+ ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
+ ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }}
+ ${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a checklist', editorCommand: 'toggleTaskList' }}
+ ${'code-suggestion'} | ${{ contentType: 'codeSuggestion', iconName: 'doc-code', label: 'Insert suggestion', editorCommand: 'insertCodeSuggestion' }}
+ ${'attachment'} | ${{}}
+ ${'table'} | ${{}}
+ ${'more'} | ${{}}
`('given a $testId toolbar control', ({ testId, controlProps }) => {
beforeEach(() => {
buildWrapper();
@@ -104,6 +111,7 @@ describe('content_editor/components/formatting_toolbar', () => {
buildWrapper({
provide: {
tiptapEditor,
+ contentEditor,
newCommentTemplatePath: 'some/path',
},
});
@@ -124,4 +132,16 @@ describe('content_editor/components/formatting_toolbar', () => {
expect(wrapper.findComponent(CommentTemplatesDropdown).exists()).toBe(false);
});
});
+
+ it('hides code suggestions icon if the user cannot make suggestions', () => {
+ buildWrapper({
+ provide: {
+ contentEditor: {
+ codeSuggestionsConfig: { canSuggest: false },
+ },
+ },
+ });
+
+ expect(wrapper.findByTestId('code-suggestion').exists()).toBe(false);
+ });
});
diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
index cbeea90dcb4..e802681dfc6 100644
--- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js
@@ -6,11 +6,26 @@ import eventHubFactory from '~/helpers/event_hub_factory';
import SandboxedMermaid from '~/behaviors/components/sandboxed_mermaid.vue';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import Diagram from '~/content_editor/extensions/diagram';
+import CodeSuggestion from '~/content_editor/extensions/code_suggestion';
import CodeBlockWrapper from '~/content_editor/components/wrappers/code_block.vue';
import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader';
-import { emitEditorEvent, createTestEditor } from '../../test_utils';
+import { emitEditorEvent, createTestEditor, mockChainedCommands } from '../../test_utils';
+
+const SAMPLE_README_CONTENT = `# Sample README
+
+This is a sample README.
+
+## Usage
+
+\`\`\`yaml
+foo: bar
+\`\`\`
+`;
jest.mock('~/content_editor/services/code_block_language_loader');
+jest.mock('~/content_editor/services/utils', () => ({
+ memoizedGet: jest.fn().mockResolvedValue(SAMPLE_README_CONTENT),
+}));
describe('content/components/wrappers/code_block', () => {
const language = 'yaml';
@@ -21,7 +36,7 @@ describe('content/components/wrappers/code_block', () => {
let eventHub;
const buildEditor = () => {
- tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight, Diagram] });
+ tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight, Diagram, CodeSuggestion] });
contentEditor = { renderDiagram: jest.fn().mockResolvedValue('url/to/some/diagram') };
eventHub = eventHubFactory();
};
@@ -76,7 +91,7 @@ describe('content/components/wrappers/code_block', () => {
it('renders label indicating that code block is frontmatter', () => {
createWrapper({ isFrontmatter: true, language });
- const label = wrapper.find('[data-testid="frontmatter-label"]');
+ const label = wrapper.findByTestId('frontmatter-label');
expect(label.text()).toEqual('frontmatter:yaml');
expect(label.classes()).toEqual(['gl-absolute', 'gl-top-0', 'gl-right-3']);
@@ -143,4 +158,222 @@ describe('content/components/wrappers/code_block', () => {
expect(wrapper.find('img').exists()).toBe(false);
});
});
+
+ describe('code suggestions', () => {
+ const nodeAttrs = { language: 'suggestion', isCodeSuggestion: true, langParams: '-0+0' };
+ const findCodeSuggestionBoxText = () =>
+ wrapper.findByTestId('code-suggestion-box').text().replace(/\s+/gm, ' ');
+ const findCodeDeleted = () =>
+ wrapper
+ .findByTestId('suggestion-deleted')
+ .findAll('code')
+ .wrappers.map((w) => w.html())
+ .join('\n');
+ const findCodeAdded = () =>
+ wrapper
+ .findByTestId('suggestion-added')
+ .findAll('code')
+ .wrappers.map((w) => w.html())
+ .join('\n');
+
+ let commands;
+
+ const clickButton = async ({ button, expectedLangParams }) => {
+ await button.trigger('click');
+
+ expect(commands.updateAttributes).toHaveBeenCalledWith('codeSuggestion', {
+ langParams: expectedLangParams,
+ });
+ expect(commands.run).toHaveBeenCalled();
+
+ await wrapper.setProps({ node: { attrs: { ...nodeAttrs, langParams: expectedLangParams } } });
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+ };
+
+ beforeEach(async () => {
+ contentEditor = {
+ codeSuggestionsConfig: {
+ canSuggest: true,
+ line: { new_line: 5 },
+ lines: [{ new_line: 5 }],
+ showPopover: false,
+ diffFile: {
+ view_path:
+ '/gitlab-org/gitlab-test/-/blob/468abc807a2b2572f43e72c743b76cee6db24025/README.md',
+ },
+ },
+ };
+
+ commands = mockChainedCommands(tiptapEditor, ['updateAttributes', 'run']);
+
+ createWrapper(nodeAttrs);
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+ });
+
+ it('shows a code suggestion block', () => {
+ expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 5');
+ expect(findCodeDeleted()).toMatchInlineSnapshot(
+ `"<code data-line-number=\\"5\\">## Usage\u200b</code>"`,
+ );
+ expect(findCodeAdded()).toMatchInlineSnapshot(
+ `"<code data-line-number=\\"5\\">\u200b</code>"`,
+ );
+ });
+
+ describe('decrement line start button', () => {
+ let button;
+
+ beforeEach(() => {
+ button = wrapper.findByTestId('decrement-line-start');
+ });
+
+ it('decrements the start line number', async () => {
+ await clickButton({ button, expectedLangParams: '-1+0' });
+
+ expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 4 to 5');
+ expect(findCodeDeleted()).toMatchInlineSnapshot(`
+ "<code data-line-number=\\"4\\">\u200b
+ </code>
+ <code data-line-number=\\"5\\">## Usage\u200b</code>"
+ `);
+ });
+
+ it('is disabled if the start line is already 1', async () => {
+ expect(button.attributes('disabled')).toBeUndefined();
+
+ await clickButton({ button, expectedLangParams: '-1+0' });
+ await clickButton({ button, expectedLangParams: '-2+0' });
+ await clickButton({ button, expectedLangParams: '-3+0' });
+ await clickButton({ button, expectedLangParams: '-4+0' });
+
+ expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 1 to 5');
+ expect(findCodeDeleted()).toMatchInlineSnapshot(`
+ "<code data-line-number=\\"1\\"># Sample README\u200b
+ </code>
+ <code data-line-number=\\"2\\">\u200b
+ </code>
+ <code data-line-number=\\"3\\">This is a sample README.\u200b
+ </code>
+ <code data-line-number=\\"4\\">\u200b
+ </code>
+ <code data-line-number=\\"5\\">## Usage\u200b</code>"
+ `);
+
+ expect(button.attributes('disabled')).toBe('disabled');
+ });
+ });
+
+ describe('increment line start button', () => {
+ let decrementButton;
+ let button;
+
+ beforeEach(() => {
+ decrementButton = wrapper.findByTestId('decrement-line-start');
+ button = wrapper.findByTestId('increment-line-start');
+ });
+
+ it('is disabled if the start line is already the current line', async () => {
+ expect(button.attributes('disabled')).toBe('disabled');
+
+ // decrement once, increment once
+ await clickButton({ button: decrementButton, expectedLangParams: '-1+0' });
+ expect(button.attributes('disabled')).toBeUndefined();
+ await clickButton({ button, expectedLangParams: '-0+0' });
+
+ expect(button.attributes('disabled')).toBe('disabled');
+ });
+
+ it('increments the start line number', async () => {
+ // decrement twice, increment once
+ await clickButton({ button: decrementButton, expectedLangParams: '-1+0' });
+ await clickButton({ button: decrementButton, expectedLangParams: '-2+0' });
+ await clickButton({ button, expectedLangParams: '-1+0' });
+
+ expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 4 to 5');
+ expect(findCodeDeleted()).toMatchInlineSnapshot(`
+ "<code data-line-number=\\"4\\">\u200b
+ </code>
+ <code data-line-number=\\"5\\">## Usage\u200b</code>"
+ `);
+ });
+ });
+
+ describe('decrement line end button', () => {
+ let incrementButton;
+ let button;
+
+ beforeEach(() => {
+ incrementButton = wrapper.findByTestId('increment-line-end');
+ button = wrapper.findByTestId('decrement-line-end');
+ });
+
+ it('is disabled if the line end is already the current line', async () => {
+ expect(button.attributes('disabled')).toBe('disabled');
+
+ // increment once, decrement once
+ await clickButton({ button: incrementButton, expectedLangParams: '-0+1' });
+ expect(button.attributes('disabled')).toBeUndefined();
+ await clickButton({ button, expectedLangParams: '-0+0' });
+
+ expect(button.attributes('disabled')).toBe('disabled');
+ });
+
+ it('increments the end line number', async () => {
+ // increment twice, decrement once
+ await clickButton({ button: incrementButton, expectedLangParams: '-0+1' });
+ await clickButton({ button: incrementButton, expectedLangParams: '-0+2' });
+ await clickButton({ button, expectedLangParams: '-0+1' });
+
+ expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 6');
+ expect(findCodeDeleted()).toMatchInlineSnapshot(`
+ "<code data-line-number=\\"5\\">## Usage\u200b
+ </code>
+ <code data-line-number=\\"6\\">\u200b</code>"
+ `);
+ });
+ });
+
+ describe('increment line end button', () => {
+ let button;
+
+ beforeEach(() => {
+ button = wrapper.findByTestId('increment-line-end');
+ });
+
+ it('decrements the start line number', async () => {
+ await clickButton({ button, expectedLangParams: '-0+1' });
+
+ expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 6');
+ expect(findCodeDeleted()).toMatchInlineSnapshot(`
+ "<code data-line-number=\\"5\\">## Usage\u200b
+ </code>
+ <code data-line-number=\\"6\\">\u200b</code>"
+ `);
+ });
+
+ it('is disabled if the end line is EOF', async () => {
+ expect(button.attributes('disabled')).toBeUndefined();
+
+ await clickButton({ button, expectedLangParams: '-0+1' });
+ await clickButton({ button, expectedLangParams: '-0+2' });
+ await clickButton({ button, expectedLangParams: '-0+3' });
+ await clickButton({ button, expectedLangParams: '-0+4' });
+
+ expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 9');
+ expect(findCodeDeleted()).toMatchInlineSnapshot(`
+ "<code data-line-number=\\"5\\">## Usage\u200b
+ </code>
+ <code data-line-number=\\"6\\">\u200b
+ </code>
+ <code data-line-number=\\"7\\">\`\`\`yaml\u200b
+ </code>
+ <code data-line-number=\\"8\\">foo: bar\u200b
+ </code>
+ <code data-line-number=\\"9\\">\`\`\`\u200b</code>"
+ `);
+
+ expect(button.attributes('disabled')).toBe('disabled');
+ });
+ });
+ });
});
diff --git a/spec/frontend/content_editor/extensions/code_suggestion_spec.js b/spec/frontend/content_editor/extensions/code_suggestion_spec.js
new file mode 100644
index 00000000000..3a944b7b3e9
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/code_suggestion_spec.js
@@ -0,0 +1,134 @@
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import CodeSuggestion from '~/content_editor/extensions/code_suggestion';
+import {
+ createTestEditor,
+ createDocBuilder,
+ triggerNodeInputRule,
+ expectDocumentAfterTransaction,
+} from '../test_utils';
+
+const SAMPLE_README_CONTENT = `# Sample README
+
+This is a sample README.
+
+## Usage
+
+\`\`\`yaml
+foo: bar
+\`\`\`
+`;
+
+jest.mock('~/content_editor/services/utils', () => ({
+ memoizedGet: jest.fn().mockResolvedValue(SAMPLE_README_CONTENT),
+}));
+
+describe('content_editor/extensions/code_suggestion', () => {
+ let tiptapEditor;
+ let doc;
+ let codeSuggestion;
+
+ const codeSuggestionConfig = {
+ canSuggest: true,
+ line: { new_line: 5 },
+ lines: [{ new_line: 5 }],
+ showPopover: false,
+ diffFile: {
+ view_path:
+ '/gitlab-org/gitlab-test/-/blob/468abc807a2b2572f43e72c743b76cee6db24025/README.md',
+ },
+ };
+
+ const createEditor = (config = {}) => {
+ tiptapEditor = createTestEditor({
+ extensions: [
+ CodeBlockHighlight,
+ CodeSuggestion.configure({ config: { ...codeSuggestionConfig, ...config } }),
+ ],
+ });
+
+ ({
+ builders: { doc, codeSuggestion },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ codeBlock: { nodeType: CodeBlockHighlight.name },
+ codeSuggestion: { nodeType: CodeSuggestion.name },
+ },
+ }));
+ };
+
+ describe('insertCodeSuggestion command', () => {
+ it('creates a correct suggestion for a single line selection', async () => {
+ createEditor({ line: { new_line: 5 }, lines: [] });
+
+ await expectDocumentAfterTransaction({
+ number: 1,
+ tiptapEditor,
+ action: () => tiptapEditor.commands.insertCodeSuggestion(),
+ expectedDoc: doc(codeSuggestion({ langParams: '-0+0' }, '## Usage')),
+ });
+ });
+
+ it('creates a correct suggestion for a multi-line selection', async () => {
+ createEditor({
+ line: { new_line: 9 },
+ lines: [
+ { new_line: 5 },
+ { new_line: 6 },
+ { new_line: 7 },
+ { new_line: 8 },
+ { new_line: 9 },
+ ],
+ });
+
+ await expectDocumentAfterTransaction({
+ number: 1,
+ tiptapEditor,
+ action: () => tiptapEditor.commands.insertCodeSuggestion(),
+ expectedDoc: doc(
+ codeSuggestion({ langParams: '-4+0' }, '## Usage\n\n```yaml\nfoo: bar\n```'),
+ ),
+ });
+ });
+
+ it('does not insert a new suggestion if already inside a suggestion', async () => {
+ const initialDoc = codeSuggestion({ langParams: '-0+0' }, '## Usage');
+
+ createEditor({ line: { new_line: 5 }, lines: [] });
+
+ tiptapEditor.commands.setContent(doc(initialDoc).toJSON());
+
+ jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(true);
+ jest.useRealTimers();
+
+ await new Promise((resolve) => {
+ setTimeout(() => {
+ tiptapEditor.commands.insertCodeSuggestion();
+
+ resolve();
+ }, 100);
+ });
+
+ jest.useFakeTimers();
+
+ expect(tiptapEditor.getJSON()).toEqual(doc(initialDoc).toJSON());
+ });
+ });
+
+ describe('when typing ```suggestion input rule', () => {
+ beforeEach(() => {
+ createEditor();
+
+ triggerNodeInputRule({
+ tiptapEditor,
+ inputRuleText: '```suggestion ',
+ });
+ });
+
+ it('creates a new code suggestion block with lines -0+0', () => {
+ const expectedDoc = doc(codeSuggestion({ language: 'suggestion', langParams: '-0+0' }));
+
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/services/code_suggestion_utils_spec.js b/spec/frontend/content_editor/services/code_suggestion_utils_spec.js
new file mode 100644
index 00000000000..f26d33adf4c
--- /dev/null
+++ b/spec/frontend/content_editor/services/code_suggestion_utils_spec.js
@@ -0,0 +1,53 @@
+import {
+ lineOffsetToLangParams,
+ langParamsToLineOffset,
+ toAbsoluteLineOffset,
+ getLines,
+ appendNewlines,
+} from '~/content_editor/services/code_suggestion_utils';
+
+describe('content_editor/services/code_suggestion_utils', () => {
+ describe('lineOffsetToLangParams', () => {
+ it.each`
+ lineOffset | expected
+ ${[0, 0]} | ${'-0+0'}
+ ${[0, 2]} | ${'-0+2'}
+ ${[1, 1]} | ${'+1+1'}
+ ${[-1, 1]} | ${'-1+1'}
+ `('converts line offset $lineOffset to lang params $expected', ({ lineOffset, expected }) => {
+ expect(lineOffsetToLangParams(lineOffset)).toBe(expected);
+ });
+ });
+
+ describe('langParamsToLineOffset', () => {
+ it.each`
+ langParams | expected
+ ${'-0+0'} | ${[-0, 0]}
+ ${'-0+2'} | ${[-0, 2]}
+ ${'+1+1'} | ${[1, 1]}
+ ${'-1+1'} | ${[-1, 1]}
+ `('converts lang params $langParams to line offset $expected', ({ langParams, expected }) => {
+ expect(langParamsToLineOffset(langParams)).toEqual(expected);
+ });
+ });
+
+ describe('toAbsoluteLineOffset', () => {
+ it('adds line number to line offset', () => {
+ expect(toAbsoluteLineOffset([-2, 3], 72)).toEqual([70, 75]);
+ });
+ });
+
+ describe('getLines', () => {
+ it('returns lines from allLines', () => {
+ const allLines = ['foo', 'bar', 'baz', 'qux', 'quux'];
+ expect(getLines([2, 4], allLines)).toEqual(['bar', 'baz', 'qux']);
+ });
+ });
+
+ describe('appendNewlines', () => {
+ it('appends zero-width space to each line', () => {
+ const lines = ['foo', 'bar', 'baz'];
+ expect(appendNewlines(lines)).toEqual(['foo\u200b\n', 'bar\u200b\n', 'baz\u200b']);
+ });
+ });
+});