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';
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 { redirectTo } from '~/lib/utils/url_utility';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
......@@ -15,6 +14,9 @@ import {
SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_CREATE_MUTATION_ERROR,
SNIPPET_UPDATE_MUTATION_ERROR,
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
} from '../constants';
import SnippetBlobEdit from './snippet_blob_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
......@@ -53,18 +55,25 @@ export default {
},
data() {
return {
blob: {},
fileName: '',
content: '',
originalContent: '',
isContentLoading: true,
blobsActions: {},
isUpdating: false,
newSnippet: false,
};
},
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() {
return this.snippet.title === '' || this.content === '' || this.isUpdating;
return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating;
},
isProjectSnippet() {
return Boolean(this.projectPath);
......@@ -75,8 +84,7 @@ export default {
title: this.snippet.title,
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
fileName: this.fileName,
content: this.content,
files: this.getActionsEntries.filter(entry => entry.action !== ''),
};
},
saveButtonLabel() {
......@@ -108,16 +116,47 @@ export default {
onBeforeUnload(e = {}) {
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 });
return returnValue;
},
hasChanges() {
return this.content !== this.originalContent;
},
updateFileName(newName) {
this.fileName = newName;
updateBlobActions(args = {}) {
// `_constants` is the internal prop that
// should not be sent to the mutation. Hence we filter it out from
// the argsToUpdateAction that is the data-basis for the mutation.
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) {
const defaultErrorMsg = this.newSnippet
......@@ -129,26 +168,9 @@ export default {
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.originalContent = res.data;
this.content = res.data;
this.isContentLoading = false;
})
.catch(e => this.flashAPIFailure(e));
},
onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.edges.length === 0) {
......@@ -205,7 +227,6 @@ export default {
title: '',
description: '',
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
blob: {},
},
};
</script>
......@@ -236,12 +257,16 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
/>
<template v-if="blobs.length">
<snippet-blob-edit
v-model="content"
:file-name="fileName"
:is-loading="isContentLoading"
@name-change="updateFileName"
v-for="blob in blobs"
:key="blob.name"
:blob="blob"
@blob-updated="updateBlobActions"
/>
</template>
<snippet-blob-edit v-else @blob-updated="updateBlobActions" />
<snippet-visibility-edit
v-model="snippet.visibilityLevel"
:help-link="visibilityHelpLink"
......
......@@ -2,6 +2,17 @@
import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue';
import BlobContentEdit from '~/blob/components/blob_edit_content.vue';
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 {
components: {
......@@ -11,20 +22,70 @@ export default {
},
inheritAttrs: false,
props: {
value: {
type: String,
blob: {
type: Object,
required: false,
default: '',
default: null,
validator: ({ rawPath }) => Boolean(rawPath),
},
fileName: {
type: String,
required: false,
default: '',
},
isLoading: {
type: Boolean,
required: false,
default: true,
data() {
return {
id: localId(),
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 {
<div class="form-group file-editor">
<label>{{ s__('Snippets|File') }}</label>
<div class="file-holder snippet">
<blob-header-edit
:value="fileName"
data-qa-selector="file_name_field"
@input="$emit('name-change', $event)"
/>
<blob-header-edit v-model="filePath" data-qa-selector="file_name_field" />
<gl-loading-icon
v-if="isLoading"
v-if="isContentLoading"
:label="__('Loading snippet')"
size="lg"
class="loading-animation prepend-top-20 append-bottom-20"
/>
<blob-content-edit
v-else
:value="value"
:file-name="fileName"
@input="$emit('input', $event)"
/>
<blob-content-edit v-else v-model="content" :file-name="filePath" />
</div>
</div>
</template>
......@@ -25,3 +25,8 @@ export const SNIPPET_VISIBILITY = {
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_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 {
...BlobViewer
}
}
blob {
binary
name
path
rawPath
size
externalStorage
renderedAsText
simpleViewer {
...BlobViewer
}
richViewer {
...BlobViewer
}
}
userPermissions {
adminSnippet
updateSnippet
......
import GetSnippetQuery from '../queries/snippet.query.graphql';
const blobsDefault = [];
export const getSnippetMixin = {
apollo: {
snippet: {
......@@ -11,7 +13,7 @@ export const getSnippetMixin = {
},
update: data => data.snippets.edges[0]?.node,
result(res) {
this.blobs = res.data.snippets.edges[0].node.blobs;
this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}
......@@ -28,7 +30,7 @@ export const getSnippetMixin = {
return {
snippet: {},
newSnippet: false,
blobs: [],
blobs: blobsDefault,
};
},
computed: {
......
---
title: Support multiple files when editing snippets
merge_request: 37079
author:
type: changed
......@@ -4140,6 +4140,9 @@ msgstr ""
msgid "Can't edit as source branch was deleted"
msgstr ""
msgid "Can't fetch content for the blob: %{err}"
msgstr ""
msgid "Can't find HEAD commit for this branch"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import Flash from '~/flash';
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 SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
......@@ -16,25 +15,17 @@ import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '~/
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'),
}));
jest.mock('~/flash');
let flashSpy;
const contentMock = 'Foo Bar';
const rawPathMock = '/foo/bar';
const rawProjectPathMock = '/project/path';
const newlyEditedSnippetUrl = 'http://foo.bar';
const apiError = { message: 'Ufff' };
......@@ -43,15 +34,27 @@ const mutationError = 'Bummer';
const attachedFilePath1 = 'foo/bar';
const attachedFilePath2 = 'alpha/beta';
const actionWithContent = {
content: 'Foo Bar',
};
const actionWithoutContent = {
content: '',
};
const defaultProps = {
snippetGid: 'gid://gitlab/PersonalSnippet/42',
markdownPreviewPath: 'http://preview.foo.bar',
markdownDocsPath: 'http://docs.foo.bar',
};
const defaultData = {
blobsActions: {
...actionWithContent,
action: '',
},
};
describe('Snippet Edit app', () => {
let wrapper;
let axiosMock;
const resolveMutate = jest.fn().mockResolvedValue({
data: {
......@@ -156,18 +159,21 @@ describe('Snippet Edit app', () => {
});
it.each`
title | content | expectation
${''} | ${''} | ${true}
${'foo'} | ${''} | ${true}
${''} | ${'foo'} | ${true}
${'foo'} | ${'bar'} | ${false}
title | blobsActions | expectation
${''} | ${{}} | ${true}
${''} | ${{ actionWithContent }} | ${true}
${''} | ${{ actionWithoutContent }} | ${true}
${'foo'} | ${{}} | ${true}
${'foo'} | ${{ actionWithoutContent }} | ${true}
${'foo'} | ${{ actionWithoutContent, actionWithContent }} | ${true}
${'foo'} | ${{ actionWithContent }} | ${false}
`(
'disables submit button unless both title and content are present',
({ title, content, expectation }) => {
'disables submit button unless both title and content for all blobs are present',
({ title, blobsActions, expectation }) => {
createComponent({
data: {
snippet: { title },
content,
blobsActions,
},
});
const isBtnDisabled = Boolean(findSubmitButton().attributes('disabled'));
......@@ -192,83 +198,31 @@ describe('Snippet Edit app', () => {
});
describe('functionality', () => {
describe('handling of the data from GraphQL response', () => {
const snippet = {
blob: {
rawPath: rawPathMock,
},
};
const getResSchema = newSnippet => {
return {
data: {
snippets: {
edges: newSnippet ? [] : [snippet],
},
},
describe('form submission handling', () => {
it('does not submit unchanged blobs', () => {
const foo = {
action: '',
};
const bar = {
action: 'update',
};
const bootstrapForExistingSnippet = resp => {
createComponent({
data: {
snippet,
blobsActions: {
foo,
bar,
},
},
});
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();
clickSubmitBtn();
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);
});
expect(resolveMutate).toHaveBeenCalledWith(
expect.objectContaining({ variables: { input: { files: [bar] } } }),
);
});
});
describe('form submission handling', () => {
it.each`
newSnippet | projectPath | mutation | mutationName
${true} | ${rawProjectPathMock} | ${CreateSnippetMutation} | ${'CreateSnippetMutation with projectPath'}
......@@ -279,6 +233,7 @@ describe('Snippet Edit app', () => {
createComponent({
data: {
newSnippet,
...defaultData,
},
props: {
...defaultProps,
......@@ -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`
newSnippet | projectPath | mutationName
${true} | ${rawProjectPathMock} | ${'CreateSnippetMutation with projectPath'}
......@@ -434,21 +379,45 @@ describe('Snippet Edit app', () => {
let event;
let returnValueSetter;
beforeEach(() => {
createComponent();
const bootstrap = data => {
createComponent({
data,
});
event = new Event('beforeunload');
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);
expect(returnValueSetter).not.toHaveBeenCalled();
});
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);
......
......@@ -4,78 +4,161 @@ import BlobContentEdit from '~/blob/components/blob_edit_content.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
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('~/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', () => {
let wrapper;
const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const fileName = 'lorem.txt';
const findHeader = () => wrapper.find(BlobHeaderEdit);
const findContent = () => wrapper.find(BlobContentEdit);
let axiosMock;
const contentMock = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const pathMock = 'lorem.txt';
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, {
propsData: {
value,
fileName,
isLoading: false,
...props,
},
data() {
return {
...data,
};
},
});
flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure');
}
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
createComponent();
});
afterEach(() => {
axiosMock.restore();
wrapper.destroy();
});
describe('rendering', () => {
it('matches the snapshot', () => {
createComponent({ blob });
expect(wrapper.element).toMatchSnapshot();
});
it('renders required components', () => {
expect(findHeader().exists()).toBe(true);
expect(findContent().exists()).toBe(true);
expect(findComponent(BlobHeaderEdit).exists()).toBe(true);
expect(findComponent(BlobContentEdit).exists()).toBe(true);
});
it('renders loader if isLoading equals true', () => {
createComponent({ isLoading: true });
it('renders loader if existing blob is supplied but no content is fetched yet', () => {
createComponent({ blob }, { isContentLoading: 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', () => {
it('does not fail without content', () => {
it('does not fail without blob', () => {
const spy = jest.spyOn(global.console, 'error');
createComponent({ value: undefined });
createComponent({ blob: undefined });
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', () => {
expect(wrapper.emitted('name-change')).toBeUndefined();
const newFilename = 'foo.bar';
findHeader().vm.$emit('input', newFilename);
it.each`
emitter | prop
${BlobHeaderEdit} | ${'filePath'}
${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(() => {
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', () => {
expect(wrapper.emitted('input')).toBeUndefined();
const newValue = 'foo.bar';
findContent().vm.$emit('input', newValue);
describe('fetching blob content', () => {
const bootstrapForExistingSnippet = resp => {
createComponent({
blob: {
...blob,
content: '',
},
});
return nextTick().then(() => {
expect(wrapper.emitted('input')[0]).toEqual([newValue]);
if (resp === 500) {
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