Commit 88d164a2 authored by Miguel Rincon's avatar Miguel Rincon

Add readonly editor to visualize ci config

As a first iteration on the CI editor, this change adds an simple editor
(editor lite) and loads data of a single .gitlab-ci file.

This change uses an graphql queyr that wraps a REST API to load the
contents of the file. Some client side schema definitions were added
for this.
parent fc6ff243
<script>
import EditorLite from '~/vue_shared/components/editor_lite.vue';
export default {
components: {
EditorLite,
},
props: {
value: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div class="gl-border-solid gl-border-gray-100 gl-border-1">
<editor-lite v-model="value" file-name="*.yml" :editor-options="{ readOnly: true }" />
</div>
</template>
query getBlobContent($projectPath: ID!, $path: String, $ref: String!) {
blobContent(projectPath: $projectPath, path: $path, ref: $ref) @client {
rawData
}
}
import Api from '~/api';
export const resolvers = {
Query: {
blobContent(_, { projectPath, path, ref }) {
return {
__typename: 'BlobContent',
rawData: Api.getRawFile(projectPath, path, { ref }).then(({ data }) => {
return data;
}),
};
},
},
};
export default resolvers;
type BlobContent {
rawData: String!
}
extend type Query {
blobContent: BlobContent
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import typeDefs from './graphql/typedefs.graphql';
import { resolvers } from './graphql/resolvers';
import PipelineEditorApp from './pipeline_editor_app.vue';
export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const el = document.querySelector(selector);
const { projectPath, defaultBranch, ciConfigPath } = el?.dataset;
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(resolvers, { typeDefs }),
});
return new Vue({
el,
apolloProvider,
render(h) {
return h(PipelineEditorApp);
return h(PipelineEditorApp, {
props: {
projectPath,
defaultBranch,
ciConfigPath,
},
});
},
});
};
<script>
import { GlEmptyState } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import TextEditor from './components/text_editor.vue';
import getBlobContent from './graphql/queries/blob_content.graphql';
export default {
components: {
GlEmptyState,
GlLoadingIcon,
GlAlert,
TextEditor,
},
props: {
projectPath: {
type: String,
required: true,
},
defaultBranch: {
type: String,
required: false,
default: null,
},
ciConfigPath: {
type: String,
required: true,
},
},
data() {
return {
error: null,
content: '',
};
},
apollo: {
content: {
query: getBlobContent,
variables() {
return {
projectPath: this.projectPath,
path: this.ciConfigPath,
ref: this.defaultBranch,
};
},
update(data) {
return data?.blobContent?.rawData;
},
error(error) {
this.error = error;
},
},
},
computed: {
loading() {
return this.$apollo.queries.content.loading;
},
errorMessage() {
const { message, networkError } = this.error ?? {};
let reason = message ?? this.$options.i18n.unknownMessage;
if (networkError && networkError.response) {
const { data = {} } = networkError.response;
// 400 for a missing ref uses `error`
// 404 for missing file uses `message`
reason = data.message ?? data.error ?? reason;
}
return sprintf(this.$options.i18n.errorMessageWithReason, { reason });
},
},
i18n: {
title: s__('Pipelines|Pipeline Editor'),
description: s__(
'Pipelines|We are beginning our work around building the foundation for our dedicated pipeline editor.',
),
primaryButtonText: __('Learn more'),
unknownMessage: __('Unknown Error'),
errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'),
},
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.title"
:description="$options.i18n.description"
:primary-button-text="$options.i18n.primaryButtonText"
primary-button-link="https://about.gitlab.com/direction/verify/pipeline_authoring/"
/>
<div class="gl-mt-4">
<gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert>
<div class="gl-mt-4">
<gl-loading-icon v-if="loading" size="lg" />
<text-editor v-else v-model="content" />
</div>
</div>
</template>
- page_title s_('Pipelines|Pipeline Editor')
#js-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,
} }
......@@ -19519,6 +19519,9 @@ msgstr ""
msgid "Pipelines|CI Lint"
msgstr ""
msgid "Pipelines|CI file could not be loaded: %{reason}"
msgstr ""
msgid "Pipelines|Child pipeline"
msgstr ""
......@@ -19609,9 +19612,6 @@ msgstr ""
msgid "Pipelines|Trigger user has insufficient permissions to project"
msgstr ""
msgid "Pipelines|We are beginning our work around building the foundation for our dedicated pipeline editor."
msgstr ""
msgid "Pipelines|invalid"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
import { mockCiYml } from '../mock_data';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
describe('~/pipeline_editor/components/text_editor.vue', () => {
let wrapper;
const createComponent = (props = {}, mountFn = shallowMount) => {
wrapper = mountFn(TextEditor, {
propsData: {
value: mockCiYml,
...props,
},
});
};
const findEditor = () => wrapper.find(EditorLite);
it('contains an editor', () => {
createComponent();
expect(findEditor().exists()).toBe(true);
});
it('editor contains the value provided', () => {
expect(findEditor().props('value')).toBe(mockCiYml);
});
it('editor is readony and configured for .yml', () => {
expect(findEditor().props('editorOptions')).toEqual({ readOnly: true });
expect(findEditor().props('fileName')).toBe('*.yml');
});
});
import Api from '~/api';
import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from '../mock_data';
import { resolvers } from '~/pipeline_editor/graphql/resolvers';
jest.mock('~/api', () => {
return {
getRawFile: jest.fn(),
};
});
describe('~/pipeline_editor/graphql/resolvers', () => {
describe('Query', () => {
describe('blobContent', () => {
beforeEach(() => {
Api.getRawFile.mockResolvedValue({
data: mockCiYml,
});
});
afterEach(() => {
Api.getRawFile.mockReset();
});
it('resolves lint data with type names', async () => {
const result = resolvers.Query.blobContent(null, {
projectPath: mockProjectPath,
path: mockCiConfigPath,
ref: mockDefaultBranch,
});
expect(Api.getRawFile).toHaveBeenCalledWith(mockProjectPath, mockCiConfigPath, {
ref: mockDefaultBranch,
});
// eslint-disable-next-line no-underscore-dangle
expect(result.__typename).toBe('BlobContent');
await expect(result.rawData).resolves.toBe(mockCiYml);
});
});
});
});
export const mockProjectPath = 'user1/project1';
export const mockDefaultBranch = 'master';
export const mockCiConfigPath = '.gitlab-ci.yml';
export const mockCiYml = `
job1:
stage: test
script:
- echo 'test'
`;
import { nextTick } from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from './mock_data';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
let wrapper;
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(PipelineEditorApp);
const createComponent = ({ props = {}, loading = false } = {}, mountFn = shallowMount) => {
wrapper = mountFn(PipelineEditorApp, {
propsData: {
projectPath: mockProjectPath,
defaultBranch: mockDefaultBranch,
ciConfigPath: mockCiConfigPath,
...props,
},
stubs: {
TextEditor,
},
mocks: {
$apollo: {
queries: {
content: {
loading,
},
},
},
},
});
};
const findEmptyState = () => wrapper.find(GlEmptyState);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findAlert = () => wrapper.find(GlAlert);
const findEditor = () => wrapper.find(EditorLite);
it('contains an empty state', () => {
it('displays content', async () => {
createComponent();
wrapper.setData({ content: mockCiYml });
await nextTick();
expect(findEmptyState().exists()).toBe(true);
expect(findLoadingIcon().exists()).toBe(false);
expect(findEditor().props('value')).toBe(mockCiYml);
});
it('contains a text description', () => {
createComponent(mount);
it('displays a loading icon if the query is loading', async () => {
createComponent({ loading: true });
expect(findEmptyState().text()).toMatchInterpolatedText(
'Pipeline Editor We are beginning our work around building the foundation for our dedicated pipeline editor. Learn more',
);
expect(findLoadingIcon().exists()).toBe(true);
});
describe('when in error state', () => {
class MockError extends Error {
constructor(message, data) {
super(message);
if (data) {
this.networkError = {
response: { data },
};
}
}
}
beforeEach(() => {
createComponent(mount);
});
it('shows a generic error', async () => {
wrapper.setData({ error: new MockError('An error message') });
await nextTick();
expect(findAlert().text()).toBe('CI file could not be loaded: An error message');
});
it('shows a ref missing error state', async () => {
const error = new MockError('Ref missing!', {
error: 'ref is missing, ref is empty',
});
wrapper.setData({ error });
await nextTick();
expect(findAlert().text()).toMatch(
'CI file could not be loaded: ref is missing, ref is empty',
);
});
it('shows a file missing error state', async () => {
const error = new MockError('File missing!', {
message: 'file not found',
});
wrapper.setData({ error });
await nextTick();
expect(findAlert().text()).toMatch('CI file could not be loaded: file not found');
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment