diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index b02eb37206aeaf4afce436089860e72b03bcf5fd..1539ab806e4123a77abce4b411b34886b71335eb 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -6,3 +6,4 @@ export const EDITOR_LITE_INSTANCE_ERROR_NO_EL = __( export const URI_PREFIX = 'gitlab'; export const CONTENT_UPDATE_DEBOUNCE = 250; +export const SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml'; diff --git a/app/assets/javascripts/editor/editor_ci_schema_ext.js b/app/assets/javascripts/editor/editor_ci_schema_ext.js new file mode 100644 index 0000000000000000000000000000000000000000..5a033ba70c3579ac2d5f7259dfb7eef6210e9bc4 --- /dev/null +++ b/app/assets/javascripts/editor/editor_ci_schema_ext.js @@ -0,0 +1,35 @@ +import { registerSchema } from '~/ide/utils'; +import Api from '~/api'; + +// For CI config schemas the filename must match +// '*.gitlab-ci.yml' regardless of project configuration. +// https://gitlab.com/gitlab-org/gitlab/-/issues/293641 +import { SCHEMA_FILE_NAME_MATCH } from './constants'; + +const getCiSchemaUri = ({ projectNamespace, projectPath, ref }) => + Api.buildUrl(Api.projectFileSchemaPath) + .replace(':namespace_path', projectNamespace) + .replace(':project_path', projectPath) + .replace(':ref', ref) + .replace(':filename', SCHEMA_FILE_NAME_MATCH); + +export default { + /** + * Registers a schema in a model based on project properties + * and the name of the file that is currently edited. + * + * @param {Object} opts + * @param {String} opts.projectNamespace + * @param {String} opts.projectPath + * @param {String?} opts.ref + */ + registerCiSchema({ projectNamespace, projectPath, ref = 'master' } = {}) { + const fileName = this.getModel() + .uri.path.split('/') + .pop(); + registerSchema({ + uri: getCiSchemaUri({ projectNamespace, projectPath, ref }), + fileMatch: [fileName], + }); + }, +}; diff --git a/app/assets/javascripts/pipeline_editor/components/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/text_editor.vue index 22f2a32c9ac03df7f0c92866565a9a6396b73fc9..50463e43bb6778a7280ed693697e92bb0ed1de33 100644 --- a/app/assets/javascripts/pipeline_editor/components/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/text_editor.vue @@ -1,14 +1,45 @@ diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 8268a907a29243c058f801aaecdc36f1c959409a..01ea865e829bd644ebff710341223978d598e1a5 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -14,7 +14,15 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { return null; } - const { ciConfigPath, commitId, defaultBranch, newMergeRequestPath, projectPath } = el?.dataset; + const { + ciConfigPath, + commitId, + defaultBranch, + projectFullPath, + projectPath, + projectNamespace, + newMergeRequestPath, + } = el?.dataset; Vue.use(VueApollo); @@ -25,6 +33,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { return new Vue({ el, apolloProvider, + provide: { + projectFullPath, + projectPath, + projectNamespace, + }, render(h) { return h(PipelineEditorApp, { props: { @@ -32,7 +45,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { commitId, defaultBranch, newMergeRequestPath, - projectPath, }, }); }, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index b1c52ffa9205f421f31757ad52c1763c88953b86..fe0e999cf71be4968e9f2efd9838d543e23a0beb 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -31,16 +31,12 @@ export default { TextEditor, }, props: { - projectPath: { - type: String, - required: true, - }, - defaultBranch: { + commitId: { type: String, required: false, default: null, }, - commitId: { + defaultBranch: { type: String, required: false, default: null, @@ -54,6 +50,7 @@ export default { required: true, }, }, + inject: ['projectFullPath'], data() { return { ciConfigData: {}, @@ -72,7 +69,7 @@ export default { query: getBlobContent, variables() { return { - projectPath: this.projectPath, + projectPath: this.projectFullPath, path: this.ciConfigPath, ref: this.defaultBranch, }; @@ -204,7 +201,7 @@ export default { } = await this.$apollo.mutate({ mutation: commitCiFileMutation, variables: { - projectPath: this.projectPath, + projectPath: this.projectFullPath, branch, startBranch: this.defaultBranch, message, @@ -258,10 +255,20 @@ export default { - + - + diff --git a/app/assets/javascripts/vue_shared/components/editor_lite.vue b/app/assets/javascripts/vue_shared/components/editor_lite.vue index cfe3ce0a11cab1aa511bccdab3fffabf3baa525c..7218b84cf8a87f3dca06cc6ee5d290e9cf0fcc06 100644 --- a/app/assets/javascripts/vue_shared/components/editor_lite.vue +++ b/app/assets/javascripts/vue_shared/components/editor_lite.vue @@ -84,6 +84,9 @@ export default { onFileChange() { this.$emit('input', this.editor.getValue()); }, + getEditor() { + return this.editor; + }, }, }; diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml index f1f8658fa3b59348e526c3021c3f610271d068b3..70695c59327034481ebb0573066984dd04bc170f 100644 --- a/app/views/projects/ci/pipeline_editor/show.html.haml +++ b/app/views/projects/ci/pipeline_editor/show.html.haml @@ -1,8 +1,10 @@ - page_title s_('Pipelines|Pipeline Editor') #js-pipeline-editor{ data: { "ci-config-path": @project.ci_config_path_or_default, - "project-path" => @project.full_path, - "default-branch" => @project.default_branch, "commit-id" => @project.commit ? @project.commit.id : '', + "default-branch" => @project.default_branch, + "project-full-path" => @project.full_path, + "project-path" => @project.path, + "project-namespace" => @project.namespace.path, "new-merge-request-path" => namespace_project_new_merge_request_path, } } diff --git a/spec/frontend/editor/editor_ci_schema_ext_spec.js b/spec/frontend/editor/editor_ci_schema_ext_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..526d18675cc11b835d909299ccc144aca945f791 --- /dev/null +++ b/spec/frontend/editor/editor_ci_schema_ext_spec.js @@ -0,0 +1,76 @@ +import { languages } from 'monaco-editor'; +import EditorLite from '~/editor/editor_lite'; +import EditorCiSchemaExtension from '~/editor/editor_ci_schema_ext'; + +describe('~/editor/editor_ci_config_ext', () => { + const mockBlobPath = '.gitlab-ci.yml'; + let editor; + let instance; + let editorEl; + + beforeEach(() => { + setFixtures('
'); + editorEl = document.getElementById('editor'); + editor = new EditorLite(); + instance = editor.createInstance({ + el: editorEl, + blobPath: mockBlobPath, + blobContent: '', + }); + instance.use(EditorCiSchemaExtension); + }); + + afterEach(() => { + instance.dispose(); + editorEl.remove(); + }); + + describe('registerCiSchema', () => { + beforeEach(() => { + jest.spyOn(languages.yaml.yamlDefaults, 'setDiagnosticsOptions'); + }); + + describe('register validations options with monaco for yaml language', () => { + it('with expected basic validation configuration', () => { + instance.registerCiSchema({ projectNamespace: 'namespace1', projectPath: 'project1' }); + + const expectedOptions = { + validate: true, + enableSchemaRequest: true, + hover: true, + completion: true, + }; + + expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledWith( + expect.objectContaining(expectedOptions), + ); + }); + + it.each` + opts | expectedUri + ${{}} | ${'/namespace1/project1/-/schema/master/.gitlab-ci.yml'} + ${{ ref: 'REF' }} | ${'/namespace1/project1/-/schema/REF/.gitlab-ci.yml'} + ${{ projectNamespace: 'namespace2', projectPath: 'other-project' }} | ${'/namespace2/other-project/-/schema/master/.gitlab-ci.yml'} + `('with the expected schema for options "$opts"', ({ opts, expectedUri }) => { + instance.registerCiSchema({ + projectNamespace: 'namespace1', + projectPath: 'project1', + ...opts, + }); + + const expectedOptions = expect.objectContaining({ + schemas: [ + { + uri: expectedUri, + fileMatch: [mockBlobPath], + }, + ], + }); + + expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledWith( + expectedOptions, + ); + }); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/text_editor_spec.js b/spec/frontend/pipeline_editor/components/text_editor_spec.js index 18f71ebc95cc08f86476d3488f2a48e36ab7b44c..211bafa11e5de47a621331fea358f502d7b4e58c 100644 --- a/spec/frontend/pipeline_editor/components/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/text_editor_spec.js @@ -1,15 +1,34 @@ -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { shallowMount, mount } from '@vue/test-utils'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; -import { mockCiYml } from '../mock_data'; - +import EditorCiSchemaExtension from '~/editor/editor_ci_schema_ext'; import TextEditor from '~/pipeline_editor/components/text_editor.vue'; +import { + mockCiYml, + mockCiConfigPath, + mockProjectPath, + mockProjectNamespace, + mockCommitId, +} from '../mock_data'; + +jest.mock('~/editor/editor_ci_schema_ext'); + describe('~/pipeline_editor/components/text_editor.vue', () => { let wrapper; const editorReadyListener = jest.fn(); - const createComponent = (attrs = {}, mountFn = shallowMount) => { + const createComponent = ({ props = {}, attrs = {} } = {}, mountFn = shallowMount) => { wrapper = mountFn(TextEditor, { + provide: { + projectPath: mockProjectPath, + projectNamespace: mockProjectNamespace, + }, + propsData: { + ciConfigPath: mockCiConfigPath, + commitId: mockCommitId, + ...props, + }, attrs: { value: mockCiYml, ...attrs, @@ -20,25 +39,46 @@ describe('~/pipeline_editor/components/text_editor.vue', () => { }); }; - const findEditor = () => wrapper.find(EditorLite); - - it('contains an editor', () => { + beforeEach(() => { createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findEditorLite = () => wrapper.find(EditorLite); - expect(findEditor().exists()).toBe(true); + it('contains an editor', () => { + expect(findEditorLite().exists()).toBe(true); }); it('editor contains the value provided', () => { - expect(findEditor().props('value')).toBe(mockCiYml); + expect(findEditorLite().props('value')).toBe(mockCiYml); }); it('editor is configured for .yml', () => { - expect(findEditor().props('fileName')).toBe('*.yml'); + expect(findEditorLite().props('fileName')).toBe(mockCiConfigPath); }); - it('bubbles up events', () => { - findEditor().vm.$emit('editor-ready'); + it('bubbles up editor-ready event', () => { + createComponent({}, mount); + + findEditorLite().vm.$emit('editor-ready'); expect(editorReadyListener).toHaveBeenCalled(); }); + + it('registers ci schema extension', async () => { + createComponent({}, mount); + + await nextTick(); + + expect(EditorCiSchemaExtension.registerCiSchema).toHaveBeenCalledWith({ + projectPath: mockProjectPath, + projectNamespace: mockProjectNamespace, + ref: mockCommitId, + }); + }); }); diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js index b531f8af79747a3c3ef06808ec1d974ae22fc9d1..3e00852741550eff079fe304225874ded9fab20f 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -5,7 +5,7 @@ import { mockCiYml, mockDefaultBranch, mockLintResponse, - mockProjectPath, + mockProjectFullPath, } from '../mock_data'; import httpStatus from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; @@ -32,12 +32,12 @@ describe('~/pipeline_editor/graphql/resolvers', () => { it('resolves lint data with type names', async () => { const result = resolvers.Query.blobContent(null, { - projectPath: mockProjectPath, + projectPath: mockProjectFullPath, path: mockCiConfigPath, ref: mockDefaultBranch, }); - expect(Api.getRawFile).toHaveBeenCalledWith(mockProjectPath, mockCiConfigPath, { + expect(Api.getRawFile).toHaveBeenCalledWith(mockProjectFullPath, mockCiConfigPath, { ref: mockDefaultBranch, }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index d882490c272418ddd5f520c4ada39adc263e3a80..d783e561a77185cf9a8ced301608b982425e9ebc 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -1,4 +1,7 @@ -export const mockProjectPath = 'user1/project1'; +export const mockProjectFullPath = 'user1/project1'; +export const mockProjectPath = 'project1'; +export const mockProjectNamespace = 'user1'; + export const mockDefaultBranch = 'master'; export const mockNewMergeRequestPath = '/-/merge_requests/new'; export const mockCommitId = 'aabbccdd'; diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index ca54c97d2bb87ef48773a77c8977759a070c853f..5f26dc97ebb2e6b8e96212afcb0df9b0f02ad7a4 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -1,14 +1,6 @@ import { nextTick } from 'vue'; import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; -import { - GlAlert, - GlButton, - GlFormInput, - GlFormTextarea, - GlLoadingIcon, - GlTabs, - GlTab, -} from '@gitlab/ui'; +import { GlAlert, GlFormInput, GlFormTextarea, GlLoadingIcon, GlTab } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import VueApollo from 'vue-apollo'; import createMockApollo from 'jest/helpers/mock_apollo_helper'; @@ -21,7 +13,9 @@ import { mockCommitId, mockCommitMessage, mockDefaultBranch, + mockProjectFullPath, mockProjectPath, + mockProjectNamespace, mockNewMergeRequestPath, } from './mock_data'; @@ -34,6 +28,18 @@ import TextEditor from '~/pipeline_editor/components/text_editor.vue'; const localVue = createLocalVue(); localVue.use(VueApollo); +const MockEditorLite = { + template: '
', + methods: { + getEditor() { + return { + use: jest.fn(), + registerCiSchema: jest.fn(), + }; + }, + }, +}; + jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), refreshCurrentPage: jest.fn(), @@ -65,21 +71,21 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { }); wrapper = mountFn(PipelineEditorApp, { + provide: { + projectFullPath: mockProjectFullPath, + projectPath: mockProjectPath, + projectNamespace: mockProjectNamespace, + }, propsData: { ciConfigPath: mockCiConfigPath, commitId: mockCommitId, defaultBranch: mockDefaultBranch, - projectPath: mockProjectPath, newMergeRequestPath: mockNewMergeRequestPath, ...props, }, stubs: { - GlTabs, - GlButton, CommitForm, - EditorLite: { - template: '
', - }, + EditorLite: MockEditorLite, TextEditor, }, mocks: { @@ -125,7 +131,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findAlert = () => wrapper.find(GlAlert); const findTabAt = i => wrapper.findAll(GlTab).at(i); - const findTextEditor = () => wrapper.find(TextEditor); + const findTextEditor = () => wrapper.find(MockEditorLite); const findCommitForm = () => wrapper.find(CommitForm); const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').find(GlLoadingIcon); @@ -152,47 +158,68 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { expect(findTextEditor().exists()).toBe(false); }); - describe('tabs', () => { - beforeEach(() => { - createComponent(); - }); + describe('lazy tabs', () => { + it('displays editor tab, until editor is ready', async () => { + createComponent({ mountFn: mount }); + + expect( + findTabAt(0) + .find(TextEditor) + .exists(), + ).toBe(false); + + await nextTick(); - it('displays tabs and their content', async () => { expect( findTabAt(0) .find(TextEditor) .exists(), ).toBe(true); + }); + + it('displays pipeline tab lazily, when tab is selected', async () => { + createComponent({ mountFn: mount }); + expect( findTabAt(1) .find(PipelineGraph) .exists(), - ).toBe(true); - }); + ).toBe(false); - it('displays editor tab lazily, until editor is ready', async () => { - expect(findTabAt(0).attributes('lazy')).toBe('true'); + await nextTick(); - findTextEditor().vm.$emit('editor-ready'); + // select graph tab + wrapper.find('[data-testid="graph-tab-btn"]').trigger('click'); await nextTick(); - expect(findTabAt(0).attributes('lazy')).toBe(undefined); + expect( + findTabAt(1) + .find(PipelineGraph) + .exists(), + ).toBe(true); }); }); describe('when data is set', () => { - beforeEach(async () => { - createComponent({ mountFn: mount }); - - await wrapper.setData({ - content: mockCiYml, - contentModel: mockCiYml, + beforeEach(() => { + createComponent({ + options: { + data() { + return { + content: mockCiYml, + contentModel: mockCiYml, + }; + }, + }, + mountFn: mount, }); }); it('displays content after the query loads', () => { expect(findLoadingIcon().exists()).toBe(false); + + expect(findTextEditor().attributes('file-name')).toBe(mockCiConfigPath); expect(findTextEditor().attributes('value')).toBe(mockCiYml); }); @@ -202,7 +229,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { filePath: mockCiConfigPath, lastCommitId: mockCommitId, message: mockCommitMessage, - projectPath: mockProjectPath, + projectPath: mockProjectFullPath, startBranch: mockDefaultBranch, }; diff --git a/spec/frontend/vue_shared/components/editor_lite_spec.js b/spec/frontend/vue_shared/components/editor_lite_spec.js index 52502fcf64fc556fedb09b2a6682c79187cba759..cc11f76d01f8c8ef42513a97d062c1a7b51f193a 100644 --- a/spec/frontend/vue_shared/components/editor_lite_spec.js +++ b/spec/frontend/vue_shared/components/editor_lite_spec.js @@ -7,20 +7,22 @@ jest.mock('~/editor/editor_lite'); describe('Editor Lite component', () => { let wrapper; - const onDidChangeModelContent = jest.fn(); - const updateModelLanguage = jest.fn(); - const getValue = jest.fn(); - const setValue = jest.fn(); + let mockInstance; + const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; const fileName = 'lorem.txt'; const fileGlobalId = 'snippet_777'; - const createInstanceMock = jest.fn().mockImplementation(() => ({ - onDidChangeModelContent, - updateModelLanguage, - getValue, - setValue, - dispose: jest.fn(), - })); + const createInstanceMock = jest.fn().mockImplementation(() => { + mockInstance = { + onDidChangeModelContent: jest.fn(), + updateModelLanguage: jest.fn(), + getValue: jest.fn(), + setValue: jest.fn(), + dispose: jest.fn(), + }; + return mockInstance; + }); + Editor.mockImplementation(() => { return { createInstance: createInstanceMock, @@ -46,8 +48,8 @@ describe('Editor Lite component', () => { }); const triggerChangeContent = val => { - getValue.mockReturnValue(val); - const [cb] = onDidChangeModelContent.mock.calls[0]; + mockInstance.getValue.mockReturnValue(val); + const [cb] = mockInstance.onDidChangeModelContent.mock.calls[0]; cb(); @@ -92,12 +94,12 @@ describe('Editor Lite component', () => { }); return nextTick().then(() => { - expect(updateModelLanguage).toHaveBeenCalledWith(newFileName); + expect(mockInstance.updateModelLanguage).toHaveBeenCalledWith(newFileName); }); }); it('registers callback with editor onChangeContent', () => { - expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); + expect(mockInstance.onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); }); it('emits input event when the blob content is changed', () => { @@ -117,6 +119,10 @@ describe('Editor Lite component', () => { expect(wrapper.emitted()['editor-ready']).toBeDefined(); }); + it('component API `getEditor()` returns the editor instance', () => { + expect(wrapper.vm.getEditor()).toBe(mockInstance); + }); + describe('reaction to the value update', () => { it('reacts to the changes in the passed value', async () => { const newValue = 'New Value'; @@ -126,7 +132,7 @@ describe('Editor Lite component', () => { }); await nextTick(); - expect(setValue).toHaveBeenCalledWith(newValue); + expect(mockInstance.setValue).toHaveBeenCalledWith(newValue); }); it("does not update value if the passed one is exactly the same as the editor's content", async () => { @@ -137,7 +143,7 @@ describe('Editor Lite component', () => { }); await nextTick(); - expect(setValue).not.toHaveBeenCalled(); + expect(mockInstance.setValue).not.toHaveBeenCalled(); }); }); });