Commit 20f7e1a0 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '207463-edit-form-connected' into 'master'

Snippet edit form

See merge request gitlab-org/gitlab!28600
parents a792e9ed 78f1d2fb
<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