Commit 4556e138 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '220785-snippet-editing-multi' into 'master'

Support multiple files when editing snippets

See merge request gitlab-org/gitlab!37079
parents 9567f485 90fdcc2c
...@@ -3,9 +3,8 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; ...@@ -3,9 +3,8 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import Flash from '~/flash'; import Flash from '~/flash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import TitleField from '~/vue_shared/components/form/title.vue'; import TitleField from '~/vue_shared/components/form/title.vue';
import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
...@@ -15,6 +14,9 @@ import { ...@@ -15,6 +14,9 @@ import {
SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_CREATE_MUTATION_ERROR,
SNIPPET_UPDATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR,
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
} from '../constants'; } from '../constants';
import SnippetBlobEdit from './snippet_blob_edit.vue'; import SnippetBlobEdit from './snippet_blob_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
...@@ -53,18 +55,25 @@ export default { ...@@ -53,18 +55,25 @@ export default {
}, },
data() { data() {
return { return {
blob: {}, blobsActions: {},
fileName: '',
content: '',
originalContent: '',
isContentLoading: true,
isUpdating: false, isUpdating: false,
newSnippet: false, newSnippet: false,
}; };
}, },
computed: { computed: {
getActionsEntries() {
return Object.values(this.blobsActions);
},
allBlobsHaveContent() {
const entries = this.getActionsEntries;
return entries.length > 0 && !entries.find(action => !action.content);
},
allBlobChangesRegistered() {
const entries = this.getActionsEntries;
return entries.length > 0 && !entries.find(action => action.action === '');
},
updatePrevented() { updatePrevented() {
return this.snippet.title === '' || this.content === '' || this.isUpdating; return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating;
}, },
isProjectSnippet() { isProjectSnippet() {
return Boolean(this.projectPath); return Boolean(this.projectPath);
...@@ -75,8 +84,7 @@ export default { ...@@ -75,8 +84,7 @@ export default {
title: this.snippet.title, title: this.snippet.title,
description: this.snippet.description, description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel, visibilityLevel: this.snippet.visibilityLevel,
fileName: this.fileName, files: this.getActionsEntries.filter(entry => entry.action !== ''),
content: this.content,
}; };
}, },
saveButtonLabel() { saveButtonLabel() {
...@@ -108,16 +116,47 @@ export default { ...@@ -108,16 +116,47 @@ export default {
onBeforeUnload(e = {}) { onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?'); const returnValue = __('Are you sure you want to lose unsaved changes?');
if (!this.hasChanges()) return undefined; if (!this.allBlobChangesRegistered) return undefined;
Object.assign(e, { returnValue }); Object.assign(e, { returnValue });
return returnValue; return returnValue;
}, },
hasChanges() { updateBlobActions(args = {}) {
return this.content !== this.originalContent; // `_constants` is the internal prop that
}, // should not be sent to the mutation. Hence we filter it out from
updateFileName(newName) { // the argsToUpdateAction that is the data-basis for the mutation.
this.fileName = newName; const { _constants: blobConstants, ...argsToUpdateAction } = args;
const { previousPath, filePath, content } = argsToUpdateAction;
let actionEntry = this.blobsActions[blobConstants.id] || {};
let tunedActions = {
action: '',
previousPath,
};
if (this.newSnippet) {
// new snippet, hence new blob
tunedActions = {
action: SNIPPET_BLOB_ACTION_CREATE,
previousPath: '',
};
} else if (previousPath && filePath) {
// renaming of a blob + renaming & content update
const renamedToOriginal = filePath === blobConstants.originalPath;
tunedActions = {
action: renamedToOriginal ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE,
previousPath: !renamedToOriginal ? blobConstants.originalPath : '',
};
} else if (content !== blobConstants.originalContent) {
// content update only
tunedActions = {
action: SNIPPET_BLOB_ACTION_UPDATE,
previousPath: '',
};
}
actionEntry = { ...actionEntry, ...argsToUpdateAction, ...tunedActions };
this.$set(this.blobsActions, blobConstants.id, actionEntry);
}, },
flashAPIFailure(err) { flashAPIFailure(err) {
const defaultErrorMsg = this.newSnippet const defaultErrorMsg = this.newSnippet
...@@ -129,26 +168,9 @@ export default { ...@@ -129,26 +168,9 @@ export default {
onNewSnippetFetched() { onNewSnippetFetched() {
this.newSnippet = true; this.newSnippet = true;
this.snippet = this.$options.newSnippetSchema; this.snippet = this.$options.newSnippetSchema;
this.blob = this.snippet.blob;
this.isContentLoading = false;
}, },
onExistingSnippetFetched() { onExistingSnippetFetched() {
this.newSnippet = false; 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.originalContent = res.data;
this.content = res.data;
this.isContentLoading = false;
})
.catch(e => this.flashAPIFailure(e));
}, },
onSnippetFetch(snippetRes) { onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) { if (snippetRes.data.snippets.edges.length === 0) {
...@@ -205,7 +227,6 @@ export default { ...@@ -205,7 +227,6 @@ export default {
title: '', title: '',
description: '', description: '',
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
blob: {},
}, },
}; };
</script> </script>
...@@ -236,12 +257,16 @@ export default { ...@@ -236,12 +257,16 @@ export default {
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath" :markdown-docs-path="markdownDocsPath"
/> />
<template v-if="blobs.length">
<snippet-blob-edit <snippet-blob-edit
v-model="content" v-for="blob in blobs"
:file-name="fileName" :key="blob.name"
:is-loading="isContentLoading" :blob="blob"
@name-change="updateFileName" @blob-updated="updateBlobActions"
/> />
</template>
<snippet-blob-edit v-else @blob-updated="updateBlobActions" />
<snippet-visibility-edit <snippet-visibility-edit
v-model="snippet.visibilityLevel" v-model="snippet.visibilityLevel"
:help-link="visibilityHelpLink" :help-link="visibilityHelpLink"
......
...@@ -2,6 +2,17 @@ ...@@ -2,6 +2,17 @@
import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue';
import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; import BlobContentEdit from '~/blob/components/blob_edit_content.vue';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { getBaseURL, joinPaths } from '~/lib/utils/url_utility';
import axios from '~/lib/utils/axios_utils';
import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants';
import Flash from '~/flash';
import { sprintf } from '~/locale';
function localId() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
export default { export default {
components: { components: {
...@@ -11,20 +22,70 @@ export default { ...@@ -11,20 +22,70 @@ export default {
}, },
inheritAttrs: false, inheritAttrs: false,
props: { props: {
value: { blob: {
type: String, type: Object,
required: false, required: false,
default: '', default: null,
validator: ({ rawPath }) => Boolean(rawPath),
}, },
fileName: {
type: String,
required: false,
default: '',
}, },
isLoading: { data() {
type: Boolean, return {
required: false, id: localId(),
default: true, filePath: this.blob?.path || '',
previousPath: '',
originalPath: this.blob?.path || '',
content: this.blob?.content || '',
originalContent: '',
isContentLoading: this.blob,
};
},
watch: {
filePath(filePath, previousPath) {
this.previousPath = previousPath;
this.notifyAboutUpdates({ previousPath });
},
content() {
this.notifyAboutUpdates();
},
},
mounted() {
if (this.blob) {
this.fetchBlobContent();
}
},
methods: {
notifyAboutUpdates(args = {}) {
const { filePath, previousPath } = args;
this.$emit('blob-updated', {
filePath: filePath || this.filePath,
previousPath: previousPath || this.previousPath,
content: this.content,
_constants: {
originalPath: this.originalPath,
originalContent: this.originalContent,
id: this.id,
},
});
},
fetchBlobContent() {
const baseUrl = getBaseURL();
const url = joinPaths(baseUrl, this.blob.rawPath);
axios
.get(url)
.then(res => {
this.originalContent = res.data;
this.content = res.data;
})
.catch(e => this.flashAPIFailure(e))
.finally(() => {
this.isContentLoading = false;
});
},
flashAPIFailure(err) {
Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }));
this.isContentLoading = false;
}, },
}, },
}; };
...@@ -33,23 +94,14 @@ export default { ...@@ -33,23 +94,14 @@ export default {
<div class="form-group file-editor"> <div class="form-group file-editor">
<label>{{ s__('Snippets|File') }}</label> <label>{{ s__('Snippets|File') }}</label>
<div class="file-holder snippet"> <div class="file-holder snippet">
<blob-header-edit <blob-header-edit v-model="filePath" data-qa-selector="file_name_field" />
:value="fileName"
data-qa-selector="file_name_field"
@input="$emit('name-change', $event)"
/>
<gl-loading-icon <gl-loading-icon
v-if="isLoading" v-if="isContentLoading"
:label="__('Loading snippet')" :label="__('Loading snippet')"
size="lg" size="lg"
class="loading-animation prepend-top-20 append-bottom-20" class="loading-animation prepend-top-20 append-bottom-20"
/> />
<blob-content-edit <blob-content-edit v-else v-model="content" :file-name="filePath" />
v-else
:value="value"
:file-name="fileName"
@input="$emit('input', $event)"
/>
</div> </div>
</div> </div>
</template> </template>
...@@ -25,3 +25,8 @@ export const SNIPPET_VISIBILITY = { ...@@ -25,3 +25,8 @@ export const SNIPPET_VISIBILITY = {
export const SNIPPET_CREATE_MUTATION_ERROR = __("Can't create snippet: %{err}"); export const SNIPPET_CREATE_MUTATION_ERROR = __("Can't create snippet: %{err}");
export const SNIPPET_UPDATE_MUTATION_ERROR = __("Can't update snippet: %{err}"); export const SNIPPET_UPDATE_MUTATION_ERROR = __("Can't update snippet: %{err}");
export const SNIPPET_BLOB_CONTENT_FETCH_ERROR = __("Can't fetch content for the blob: %{err}");
export const SNIPPET_BLOB_ACTION_CREATE = 'create';
export const SNIPPET_BLOB_ACTION_UPDATE = 'update';
export const SNIPPET_BLOB_ACTION_MOVE = 'move';
...@@ -26,21 +26,6 @@ fragment SnippetBase on Snippet { ...@@ -26,21 +26,6 @@ fragment SnippetBase on Snippet {
...BlobViewer ...BlobViewer
} }
} }
blob {
binary
name
path
rawPath
size
externalStorage
renderedAsText
simpleViewer {
...BlobViewer
}
richViewer {
...BlobViewer
}
}
userPermissions { userPermissions {
adminSnippet adminSnippet
updateSnippet updateSnippet
......
import GetSnippetQuery from '../queries/snippet.query.graphql'; import GetSnippetQuery from '../queries/snippet.query.graphql';
const blobsDefault = [];
export const getSnippetMixin = { export const getSnippetMixin = {
apollo: { apollo: {
snippet: { snippet: {
...@@ -11,7 +13,7 @@ export const getSnippetMixin = { ...@@ -11,7 +13,7 @@ export const getSnippetMixin = {
}, },
update: data => data.snippets.edges[0]?.node, update: data => data.snippets.edges[0]?.node,
result(res) { result(res) {
this.blobs = res.data.snippets.edges[0].node.blobs; this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault;
if (this.onSnippetFetch) { if (this.onSnippetFetch) {
this.onSnippetFetch(res); this.onSnippetFetch(res);
} }
...@@ -28,7 +30,7 @@ export const getSnippetMixin = { ...@@ -28,7 +30,7 @@ export const getSnippetMixin = {
return { return {
snippet: {}, snippet: {},
newSnippet: false, newSnippet: false,
blobs: [], blobs: blobsDefault,
}; };
}, },
computed: { computed: {
......
---
title: Support multiple files when editing snippets
merge_request: 37079
author:
type: changed
...@@ -4140,6 +4140,9 @@ msgstr "" ...@@ -4140,6 +4140,9 @@ msgstr ""
msgid "Can't edit as source branch was deleted" msgid "Can't edit as source branch was deleted"
msgstr "" msgstr ""
msgid "Can't fetch content for the blob: %{err}"
msgstr ""
msgid "Can't find HEAD commit for this branch" msgid "Can't find HEAD commit for this branch"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import Flash from '~/flash'; import Flash from '~/flash';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { joinPaths, redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue'; import SnippetEditApp from '~/snippets/components/edit.vue';
import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue'; import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
...@@ -16,25 +15,17 @@ import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '~/ ...@@ -16,25 +15,17 @@ import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '~/
import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql'; import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql'; import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql';
import AxiosMockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { ApolloMutation } from 'vue-apollo'; import { ApolloMutation } from 'vue-apollo';
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
getBaseURL: jest.fn().mockReturnValue('foo/'),
redirectTo: jest.fn().mockName('redirectTo'), redirectTo: jest.fn().mockName('redirectTo'),
joinPaths: jest
.fn()
.mockName('joinPaths')
.mockReturnValue('contentApiURL'),
})); }));
jest.mock('~/flash'); jest.mock('~/flash');
let flashSpy; let flashSpy;
const contentMock = 'Foo Bar';
const rawPathMock = '/foo/bar';
const rawProjectPathMock = '/project/path'; const rawProjectPathMock = '/project/path';
const newlyEditedSnippetUrl = 'http://foo.bar'; const newlyEditedSnippetUrl = 'http://foo.bar';
const apiError = { message: 'Ufff' }; const apiError = { message: 'Ufff' };
...@@ -43,15 +34,27 @@ const mutationError = 'Bummer'; ...@@ -43,15 +34,27 @@ const mutationError = 'Bummer';
const attachedFilePath1 = 'foo/bar'; const attachedFilePath1 = 'foo/bar';
const attachedFilePath2 = 'alpha/beta'; const attachedFilePath2 = 'alpha/beta';
const actionWithContent = {
content: 'Foo Bar',
};
const actionWithoutContent = {
content: '',
};
const defaultProps = { const defaultProps = {
snippetGid: 'gid://gitlab/PersonalSnippet/42', snippetGid: 'gid://gitlab/PersonalSnippet/42',
markdownPreviewPath: 'http://preview.foo.bar', markdownPreviewPath: 'http://preview.foo.bar',
markdownDocsPath: 'http://docs.foo.bar', markdownDocsPath: 'http://docs.foo.bar',
}; };
const defaultData = {
blobsActions: {
...actionWithContent,
action: '',
},
};
describe('Snippet Edit app', () => { describe('Snippet Edit app', () => {
let wrapper; let wrapper;
let axiosMock;
const resolveMutate = jest.fn().mockResolvedValue({ const resolveMutate = jest.fn().mockResolvedValue({
data: { data: {
...@@ -156,18 +159,21 @@ describe('Snippet Edit app', () => { ...@@ -156,18 +159,21 @@ describe('Snippet Edit app', () => {
}); });
it.each` it.each`
title | content | expectation title | blobsActions | expectation
${''} | ${''} | ${true} ${''} | ${{}} | ${true}
${'foo'} | ${''} | ${true} ${''} | ${{ actionWithContent }} | ${true}
${''} | ${'foo'} | ${true} ${''} | ${{ actionWithoutContent }} | ${true}
${'foo'} | ${'bar'} | ${false} ${'foo'} | ${{}} | ${true}
${'foo'} | ${{ actionWithoutContent }} | ${true}
${'foo'} | ${{ actionWithoutContent, actionWithContent }} | ${true}
${'foo'} | ${{ actionWithContent }} | ${false}
`( `(
'disables submit button unless both title and content are present', 'disables submit button unless both title and content for all blobs are present',
({ title, content, expectation }) => { ({ title, blobsActions, expectation }) => {
createComponent({ createComponent({
data: { data: {
snippet: { title }, snippet: { title },
content, blobsActions,
}, },
}); });
const isBtnDisabled = Boolean(findSubmitButton().attributes('disabled')); const isBtnDisabled = Boolean(findSubmitButton().attributes('disabled'));
...@@ -192,83 +198,31 @@ describe('Snippet Edit app', () => { ...@@ -192,83 +198,31 @@ describe('Snippet Edit app', () => {
}); });
describe('functionality', () => { describe('functionality', () => {
describe('handling of the data from GraphQL response', () => { describe('form submission handling', () => {
const snippet = { it('does not submit unchanged blobs', () => {
blob: { const foo = {
rawPath: rawPathMock, action: '',
},
};
const getResSchema = newSnippet => {
return {
data: {
snippets: {
edges: newSnippet ? [] : [snippet],
},
},
}; };
const bar = {
action: 'update',
}; };
const bootstrapForExistingSnippet = resp => {
createComponent({ createComponent({
data: { data: {
snippet, blobsActions: {
foo,
bar,
},
}, },
}); });
clickSubmitBtn();
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(() => { return waitForPromises().then(() => {
// we keep using waitForPromises to make sure we do not run failed test expect(resolveMutate).toHaveBeenCalledWith(
expect(wrapper.vm.newSnippet).toBe(true); expect.objectContaining({ variables: { input: { files: [bar] } } }),
expect(wrapper.vm.content).toBe(''); );
expect(joinPaths).not.toHaveBeenCalled();
expect(wrapper.vm.snippet).toEqual(wrapper.vm.$options.newSnippetSchema);
});
}); });
}); });
describe('form submission handling', () => {
it.each` it.each`
newSnippet | projectPath | mutation | mutationName newSnippet | projectPath | mutation | mutationName
${true} | ${rawProjectPathMock} | ${CreateSnippetMutation} | ${'CreateSnippetMutation with projectPath'} ${true} | ${rawProjectPathMock} | ${CreateSnippetMutation} | ${'CreateSnippetMutation with projectPath'}
...@@ -279,6 +233,7 @@ describe('Snippet Edit app', () => { ...@@ -279,6 +233,7 @@ describe('Snippet Edit app', () => {
createComponent({ createComponent({
data: { data: {
newSnippet, newSnippet,
...defaultData,
}, },
props: { props: {
...defaultProps, ...defaultProps,
...@@ -307,16 +262,6 @@ describe('Snippet Edit app', () => { ...@@ -307,16 +262,6 @@ describe('Snippet Edit app', () => {
}); });
}); });
it('makes sure there are no unsaved changes in the snippet', () => {
createComponent();
clickSubmitBtn();
return waitForPromises().then(() => {
expect(wrapper.vm.originalContent).toBe(wrapper.vm.content);
expect(wrapper.vm.hasChanges()).toBe(false);
});
});
it.each` it.each`
newSnippet | projectPath | mutationName newSnippet | projectPath | mutationName
${true} | ${rawProjectPathMock} | ${'CreateSnippetMutation with projectPath'} ${true} | ${rawProjectPathMock} | ${'CreateSnippetMutation with projectPath'}
...@@ -434,21 +379,45 @@ describe('Snippet Edit app', () => { ...@@ -434,21 +379,45 @@ describe('Snippet Edit app', () => {
let event; let event;
let returnValueSetter; let returnValueSetter;
beforeEach(() => { const bootstrap = data => {
createComponent(); createComponent({
data,
});
event = new Event('beforeunload'); event = new Event('beforeunload');
returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); returnValueSetter = jest.spyOn(event, 'returnValue', 'set');
};
it('does not prevent page navigation if there are no blobs', () => {
bootstrap();
window.dispatchEvent(event);
expect(returnValueSetter).not.toHaveBeenCalled();
}); });
it('does not prevent page navigation if there are no changes to the snippet content', () => { it('does not prevent page navigation if there are no changes to the blobs content', () => {
bootstrap({
blobsActions: {
foo: {
...actionWithContent,
action: '',
},
},
});
window.dispatchEvent(event); window.dispatchEvent(event);
expect(returnValueSetter).not.toHaveBeenCalled(); expect(returnValueSetter).not.toHaveBeenCalled();
}); });
it('prevents page navigation if there are some changes in the snippet content', () => { it('prevents page navigation if there are some changes in the snippet content', () => {
wrapper.setData({ content: 'new content' }); bootstrap({
blobsActions: {
foo: {
...actionWithContent,
action: 'update',
},
},
});
window.dispatchEvent(event); window.dispatchEvent(event);
......
...@@ -4,78 +4,161 @@ import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; ...@@ -4,78 +4,161 @@ import BlobContentEdit from '~/blob/components/blob_edit_content.vue';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import AxiosMockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/blob/utils', () => jest.fn()); jest.mock('~/blob/utils', () => jest.fn());
jest.mock('~/lib/utils/url_utility', () => ({
getBaseURL: jest.fn().mockReturnValue('foo/'),
joinPaths: jest
.fn()
.mockName('joinPaths')
.mockReturnValue('contentApiURL'),
}));
jest.mock('~/flash');
let flashSpy;
describe('Snippet Blob Edit component', () => { describe('Snippet Blob Edit component', () => {
let wrapper; let wrapper;
const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; let axiosMock;
const fileName = 'lorem.txt'; const contentMock = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const findHeader = () => wrapper.find(BlobHeaderEdit); const pathMock = 'lorem.txt';
const findContent = () => wrapper.find(BlobContentEdit); const rawPathMock = 'foo/bar';
const blob = {
path: pathMock,
content: contentMock,
rawPath: rawPathMock,
};
const findComponent = component => wrapper.find(component);
function createComponent(props = {}) { function createComponent(props = {}, data = { isContentLoading: false }) {
wrapper = shallowMount(SnippetBlobEdit, { wrapper = shallowMount(SnippetBlobEdit, {
propsData: { propsData: {
value,
fileName,
isLoading: false,
...props, ...props,
}, },
data() {
return {
...data,
};
},
}); });
flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure');
} }
beforeEach(() => { beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
createComponent(); createComponent();
}); });
afterEach(() => { afterEach(() => {
axiosMock.restore();
wrapper.destroy(); wrapper.destroy();
}); });
describe('rendering', () => { describe('rendering', () => {
it('matches the snapshot', () => { it('matches the snapshot', () => {
createComponent({ blob });
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
it('renders required components', () => { it('renders required components', () => {
expect(findHeader().exists()).toBe(true); expect(findComponent(BlobHeaderEdit).exists()).toBe(true);
expect(findContent().exists()).toBe(true); expect(findComponent(BlobContentEdit).exists()).toBe(true);
}); });
it('renders loader if isLoading equals true', () => { it('renders loader if existing blob is supplied but no content is fetched yet', () => {
createComponent({ isLoading: true }); createComponent({ blob }, { isContentLoading: true });
expect(wrapper.contains(GlLoadingIcon)).toBe(true); expect(wrapper.contains(GlLoadingIcon)).toBe(true);
expect(findContent().exists()).toBe(false); expect(findComponent(BlobContentEdit).exists()).toBe(false);
});
it('does not render loader if when blob is not supplied', () => {
createComponent();
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
expect(findComponent(BlobContentEdit).exists()).toBe(true);
}); });
}); });
describe('functionality', () => { describe('functionality', () => {
it('does not fail without content', () => { it('does not fail without blob', () => {
const spy = jest.spyOn(global.console, 'error'); const spy = jest.spyOn(global.console, 'error');
createComponent({ value: undefined }); createComponent({ blob: undefined });
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
expect(findContent().exists()).toBe(true); expect(findComponent(BlobContentEdit).exists()).toBe(true);
}); });
it('emits "name-change" event when the file name gets changed', () => { it.each`
expect(wrapper.emitted('name-change')).toBeUndefined(); emitter | prop
const newFilename = 'foo.bar'; ${BlobHeaderEdit} | ${'filePath'}
findHeader().vm.$emit('input', newFilename); ${BlobContentEdit} | ${'content'}
`('emits "blob-updated" event when the $prop gets changed', ({ emitter, prop }) => {
expect(wrapper.emitted('blob-updated')).toBeUndefined();
const newValue = 'foo.bar';
findComponent(emitter).vm.$emit('input', newValue);
return nextTick().then(() => { return nextTick().then(() => {
expect(wrapper.emitted('name-change')[0]).toEqual([newFilename]); expect(wrapper.emitted('blob-updated')[0]).toEqual([
expect.objectContaining({
[prop]: newValue,
}),
]);
}); });
}); });
it('emits "input" event when the file content gets changed', () => { describe('fetching blob content', () => {
expect(wrapper.emitted('input')).toBeUndefined(); const bootstrapForExistingSnippet = resp => {
const newValue = 'foo.bar'; createComponent({
findContent().vm.$emit('input', newValue); blob: {
...blob,
content: '',
},
});
return nextTick().then(() => { if (resp === 500) {
expect(wrapper.emitted('input')[0]).toEqual([newValue]); axiosMock.onGet('contentApiURL').reply(500);
} else {
axiosMock.onGet('contentApiURL').reply(200, contentMock);
}
};
const bootstrapForNewSnippet = () => {
createComponent();
};
it('fetches blob content with the additional query', () => {
bootstrapForExistingSnippet();
return waitForPromises().then(() => {
expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock);
expect(findComponent(BlobHeaderEdit).props('value')).toBe(pathMock);
expect(findComponent(BlobContentEdit).props('value')).toBe(contentMock);
});
});
it('flashes the error message if fetching content fails', () => {
bootstrapForExistingSnippet(500);
return waitForPromises().then(() => {
expect(flashSpy).toHaveBeenCalled();
expect(findComponent(BlobContentEdit).props('value')).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(findComponent(BlobHeaderEdit).props('value')).toBe('');
expect(findComponent(BlobContentEdit).props('value')).toBe('');
expect(joinPaths).not.toHaveBeenCalled();
});
}); });
}); });
}); });
......
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