Commit 78f1d2fb authored by Denys Mishunov's avatar Denys Mishunov Committed by Kushal Pandya

Initialized Snippet Edit Vue app

On the Snippet Edit form, initialize root Vue application

Support creation of new snippets
parent 93d29d34
<script>
import { initEditorLite } from '~/blob/utils';
import { debounce } from 'lodash';
export default {
props: {
......@@ -32,16 +33,14 @@ export default {
});
},
methods: {
triggerFileChange() {
triggerFileChange: debounce(function debouncedFileChange() {
this.$emit('input', this.editor.getValue());
},
}, 250),
},
};
</script>
<template>
<div class="file-content code">
<pre id="editor" ref="editor" data-editor-loading @focusout="triggerFileChange">{{
value
}}</pre>
<pre id="editor" ref="editor" data-editor-loading @keyup="triggerFileChange">{{ value }}</pre>
</div>
</template>
......@@ -2,6 +2,7 @@ import $ from 'jquery';
import initSnippet from '~/snippet/snippet_bundle';
import ZenMode from '~/zen_mode';
import GLForm from '~/gl_form';
import { SnippetEditInit } from '~/snippets';
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('.snippet-form');
......@@ -17,9 +18,15 @@ document.addEventListener('DOMContentLoaded', () => {
const projectSnippetOptions = {};
const options =
form.dataset.snippetType === 'project' ? projectSnippetOptions : personalSnippetOptions;
form.dataset.snippetType === 'project' || form.dataset.projectPath
? projectSnippetOptions
: personalSnippetOptions;
if (gon?.features?.snippetsEditVue) {
SnippetEditInit();
} else {
initSnippet();
new ZenMode(); // eslint-disable-line no-new
new GLForm($(form), options); // eslint-disable-line no-new
}
new ZenMode(); // eslint-disable-line no-new
});
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import Flash from '~/flash';
import { __, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import TitleField from '~/vue_shared/components/form/title.vue';
import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
import { getSnippetMixin } from '../mixins/snippets';
import { SNIPPET_VISIBILITY_PRIVATE } from '../constants';
import SnippetBlobEdit from './snippet_blob_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
import SnippetDescriptionEdit from './snippet_description_edit.vue';
export default {
components: {
SnippetDescriptionEdit,
SnippetVisibilityEdit,
SnippetBlobEdit,
TitleField,
FormFooterActions,
GlButton,
GlLoadingIcon,
},
mixins: [getSnippetMixin],
props: {
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocsPath: {
type: String,
required: true,
},
visibilityHelpLink: {
type: String,
default: '',
required: false,
},
projectPath: {
type: String,
default: '',
required: false,
},
},
data() {
return {
blob: {},
fileName: '',
content: '',
isContentLoading: true,
isUpdating: false,
newSnippet: false,
};
},
computed: {
updatePrevented() {
return this.snippet.title === '' || this.content === '' || this.isUpdating;
},
isProjectSnippet() {
return Boolean(this.projectPath);
},
apiData() {
return {
id: this.snippet.id,
title: this.snippet.title,
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
fileName: this.fileName,
content: this.content,
};
},
saveButtonLabel() {
if (this.newSnippet) {
return __('Create snippet');
}
return this.isUpdating ? __('Saving') : __('Save changes');
},
cancelButtonHref() {
return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`;
},
titleFieldId() {
return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`;
},
descriptionFieldId() {
return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`;
},
},
methods: {
updateFileName(newName) {
this.fileName = newName;
},
flashAPIFailure(err) {
Flash(sprintf(__("Can't update snippet: %{err}"), { err }));
},
onNewSnippetFetched() {
this.newSnippet = true;
this.snippet = this.$options.newSnippetSchema;
this.blob = this.snippet.blob;
this.isContentLoading = false;
},
onExistingSnippetFetched() {
this.newSnippet = false;
const { blob } = this.snippet;
this.blob = blob;
this.fileName = blob.name;
const baseUrl = getBaseURL();
const url = joinPaths(baseUrl, blob.rawPath);
axios
.get(url)
.then(res => {
this.content = res.data;
this.isContentLoading = false;
})
.catch(e => this.flashAPIFailure(e));
},
onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) {
this.onNewSnippetFetched();
} else {
this.onExistingSnippetFetched();
}
},
handleFormSubmit() {
this.isUpdating = true;
this.$apollo
.mutate({
mutation: this.newSnippet ? CreateSnippetMutation : UpdateSnippetMutation,
variables: {
input: {
...this.apiData,
projectPath: this.newSnippet ? this.projectPath : undefined,
},
},
})
.then(({ data }) => {
const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
const errors = baseObj?.errors;
if (errors.length) {
this.flashAPIFailure(errors[0]);
}
redirectTo(baseObj.snippet.webUrl);
})
.catch(e => {
this.isUpdating = false;
this.flashAPIFailure(e);
});
},
},
newSnippetSchema: {
title: '',
description: '',
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
blob: {},
},
};
</script>
<template>
<form
class="snippet-form js-requires-input js-quick-submit common-note-form"
:data-snippet-type="isProjectSnippet ? 'project' : 'personal'"
>
<gl-loading-icon
v-if="isLoading"
:label="__('Loading snippet')"
size="lg"
class="loading-animation prepend-top-20 append-bottom-20"
/>
<template v-else>
<title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" />
<snippet-description-edit
:id="descriptionFieldId"
v-model="snippet.description"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
/>
<snippet-blob-edit
v-model="content"
:file-name="fileName"
:is-loading="isContentLoading"
@name-change="updateFileName"
/>
<snippet-visibility-edit
v-model="snippet.visibilityLevel"
:help-link="visibilityHelpLink"
:is-project-snippet="isProjectSnippet"
/>
<form-footer-actions>
<template #prepend>
<gl-button
type="submit"
category="primary"
variant="success"
:disabled="updatePrevented"
@click="handleFormSubmit"
>{{ saveButtonLabel }}</gl-button
>
</template>
<template #append>
<gl-button :href="cancelButtonHref">{{ __('Cancel') }}</gl-button>
</template>
</form-footer-actions>
</template>
</form>
</template>
......@@ -50,7 +50,6 @@ export default {
:markdown-docs-path="markdownDocsPath"
>
<textarea
id="snippet-description"
slot="textarea"
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
......@@ -59,6 +58,7 @@ export default {
:value="value"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
v-bind="$attrs"
@input="$emit('input', $event.target.value)"
>
</textarea>
......
......@@ -3,7 +3,8 @@ import Translate from '~/vue_shared/translate';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import SnippetsApp from './components/show.vue';
import SnippetsShow from './components/show.vue';
import SnippetsEdit from './components/edit.vue';
Vue.use(VueApollo);
Vue.use(Translate);
......@@ -31,7 +32,11 @@ function appFactory(el, Component) {
}
export const SnippetShowInit = () => {
appFactory(document.getElementById('js-snippet-view'), SnippetsApp);
appFactory(document.getElementById('js-snippet-view'), SnippetsShow);
};
export const SnippetEditInit = () => {
appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit);
};
export default () => {};
mutation CreateSnippet($input: CreateSnippetInput!) {
createSnippet(input: $input) {
errors
snippet {
webUrl
}
}
}
mutation UpdateSnippet($input: UpdateSnippetInput!) {
updateSnippet(input: $input) {
errors
snippet {
webUrl
}
}
}
......@@ -10,6 +10,6 @@ export default {
</script>
<template>
<gl-form-group :label="__('Title')" label-for="title-field-edit">
<gl-form-input id="title-field-edit" v-bind="$attrs" v-on="$listeners" />
<gl-form-input v-bind="$attrs" v-on="$listeners" />
</gl-form-group>
</template>
- content_for :page_specific_javascripts do
- if Feature.disabled?(:monaco_snippets)
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/ace.js')
.snippet-form-holder
- if Feature.enabled?(:snippets_edit_vue)
#js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access") } }
- else
.snippet-form-holder
= form_for @snippet, url: url,
html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" },
data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f|
......
---
title: Refactored Snippet edit form to Vue
merge_request: 28600
author:
type: added
......@@ -3408,6 +3408,9 @@ msgstr ""
msgid "Can't scan the code?"
msgstr ""
msgid "Can't update snippet: %{err}"
msgstr ""
msgid "Canary"
msgstr ""
......@@ -6070,6 +6073,9 @@ msgstr ""
msgid "Create requirement"
msgstr ""
msgid "Create snippet"
msgstr ""
msgid "Create wildcard: %{searchTerm}"
msgstr ""
......
......@@ -4,6 +4,7 @@ require 'spec_helper'
shared_examples_for 'snippet editor' do
before do
stub_feature_flags(snippets_edit_vue: false)
stub_feature_flags(monaco_snippets: flag)
end
......
......@@ -11,6 +11,7 @@ describe 'Projects > Snippets > User updates a snippet', :js do
before do
stub_feature_flags(snippets_vue: false)
stub_feature_flags(snippets_edit_vue: false)
stub_feature_flags(version_snippets: version_snippet_enabled)
project.add_maintainer(user)
......
......@@ -10,6 +10,7 @@ shared_examples_for 'snippet editor' do
before do
stub_feature_flags(allow_possible_spam: false)
stub_feature_flags(snippets_vue: false)
stub_feature_flags(snippets_edit_vue: false)
stub_feature_flags(monaco_snippets: flag)
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
......
......@@ -5,6 +5,7 @@ require 'spec_helper'
shared_examples_for 'snippet editor' do
before do
stub_feature_flags(snippets_vue: false)
stub_feature_flags(snippets_edit_vue: false)
stub_feature_flags(monaco_snippets: flag)
sign_in(user)
visit new_snippet_path
......
......@@ -14,6 +14,7 @@ describe 'User edits snippet', :js do
before do
stub_feature_flags(snippets_vue: false)
stub_feature_flags(snippets_edit_vue: false)
stub_feature_flags(version_snippets: version_snippet_enabled)
sign_in(user)
......
......@@ -80,7 +80,7 @@ describe('Blob Header Editing', () => {
getValue: jest.fn().mockReturnValue(value),
};
editorEl.trigger('focusout');
editorEl.trigger('keyup');
return nextTick().then(() => {
expect(wrapper.emitted().input[0]).toEqual([value]);
......
export const triggerDOMEvent = type => {
window.document.dispatchEvent(
new Event(type, {
bubbles: true,
cancelable: true,
}),
);
};
export default () => {};
import '~/snippet/snippet_edit';
import { SnippetEditInit } from '~/snippets';
import initSnippet from '~/snippet/snippet_bundle';
import { triggerDOMEvent } from 'jest/helpers/dom_events_helper';
jest.mock('~/snippet/snippet_bundle');
jest.mock('~/snippets');
describe('Snippet edit form initialization', () => {
const setFF = flag => {
gon.features = { snippetsEditVue: flag };
};
let features;
beforeEach(() => {
features = gon.features;
setFixtures('<div class="snippet-form"></div>');
});
afterEach(() => {
gon.features = features;
});
it.each`
name | flag | isVue
${'Regular'} | ${false} | ${false}
${'Vue'} | ${true} | ${true}
`('correctly initializes $name Snippet Edit form', ({ flag, isVue }) => {
initSnippet.mockClear();
SnippetEditInit.mockClear();
setFF(flag);
triggerDOMEvent('DOMContentLoaded');
if (isVue) {
expect(initSnippet).not.toHaveBeenCalled();
expect(SnippetEditInit).toHaveBeenCalled();
} else {
expect(initSnippet).toHaveBeenCalled();
expect(SnippetEditInit).not.toHaveBeenCalled();
}
});
});
......@@ -39,7 +39,6 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
qa-description-textarea"
data-supports-quick-actions="false"
dir="auto"
id="snippet-description"
placeholder="Write a comment or drag your files here…"
/>
</markdown-field-stub>
......
import { shallowMount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import { GlLoadingIcon } from '@gitlab/ui';
import { joinPaths, redirectTo } from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue';
import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue';
import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue';
import TitleField from '~/vue_shared/components/form/title.vue';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql';
import AxiosMockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import { ApolloMutation } from 'vue-apollo';
jest.mock('~/lib/utils/url_utility', () => ({
getBaseURL: jest.fn().mockReturnValue('foo/'),
redirectTo: jest.fn().mockName('redirectTo'),
joinPaths: jest
.fn()
.mockName('joinPaths')
.mockReturnValue('contentApiURL'),
}));
let flashSpy;
const contentMock = 'Foo Bar';
const rawPathMock = '/foo/bar';
const rawProjectPathMock = '/project/path';
const newlyEditedSnippetUrl = 'http://foo.bar';
const apiError = { message: 'Ufff' };
const defaultProps = {
snippetGid: 'gid://gitlab/PersonalSnippet/42',
markdownPreviewPath: 'http://preview.foo.bar',
markdownDocsPath: 'http://docs.foo.bar',
};
describe('Snippet Edit app', () => {
let wrapper;
let axiosMock;
const resolveMutate = jest.fn().mockResolvedValue({
data: {
updateSnippet: {
errors: [],
snippet: {
webUrl: newlyEditedSnippetUrl,
},
},
},
});
const rejectMutation = jest.fn().mockRejectedValue(apiError);
const mutationTypes = {
RESOLVE: resolveMutate,
REJECT: rejectMutation,
};
function createComponent({
props = defaultProps,
data = {},
loading = false,
mutationRes = mutationTypes.RESOLVE,
} = {}) {
const $apollo = {
queries: {
snippet: {
loading,
},
},
mutate: mutationRes,
};
wrapper = shallowMount(SnippetEditApp, {
mocks: { $apollo },
stubs: {
FormFooterActions,
ApolloMutation,
},
propsData: {
...props,
},
data() {
return data;
},
});
flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure');
}
afterEach(() => {
wrapper.destroy();
});
const findSubmitButton = () => wrapper.find('[type=submit]');
describe('rendering', () => {
it('renders loader while the query is in flight', () => {
createComponent({ loading: true });
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('renders all required components', () => {
createComponent();
expect(wrapper.contains(TitleField)).toBe(true);
expect(wrapper.contains(SnippetDescriptionEdit)).toBe(true);
expect(wrapper.contains(SnippetBlobEdit)).toBe(true);
expect(wrapper.contains(SnippetVisibilityEdit)).toBe(true);
expect(wrapper.contains(FormFooterActions)).toBe(true);
});
it('does not fail if there is no snippet yet (new snippet creation)', () => {
const snippetGid = '';
createComponent({
props: {
...defaultProps,
snippetGid,
},
});
expect(wrapper.props('snippetGid')).toBe(snippetGid);
});
it.each`
title | content | expectation
${''} | ${''} | ${true}
${'foo'} | ${''} | ${true}
${''} | ${'foo'} | ${true}
${'foo'} | ${'bar'} | ${false}
`(
'disables submit button unless both title and content are present',
({ title, content, expectation }) => {
createComponent({
data: {
snippet: { title },
content,
},
});
const isBtnDisabled = Boolean(findSubmitButton().attributes('disabled'));
expect(isBtnDisabled).toBe(expectation);
},
);
});
describe('functionality', () => {
describe('handling of the data from GraphQL response', () => {
const snippet = {
blob: {
rawPath: rawPathMock,
},
};
const getResSchema = newSnippet => {
return {
data: {
snippets: {
edges: newSnippet ? [] : [snippet],
},
},
};
};
const bootstrapForExistingSnippet = resp => {
createComponent({
data: {
snippet,
},
});
if (resp === 500) {
axiosMock.onGet('contentApiURL').reply(500);
} else {
axiosMock.onGet('contentApiURL').reply(200, contentMock);
}
wrapper.vm.onSnippetFetch(getResSchema());
};
const bootstrapForNewSnippet = () => {
createComponent();
wrapper.vm.onSnippetFetch(getResSchema(true));
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
axiosMock.restore();
});
it('fetches blob content with the additional query', () => {
bootstrapForExistingSnippet();
return waitForPromises().then(() => {
expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock);
expect(wrapper.vm.newSnippet).toBe(false);
expect(wrapper.vm.content).toBe(contentMock);
});
});
it('flashes the error message if fetching content fails', () => {
bootstrapForExistingSnippet(500);
return waitForPromises().then(() => {
expect(flashSpy).toHaveBeenCalled();
expect(wrapper.vm.content).toBe('');
});
});
it('does not fetch content for new snippet', () => {
bootstrapForNewSnippet();
return waitForPromises().then(() => {
// we keep using waitForPromises to make sure we do not run failed test
expect(wrapper.vm.newSnippet).toBe(true);
expect(wrapper.vm.content).toBe('');
expect(joinPaths).not.toHaveBeenCalled();
expect(wrapper.vm.snippet).toEqual(wrapper.vm.$options.newSnippetSchema);
});
});
});
describe('form submission handling', () => {
it.each`
newSnippet | projectPath | mutation | mutationName
${true} | ${rawProjectPathMock} | ${CreateSnippetMutation} | ${'CreateSnippetMutation with projectPath'}
${true} | ${''} | ${CreateSnippetMutation} | ${'CreateSnippetMutation without projectPath'}
${false} | ${rawProjectPathMock} | ${UpdateSnippetMutation} | ${'UpdateSnippetMutation with projectPath'}
${false} | ${''} | ${UpdateSnippetMutation} | ${'UpdateSnippetMutation without projectPath'}
`('should submit $mutationName correctly', ({ newSnippet, projectPath, mutation }) => {
createComponent({
data: {
newSnippet,
},
props: {
...defaultProps,
projectPath,
},
});
const mutationPayload = {
mutation,
variables: {
input: newSnippet ? expect.objectContaining({ projectPath }) : expect.any(Object),
},
};
wrapper.vm.handleFormSubmit();
expect(resolveMutate).toHaveBeenCalledWith(mutationPayload);
});
it('redirects to snippet view on successful mutation', () => {
createComponent();
wrapper.vm.handleFormSubmit();
return waitForPromises().then(() => {
expect(redirectTo).toHaveBeenCalledWith(newlyEditedSnippetUrl);
});
});
it('flashes an error if mutation failed', () => {
createComponent({
mutationRes: mutationTypes.REJECT,
});
wrapper.vm.handleFormSubmit();
return waitForPromises().then(() => {
expect(redirectTo).not.toHaveBeenCalled();
expect(flashSpy).toHaveBeenCalledWith(apiError);
});
});
});
});
});
......@@ -5,8 +5,6 @@ exports[`Title edit field matches the snapshot 1`] = `
label="Title"
label-for="title-field-edit"
>
<gl-form-input-stub
id="title-field-edit"
/>
<gl-form-input-stub />
</gl-form-group-stub>
`;
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