diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-05 12:07:09 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-05 12:07:09 +0300 |
commit | 26d28ba1597a9ce3018206475fa3dfdb42829656 (patch) | |
tree | 81349a0dc45d0f5a752673361c44902f869b4663 /spec/frontend/content_editor | |
parent | afcff137096463cd55bf4776c83a76d648b03dc5 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend/content_editor')
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']); + }); + }); +}); |