diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 09a5bab8d374ce92260127c097a49ba13c919e91..9e08a257abff865de191f139be758e1d80c6dc26 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -56,6 +56,11 @@ export default {
required: false,
default: '',
},
+ placeholder: {
+ type: String,
+ required: false,
+ default: '',
+ },
autofocus: {
type: [String, Boolean],
required: false,
@@ -67,6 +72,16 @@ export default {
required: false,
default: '',
},
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ editable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -81,9 +96,20 @@ export default {
this.setSerializedContent(markdown);
}
},
+ editable(value) {
+ this.contentEditor.setEditable(value);
+ },
},
created() {
- const { renderMarkdown, uploadsPath, extensions, serializerConfig, autofocus } = this;
+ const {
+ renderMarkdown,
+ uploadsPath,
+ extensions,
+ serializerConfig,
+ autofocus,
+ drawioEnabled,
+ editable,
+ } = this;
// This is a non-reactive attribute intentionally since this is a complex object.
this.contentEditor = createContentEditor({
@@ -91,8 +117,10 @@ export default {
uploadsPath,
extensions,
serializerConfig,
+ drawioEnabled,
tiptapOptions: {
autofocus,
+ editable,
},
});
},
@@ -109,10 +137,10 @@ export default {
try {
await this.contentEditor.setSerializedContent(markdown);
- this.contentEditor.setEditable(true);
this.notifyLoadingSuccess();
this.latestMarkdown = markdown;
} catch {
+ this.contentEditor.setEditable(false);
this.contentEditor.eventHub.$emit(ALERT_EVENT, {
message: __(
'An error occurred while trying to render the content editor. Please try again.',
@@ -120,10 +148,10 @@ export default {
variant: VARIANT_DANGER,
actionLabel: __('Retry'),
action: () => {
+ this.contentEditor.setEditable(true);
this.setSerializedContent(markdown);
},
});
- this.contentEditor.setEditable(false);
this.notifyLoadingError();
}
},
@@ -189,6 +217,9 @@ export default {
${text}
`, - ); + renderMarkdown.mockResolvedValueOnce(`${text}
`); result = await deserializer.deserialize({ - content: 'content', + markdown: '**Bold text**\n', schema: tiptapEditor.schema, }); }); @@ -53,12 +51,22 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => { }); describe('when the render function returns an empty value', () => { - it('returns an empty object', async () => { - const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); + it('returns an empty prosemirror document', async () => { + const deserializer = createMarkdownDeserializer({ + render: renderMarkdown, + schema: tiptapEditor.schema, + }); renderMarkdown.mockResolvedValueOnce(null); - expect(await deserializer.deserialize({ content: 'content' })).toEqual({}); + const result = await deserializer.deserialize({ + markdown: '', + schema: tiptapEditor.schema, + }); + + const document = doc(p()); + + expect(result.document.toJSON()).toEqual(document.toJSON()); }); }); }); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 0d966c1dca0eac8f837f56ee79273dc65ddaa78f..d7966f72fc60d298734247df0578dc2fb04e5bbb 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -5,11 +5,11 @@ import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import Autosave from '~/autosave'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; +import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import CommentForm from '~/notes/components/comment_form.vue'; import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue'; @@ -22,7 +22,6 @@ import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } jest.mock('autosize'); jest.mock('~/commons/nav/user_merge_requests'); jest.mock('~/alert'); -jest.mock('~/autosave'); Vue.use(Vuex); @@ -32,7 +31,8 @@ describe('issue_comment_form component', () => { let axiosMock; const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button'); - const findTextArea = () => wrapper.findByTestId('comment-field'); + const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); + const findMarkdownEditorTextarea = () => findMarkdownEditor().find('textarea'); const findAddToReviewButton = () => wrapper.findByTestId('add-to-review-button'); const findAddCommentNowButton = () => wrapper.findByTestId('add-comment-now-button'); const findConfidentialNoteCheckbox = () => wrapper.findByTestId('internal-note-checkbox'); @@ -135,7 +135,6 @@ describe('issue_comment_form component', () => { mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - jest.spyOn(wrapper.vm, 'resizeTextarea'); jest.spyOn(wrapper.vm, 'stopPolling'); findCloseReopenButton().trigger('click'); @@ -144,7 +143,6 @@ describe('issue_comment_form component', () => { expect(wrapper.vm.note).toBe(''); expect(wrapper.vm.saveNote).toHaveBeenCalled(); expect(wrapper.vm.stopPolling).toHaveBeenCalled(); - expect(wrapper.vm.resizeTextarea).toHaveBeenCalled(); }); it('does not report errors in the UI when the save succeeds', async () => { @@ -259,6 +257,18 @@ describe('issue_comment_form component', () => { }); }); + it('hides content editor switcher if feature flag content_editor_on_issues is off', () => { + mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: false } }); + + expect(wrapper.text()).not.toContain('Rich text'); + }); + + it('shows content editor switcher if feature flag content_editor_on_issues is on', () => { + mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: true } }); + + expect(wrapper.text()).toContain('Rich text'); + }); + describe('textarea', () => { describe('general', () => { it.each` @@ -267,13 +277,13 @@ describe('issue_comment_form component', () => { ${'internal note'} | ${true} | ${'Write an internal note or drag your files hereā¦'} `( 'should render textarea with placeholder for $noteType', - ({ noteIsInternal, placeholder }) => { - mountComponent({ - mountFunction: mount, - initialData: { noteIsInternal }, - }); + async ({ noteIsInternal, placeholder }) => { + mountComponent(); + + wrapper.vm.noteIsInternal = noteIsInternal; + await nextTick(); - expect(findTextArea().attributes('placeholder')).toBe(placeholder); + expect(findMarkdownEditor().props('formFieldProps').placeholder).toBe(placeholder); }, ); @@ -289,13 +299,13 @@ describe('issue_comment_form component', () => { await findCommentButton().trigger('click'); - expect(findTextArea().attributes('disabled')).toBe('disabled'); + expect(findMarkdownEditor().find('textarea').attributes('disabled')).toBe('disabled'); }); it('should support quick actions', () => { mountComponent({ mountFunction: mount }); - expect(findTextArea().attributes('data-supports-quick-actions')).toBe('true'); + expect(findMarkdownEditor().props('supportsQuickActions')).toBe(true); }); it('should link to markdown docs', () => { @@ -335,63 +345,51 @@ describe('issue_comment_form component', () => { it('should enter edit mode when arrow up is pressed', () => { jest.spyOn(wrapper.vm, 'editCurrentUserLastNote'); - findTextArea().trigger('keydown.up'); + findMarkdownEditorTextarea().trigger('keydown.up'); expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled(); }); - it('inits autosave', () => { - expect(Autosave).toHaveBeenCalledWith(expect.any(Element), [ - 'Note', - 'Issue', - noteableDataMock.id, - ]); - }); - }); + describe('event enter', () => { + describe('when no draft exists', () => { + it('should save note when cmd+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); - describe('event enter', () => { - beforeEach(() => { - mountComponent({ mountFunction: mount }); - }); - - describe('when no draft exists', () => { - it('should save note when cmd+enter is pressed', () => { - jest.spyOn(wrapper.vm, 'handleSave'); - - findTextArea().trigger('keydown.enter', { metaKey: true }); + findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true }); - expect(wrapper.vm.handleSave).toHaveBeenCalledWith(); - }); + expect(wrapper.vm.handleSave).toHaveBeenCalledWith(); + }); - it('should save note when ctrl+enter is pressed', () => { - jest.spyOn(wrapper.vm, 'handleSave'); + it('should save note when ctrl+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); - findTextArea().trigger('keydown.enter', { ctrlKey: true }); + findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true }); - expect(wrapper.vm.handleSave).toHaveBeenCalledWith(); + expect(wrapper.vm.handleSave).toHaveBeenCalledWith(); + }); }); - }); - describe('when a draft exists', () => { - beforeEach(() => { - store.registerModule('batchComments', batchComments()); - store.state.batchComments.drafts = [{ note: 'A' }]; - }); + describe('when a draft exists', () => { + beforeEach(() => { + store.registerModule('batchComments', batchComments()); + store.state.batchComments.drafts = [{ note: 'A' }]; + }); - it('should save note draft when cmd+enter is pressed', () => { - jest.spyOn(wrapper.vm, 'handleSaveDraft'); + it('should save note draft when cmd+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSaveDraft'); - findTextArea().trigger('keydown.enter', { metaKey: true }); + findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true }); - expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); - }); + expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); + }); - it('should save note draft when ctrl+enter is pressed', () => { - jest.spyOn(wrapper.vm, 'handleSaveDraft'); + it('should save note draft when ctrl+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSaveDraft'); - findTextArea().trigger('keydown.enter', { ctrlKey: true }); + findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true }); - expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); + expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith(); + }); }); }); }); @@ -660,7 +658,7 @@ describe('issue_comment_form component', () => { }); it('should not render submission form', () => { - expect(findTextArea().exists()).toBe(false); + expect(findMarkdownEditor().exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index 8cb773ddfa3fa0df4b39e82bf2df4c5d40126e92..681ff6c8dd341d977c646bf292cdeca1cc1d52b9 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -63,6 +63,18 @@ describe('vue_shared/component/markdown/markdown_editor', () => { const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const findContentEditor = () => wrapper.findComponent(ContentEditor); + const enableContentEditor = async () => { + findMarkdownField().vm.$emit('enableContentEditor'); + await nextTick(); + await waitForPromises(); + }; + + const enableMarkdownEditor = async () => { + findContentEditor().vm.$emit('enableMarkdownEditor'); + await nextTick(); + await waitForPromises(); + }; + beforeEach(() => { window.uploads_path = 'uploads'; mock = new MockAdapter(axios); @@ -90,6 +102,18 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); }); + it('enables content editor switcher when contentEditorEnabled prop is true', () => { + buildWrapper({ propsData: { enableContentEditor: true } }); + + expect(findMarkdownField().text()).toContain('Rich text'); + }); + + it('hides content editor switcher when contentEditorEnabled prop is false', () => { + buildWrapper({ propsData: { enableContentEditor: false } }); + + expect(findMarkdownField().text()).not.toContain('Rich text'); + }); + it('passes down any additional props to markdown field component', () => { const propsData = { line: { text: 'hello world', richText: 'hello world' }, @@ -110,6 +134,36 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); }); + describe('disabled', () => { + it('disables markdown field when disabled prop is true', () => { + buildWrapper({ propsData: { disabled: true } }); + + expect(findMarkdownField().find('textarea').attributes('disabled')).toBe('disabled'); + }); + + it('enables markdown field when disabled prop is false', () => { + buildWrapper({ propsData: { disabled: false } }); + + expect(findMarkdownField().find('textarea').attributes('disabled')).toBe(undefined); + }); + + it('disables content editor when disabled prop is true', async () => { + buildWrapper({ propsData: { disabled: true } }); + + await enableContentEditor(); + + expect(findContentEditor().props('editable')).toBe(false); + }); + + it('enables content editor when disabled prop is false', async () => { + buildWrapper({ propsData: { disabled: false } }); + + await enableContentEditor(); + + expect(findContentEditor().props('editable')).toBe(true); + }); + }); + describe('autosize', () => { it('autosizes the textarea when the value changes', async () => { buildWrapper(); @@ -129,12 +183,10 @@ describe('vue_shared/component/markdown/markdown_editor', () => { it('does not autosize the textarea if markdown editor is disabled', async () => { buildWrapper(); - findMarkdownField().vm.$emit('enableContentEditor'); + await enableContentEditor(); wrapper.setProps({ value: 'Lots of newlines\n\n\n\n\n\n\nMore content\n\n\nand newlines' }); - await nextTick(); - await waitForPromises(); expect(Autosize.update).not.toHaveBeenCalled(); }); }); @@ -200,9 +252,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { it(`emits ${EDITING_MODE_CONTENT_EDITOR} event when enableContentEditor emitted from markdown editor`, async () => { buildWrapper(); - findMarkdownField().vm.$emit('enableContentEditor'); - - await nextTick(); + await enableContentEditor(); expect(wrapper.emitted(EDITING_MODE_CONTENT_EDITOR)).toHaveLength(1); }); @@ -212,11 +262,8 @@ describe('vue_shared/component/markdown/markdown_editor', () => { stubs: { ContentEditor: stubComponent(ContentEditor) }, }); - findMarkdownField().vm.$emit('enableContentEditor'); - - await nextTick(); - - findContentEditor().vm.$emit('enableMarkdownEditor'); + await enableContentEditor(); + await enableMarkdownEditor(); expect(wrapper.emitted(EDITING_MODE_MARKDOWN_FIELD)).toHaveLength(1); }); @@ -262,9 +309,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); describe(`when markdown field triggers enableContentEditor event`, () => { - beforeEach(() => { + beforeEach(async () => { buildWrapper(); - findMarkdownField().vm.$emit('enableContentEditor'); + await enableContentEditor(); }); it('displays the content editor', () => { @@ -300,9 +347,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); describe(`when editingMode is ${EDITING_MODE_CONTENT_EDITOR}`, () => { - beforeEach(() => { + beforeEach(async () => { buildWrapper({ propsData: { autosaveKey: 'issue/1234' } }); - findMarkdownField().vm.$emit('enableContentEditor'); + await enableContentEditor(); }); describe('when autofocus is true', () => { @@ -343,9 +390,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); describe(`when richText editor triggers enableMarkdownEditor event`, () => { - beforeEach(() => { - findContentEditor().vm.$emit('enableMarkdownEditor'); - }); + beforeEach(enableMarkdownEditor); it('hides the content editor', () => { expect(findContentEditor().exists()).toBe(false); diff --git a/spec/support/helpers/content_editor_helpers.rb b/spec/support/helpers/content_editor_helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..c12fd1fbbd7ae9451db440c50c86705304d61e24 --- /dev/null +++ b/spec/support/helpers/content_editor_helpers.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module ContentEditorHelpers + def switch_to_content_editor + click_button _('Viewing markdown') + click_button _('Rich text') + end + + def type_in_content_editor(keys) + find(content_editor_testid).send_keys keys + end + + def open_insert_media_dropdown + page.find('svg[data-testid="media-icon"]').click + end + + def set_source_editor_content(content) + find('.js-gfm-input').set content + end + + def expect_formatting_menu_to_be_visible + expect(page).to have_css('[data-testid="formatting-bubble-menu"]') + end + + def expect_formatting_menu_to_be_hidden + expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]') + end + + def expect_media_bubble_menu_to_be_visible + expect(page).to have_css('[data-testid="media-bubble-menu"]') + end + + def upload_asset(fixture_name) + attach_file('content_editor_image', Rails.root.join('spec', 'fixtures', fixture_name), make_visible: true) + end + + def wait_until_hidden_field_is_updated(value) + expect(page).to have_field(with: value, type: 'hidden') + end + + def display_media_bubble_menu(media_element_selector, fixture_file) + upload_asset fixture_file + + wait_for_requests + + expect(page).to have_css(media_element_selector) + + page.find(media_element_selector).click + end + + def click_edit_diagram_button + page.find('[data-testid="edit-diagram"]').click + end + + def expect_drawio_editor_is_opened + expect(page).to have_css('#drawio-frame', visible: :hidden) + end +end diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb index 05b5b8cac0843d5df03375d4372fb2072c497319..7582e67efbd53a110f8ae793ebcd996824127321 100644 --- a/spec/support/shared_examples/features/content_editor_shared_examples.rb +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -1,54 +1,9 @@ # frozen_string_literal: true RSpec.shared_examples 'edits content using the content editor' do - let(:content_editor_testid) { '[data-testid="content-editor"] [contenteditable].ProseMirror' } - - def switch_to_content_editor - click_button _('Viewing markdown') - click_button _('Rich text') - end - - def type_in_content_editor(keys) - find(content_editor_testid).send_keys keys - end - - def open_insert_media_dropdown - page.find('svg[data-testid="media-icon"]').click - end - - def set_source_editor_content(content) - find('.js-gfm-input').set content - end - - def expect_formatting_menu_to_be_visible - expect(page).to have_css('[data-testid="formatting-bubble-menu"]') - end - - def expect_formatting_menu_to_be_hidden - expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]') - end - - def expect_media_bubble_menu_to_be_visible - expect(page).to have_css('[data-testid="media-bubble-menu"]') - end + include ContentEditorHelpers - def upload_asset(fixture_name) - attach_file('content_editor_image', Rails.root.join('spec', 'fixtures', fixture_name), make_visible: true) - end - - def wait_until_hidden_field_is_updated(value) - expect(page).to have_field('wiki[content]', with: value, type: 'hidden') - end - - def display_media_bubble_menu(media_element_selector, fixture_file) - upload_asset fixture_file - - wait_for_requests - - expect(page).to have_css(media_element_selector) - - page.find(media_element_selector).click - end + let(:content_editor_testid) { '[data-testid="content-editor"] [contenteditable].ProseMirror' } it 'saves page content in local storage if the user navigates away' do switch_to_content_editor @@ -62,16 +17,6 @@ def display_media_bubble_menu(media_element_selector, fixture_file) refresh expect(page).to have_text('Typing text in the content editor') - - refresh # also retained after second refresh - - expect(page).to have_text('Typing text in the content editor') - - click_link 'Cancel' # draft is deleted on cancel - - page.go_back - - expect(page).not_to have_text('Typing text in the content editor') end describe 'formatting bubble menu' do @@ -117,33 +62,6 @@ def display_media_bubble_menu(media_element_selector, fixture_file) end end - describe 'diagrams.net editor' do - def click_edit_diagram_button - page.find('[data-testid="edit-diagram"]').click - end - - def expect_drawio_editor_is_opened - expect(page).to have_css('#drawio-frame', visible: :hidden) - end - - before do - switch_to_content_editor - - open_insert_media_dropdown - end - - it 'displays correct media bubble menu with edit diagram button' do - display_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'diagram.drawio.svg' - - expect_formatting_menu_to_be_hidden - expect_media_bubble_menu_to_be_visible - - click_edit_diagram_button - - expect_drawio_editor_is_opened - end - end - describe 'code block' do before do visit(profile_preferences_path) @@ -255,7 +173,7 @@ def expect_drawio_editor_is_opened before do if defined?(project) create(:issue, project: project, title: 'My Cool Linked Issue') - create(:merge_request, source_project: project, title: 'My Cool Merge Request') + create(:merge_request, source_project: project, source_branch: 'branch-1', title: 'My Cool Merge Request') create(:label, project: project, title: 'My Cool Label') create(:milestone, project: project, title: 'My Cool Milestone') @@ -264,7 +182,7 @@ def expect_drawio_editor_is_opened project = create(:project, group: group) create(:issue, project: project, title: 'My Cool Linked Issue') - create(:merge_request, source_project: project, title: 'My Cool Merge Request') + create(:merge_request, source_project: project, source_branch: 'branch-1', title: 'My Cool Merge Request') create(:group_label, group: group, title: 'My Cool Label') create(:milestone, group: group, title: 'My Cool Milestone') @@ -281,7 +199,9 @@ def expect_drawio_editor_is_opened expect(find(suggestions_dropdown)).to have_text('abc123') expect(find(suggestions_dropdown)).to have_text('all') - expect(find(suggestions_dropdown)).to have_text('Group Members (2)') + expect(find(suggestions_dropdown)).to have_text('Group Members') + + type_in_content_editor 'bc' send_keys [:arrow_down, :enter] @@ -362,3 +282,24 @@ def dropdown_scroll_top end end end + +RSpec.shared_examples 'inserts diagrams.net diagram using the content editor' do + include ContentEditorHelpers + + before do + switch_to_content_editor + + open_insert_media_dropdown + end + + it 'displays correct media bubble menu with edit diagram button' do + display_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'diagram.drawio.svg' + + expect_formatting_menu_to_be_hidden + expect_media_bubble_menu_to_be_visible + + click_edit_diagram_button + + expect_drawio_editor_is_opened + end +end diff --git a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb index 0334187e4b13a51d1ef708c4469c115bdc8c772b..c1e4185e058f4f1c258b34fe5431cc8b63ca8643 100644 --- a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb +++ b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb @@ -150,6 +150,7 @@ end it_behaves_like 'edits content using the content editor' + it_behaves_like 'inserts diagrams.net diagram using the content editor' it_behaves_like 'autocompletes items' end