Commit 2a402a38 authored by Denys Mishunov's avatar Denys Mishunov

Merge branch '217801-fe-multi-file-snippets' into 'master'

Step FINAL - Add/remove snippet files in the edit view

See merge request gitlab-org/gitlab!38855
parents 61c1182b 332815d6
...@@ -15,7 +15,7 @@ export default { ...@@ -15,7 +15,7 @@ export default {
canDelete: { canDelete: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: true,
}, },
showDelete: { showDelete: {
type: Boolean, type: Boolean,
......
...@@ -14,9 +14,6 @@ import { ...@@ -14,9 +14,6 @@ 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 SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue'; import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
...@@ -56,25 +53,20 @@ export default { ...@@ -56,25 +53,20 @@ export default {
}, },
data() { data() {
return { return {
blobsActions: {},
isUpdating: false, isUpdating: false,
newSnippet: false, newSnippet: false,
actions: [],
}; };
}, },
computed: { computed: {
getActionsEntries() { hasBlobChanges() {
return Object.values(this.blobsActions); return this.actions.length > 0;
}, },
allBlobsHaveContent() { hasValidBlobs() {
const entries = this.getActionsEntries; return this.actions.every(x => x.filePath && x.content);
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.allBlobsHaveContent || this.isUpdating; return this.snippet.title === '' || !this.hasValidBlobs || this.isUpdating;
}, },
isProjectSnippet() { isProjectSnippet() {
return Boolean(this.projectPath); return Boolean(this.projectPath);
...@@ -85,7 +77,7 @@ export default { ...@@ -85,7 +77,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,
blobActions: this.getActionsEntries.filter(entry => entry.action !== ''), blobActions: this.actions,
}; };
}, },
saveButtonLabel() { saveButtonLabel() {
...@@ -120,48 +112,11 @@ export default { ...@@ -120,48 +112,11 @@ 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.allBlobChangesRegistered || this.isUpdating) return undefined; if (!this.hasBlobChanges || this.isUpdating) return undefined;
Object.assign(e, { returnValue }); Object.assign(e, { returnValue });
return returnValue; return returnValue;
}, },
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) { flashAPIFailure(err) {
const defaultErrorMsg = this.newSnippet const defaultErrorMsg = this.newSnippet
? SNIPPET_CREATE_MUTATION_ERROR ? SNIPPET_CREATE_MUTATION_ERROR
...@@ -218,7 +173,6 @@ export default { ...@@ -218,7 +173,6 @@ export default {
if (errors.length) { if (errors.length) {
this.flashAPIFailure(errors[0]); this.flashAPIFailure(errors[0]);
} else { } else {
this.originalContent = this.content;
redirectTo(baseObj.snippet.webUrl); redirectTo(baseObj.snippet.webUrl);
} }
}) })
...@@ -226,6 +180,9 @@ export default { ...@@ -226,6 +180,9 @@ export default {
this.flashAPIFailure(e); this.flashAPIFailure(e);
}); });
}, },
updateActions(actions) {
this.actions = actions;
},
}, },
newSnippetSchema: { newSnippetSchema: {
title: '', title: '',
...@@ -261,7 +218,7 @@ export default { ...@@ -261,7 +218,7 @@ export default {
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath" :markdown-docs-path="markdownDocsPath"
/> />
<snippet-blob-actions-edit :blobs="blobs" @blob-updated="updateBlobActions" /> <snippet-blob-actions-edit :init-blobs="blobs" @actions="updateActions" />
<snippet-visibility-edit <snippet-visibility-edit
v-model="snippet.visibilityLevel" v-model="snippet.visibilityLevel"
......
<script> <script>
import { GlButton } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { s__, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SnippetBlobEdit from './snippet_blob_edit.vue'; import SnippetBlobEdit from './snippet_blob_edit.vue';
import { SNIPPET_MAX_BLOBS } from '../constants';
import { createBlob, decorateBlob, diffAll } from '../utils/blob';
export default { export default {
components: { components: {
SnippetBlobEdit, SnippetBlobEdit,
GlButton,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
blobs: { initBlobs: {
type: Array, type: Array,
required: true, required: true,
}, },
}, },
data() {
return {
// This is a dictionary (by .id) of the original blobs and
// is used as the baseline for calculating diffs
// (e.g., what has been deleted, changed, renamed, etc.)
blobsOrig: {},
// This is a dictionary (by .id) of the current blobs and
// is updated as the user makes changes.
blobs: {},
// This is a list of blob ID's in order how they should be
// presented.
blobIds: [],
};
},
computed: {
actions() {
return diffAll(this.blobs, this.blobsOrig);
},
count() {
return this.blobIds.length;
},
addLabel() {
return sprintf(s__('Snippets|Add another file %{num}/%{total}'), {
num: this.count,
total: SNIPPET_MAX_BLOBS,
});
},
canDelete() {
return this.count > 1;
},
canAdd() {
return this.count < SNIPPET_MAX_BLOBS;
},
hasMultiFilesEnabled() {
return this.glFeatures.snippetMultipleFiles;
},
filesLabel() {
return this.hasMultiFilesEnabled ? s__('Snippets|Files') : s__('Snippets|File');
},
firstInputId() {
const blobId = this.blobIds[0];
if (!blobId) {
return '';
}
return `${blobId}_file_path`;
},
},
watch: {
actions: {
immediate: true,
handler(val) {
this.$emit('actions', val);
},
},
},
created() {
const blobs = this.initBlobs.map(decorateBlob);
const blobsById = blobs.reduce((acc, x) => Object.assign(acc, { [x.id]: x }), {});
this.blobsOrig = blobsById;
this.blobs = cloneDeep(blobsById);
this.blobIds = blobs.map(x => x.id);
// Show 1 empty blob if none exist
if (!this.blobIds.length) {
this.addBlob();
}
},
methods: {
updateBlobContent(id, content) {
const origBlob = this.blobsOrig[id];
const blob = this.blobs[id];
blob.content = content;
// If we've received content, but we haven't loaded the content before
// then this is also the original content.
if (origBlob && !origBlob.isLoaded) {
blob.isLoaded = true;
origBlob.isLoaded = true;
origBlob.content = content;
}
},
updateBlobFilePath(id, path) {
const blob = this.blobs[id];
blob.path = path;
},
addBlob() {
const blob = createBlob();
this.$set(this.blobs, blob.id, blob);
this.blobIds.push(blob.id);
},
deleteBlob(id) {
this.blobIds = this.blobIds.filter(x => x !== id);
this.$delete(this.blobs, id);
},
updateBlob(id, args) {
if ('content' in args) {
this.updateBlobContent(id, args.content);
}
if ('path' in args) {
this.updateBlobFilePath(id, args.path);
}
},
},
}; };
</script> </script>
<template> <template>
<div class="form-group file-editor"> <div class="form-group file-editor">
<label for="snippet_file_path">{{ s__('Snippets|File') }}</label> <label :for="firstInputId">{{ filesLabel }}</label>
<template v-if="blobs.length"> <snippet-blob-edit
<snippet-blob-edit v-for="blob in blobs" :key="blob.name" :blob="blob" v-on="$listeners" /> v-for="(blobId, index) in blobIds"
</template> :key="blobId"
<snippet-blob-edit v-else v-on="$listeners" /> :class="{ 'gl-mt-3': index > 0 }"
:blob="blobs[blobId]"
:can-delete="canDelete"
:show-delete="hasMultiFilesEnabled"
@blob-updated="updateBlob(blobId, $event)"
@delete="deleteBlob(blobId)"
/>
<gl-button
v-if="hasMultiFilesEnabled"
:disabled="!canAdd"
data-testid="add_button"
class="gl-my-3"
variant="dashed"
@click="addBlob"
>{{ addLabel }}</gl-button
>
</div> </div>
</template> </template>
...@@ -8,12 +8,6 @@ import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants'; ...@@ -8,12 +8,6 @@ import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants';
import Flash from '~/flash'; import Flash from '~/flash';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
function localId() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
export default { export default {
components: { components: {
BlobHeaderEdit, BlobHeaderEdit,
...@@ -24,49 +18,35 @@ export default { ...@@ -24,49 +18,35 @@ export default {
props: { props: {
blob: { blob: {
type: Object, type: Object,
required: true,
},
canDelete: {
type: Boolean,
required: false, required: false,
default: null, default: true,
validator: ({ rawPath }) => Boolean(rawPath),
}, },
}, showDelete: {
data() { type: Boolean,
return { required: false,
id: localId(), default: false,
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(); computed: {
inputId() {
return `${this.blob.id}_file_path`;
}, },
}, },
mounted() { mounted() {
if (this.blob) { if (!this.blob.isLoaded) {
this.fetchBlobContent(); this.fetchBlobContent();
} }
}, },
methods: { methods: {
onDelete() {
this.$emit('delete');
},
notifyAboutUpdates(args = {}) { notifyAboutUpdates(args = {}) {
const { filePath, previousPath } = args; this.$emit('blob-updated', 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() { fetchBlobContent() {
const baseUrl = getBaseURL(); const baseUrl = getBaseURL();
...@@ -75,17 +55,12 @@ export default { ...@@ -75,17 +55,12 @@ export default {
axios axios
.get(url) .get(url)
.then(res => { .then(res => {
this.originalContent = res.data; this.notifyAboutUpdates({ content: res.data });
this.content = res.data;
}) })
.catch(e => this.flashAPIFailure(e)) .catch(e => this.flashAPIFailure(e));
.finally(() => {
this.isContentLoading = false;
});
}, },
flashAPIFailure(err) { flashAPIFailure(err) {
Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err })); Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }));
this.isContentLoading = false;
}, },
}, },
}; };
...@@ -93,16 +68,26 @@ export default { ...@@ -93,16 +68,26 @@ export default {
<template> <template>
<div class="file-holder snippet"> <div class="file-holder snippet">
<blob-header-edit <blob-header-edit
id="snippet_file_path" :id="inputId"
v-model="filePath" :value="blob.path"
data-qa-selector="file_name_field" data-qa-selector="file_name_field"
:can-delete="canDelete"
:show-delete="showDelete"
@input="notifyAboutUpdates({ path: $event })"
@delete="onDelete"
/> />
<gl-loading-icon <gl-loading-icon
v-if="isContentLoading" v-if="!blob.isLoaded"
: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 v-else v-model="content" :file-global-id="id" :file-name="filePath" /> <blob-content-edit
v-else
:value="blob.content"
:file-global-id="blob.id"
:file-name="blob.path"
@input="notifyAboutUpdates({ content: $event })"
/>
</div> </div>
</template> </template>
...@@ -31,3 +31,5 @@ export const SNIPPET_BLOB_ACTION_CREATE = 'create'; ...@@ -31,3 +31,5 @@ export const SNIPPET_BLOB_ACTION_CREATE = 'create';
export const SNIPPET_BLOB_ACTION_UPDATE = 'update'; export const SNIPPET_BLOB_ACTION_UPDATE = 'update';
export const SNIPPET_BLOB_ACTION_MOVE = 'move'; export const SNIPPET_BLOB_ACTION_MOVE = 'move';
export const SNIPPET_BLOB_ACTION_DELETE = 'delete'; export const SNIPPET_BLOB_ACTION_DELETE = 'delete';
export const SNIPPET_MAX_BLOBS = 10;
...@@ -14,6 +14,10 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController ...@@ -14,6 +14,10 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
before_action :authorize_update_snippet!, only: [:edit, :update] before_action :authorize_update_snippet!, only: [:edit, :update]
before_action :authorize_admin_snippet!, only: [:destroy] before_action :authorize_admin_snippet!, only: [:destroy]
before_action do
push_frontend_feature_flag(:snippet_multiple_files, current_user)
end
def index def index
@snippet_counts = ::Snippets::CountService @snippet_counts = ::Snippets::CountService
.new(current_user, project: @project) .new(current_user, project: @project)
......
...@@ -17,6 +17,10 @@ class SnippetsController < Snippets::ApplicationController ...@@ -17,6 +17,10 @@ class SnippetsController < Snippets::ApplicationController
layout 'snippets' layout 'snippets'
before_action do
push_frontend_feature_flag(:snippet_multiple_files, current_user)
end
def index def index
if params[:username].present? if params[:username].present?
@user = UserFinder.new(params[:username]).find_by_username! @user = UserFinder.new(params[:username]).find_by_username!
......
...@@ -22672,6 +22672,9 @@ msgstr "" ...@@ -22672,6 +22672,9 @@ msgstr ""
msgid "SnippetsEmptyState|There are no snippets to show." msgid "SnippetsEmptyState|There are no snippets to show."
msgstr "" msgstr ""
msgid "Snippets|Add another file %{num}/%{total}"
msgstr ""
msgid "Snippets|Delete file" msgid "Snippets|Delete file"
msgstr "" msgstr ""
...@@ -22681,6 +22684,9 @@ msgstr "" ...@@ -22681,6 +22684,9 @@ msgstr ""
msgid "Snippets|File" msgid "Snippets|File"
msgstr "" msgstr ""
msgid "Snippets|Files"
msgstr ""
msgid "Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby" msgid "Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"
msgstr "" msgstr ""
......
...@@ -56,9 +56,9 @@ describe('Blob Header Editing', () => { ...@@ -56,9 +56,9 @@ describe('Blob Header Editing', () => {
}); });
describe.each` describe.each`
props | expectedDisabled props | expectedDisabled
${{ showDelete: true }} | ${true} ${{ showDelete: true }} | ${false}
${{ showDelete: true, canDelete: true }} | ${false} ${{ showDelete: true, canDelete: false }} | ${true}
`('with $props', ({ props, expectedDisabled }) => { `('with $props', ({ props, expectedDisabled }) => {
beforeEach(() => { beforeEach(() => {
createComponent(props); createComponent(props);
......
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Snippet Blob Edit component rendering matches the snapshot 1`] = ` exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = `
<div <div
class="file-holder snippet" class="file-holder snippet"
> >
<blob-header-edit-stub <blob-header-edit-stub
candelete="true"
data-qa-selector="file_name_field" data-qa-selector="file_name_field"
id="snippet_file_path" id="blob_local_7_file_path"
value="lorem.txt" value="foo/bar/test.md"
/> />
<blob-content-edit-stub <blob-content-edit-stub
fileglobalid="0a3d" fileglobalid="blob_local_7"
filename="lorem.txt" filename="foo/bar/test.md"
value="Lorem ipsum dolor sit amet, consectetur adipiscing elit." value="Lorem ipsum dolar sit amet,
consectetur adipiscing elit."
/> />
</div> </div>
`; `;
import { times } from 'lodash';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue';
import {
SNIPPET_MAX_BLOBS,
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_MOVE,
} from '~/snippets/constants';
import { testEntries, createBlobFromTestEntry } from '../test_utils';
const TEST_BLOBS = [ const TEST_BLOBS = [
{ name: 'foo', content: 'abc', rawPath: 'test/raw' }, createBlobFromTestEntry(testEntries.updated),
{ name: 'bar', content: 'def', rawPath: 'test/raw' }, createBlobFromTestEntry(testEntries.deleted),
]; ];
const TEST_EVENT = 'blob-update';
const TEST_BLOBS_UNLOADED = TEST_BLOBS.map(blob => ({ ...blob, content: '', isLoaded: false }));
describe('snippets/components/snippet_blob_actions_edit', () => { describe('snippets/components/snippet_blob_actions_edit', () => {
let onEvent;
let wrapper; let wrapper;
const createComponent = (props = {}) => { const createComponent = (props = {}, snippetMultipleFiles = true) => {
wrapper = shallowMount(SnippetBlobActionsEdit, { wrapper = shallowMount(SnippetBlobActionsEdit, {
propsData: { propsData: {
blobs: [], initBlobs: TEST_BLOBS,
...props, ...props,
}, },
listeners: { provide: {
[TEST_EVENT]: onEvent, glFeatures: {
snippetMultipleFiles,
},
}, },
}); });
}; };
const findBlobEdit = () => wrapper.find(SnippetBlobEdit);
const findBlobEditData = () => wrapper.findAll(SnippetBlobEdit).wrappers.map(x => x.props());
beforeEach(() => { const findLabel = () => wrapper.find('label');
onEvent = jest.fn(); const findBlobEdits = () => wrapper.findAll(SnippetBlobEdit);
}); const findBlobsData = () =>
findBlobEdits().wrappers.map(x => ({
blob: x.props('blob'),
classes: x.classes(),
}));
const findFirstBlobEdit = () => findBlobEdits().at(0);
const findAddButton = () => wrapper.find('[data-testid="add_button"]');
const getLastActions = () => {
const events = wrapper.emitted().actions;
return events[events.length - 1]?.[0];
};
const buildBlobsDataExpectation = blobs =>
blobs.map((blob, index) => ({
blob: {
...blob,
id: expect.stringMatching('blob_local_'),
},
classes: index > 0 ? ['gl-mt-3'] : [],
}));
const triggerBlobDelete = idx =>
findBlobEdits()
.at(idx)
.vm.$emit('delete');
const triggerBlobUpdate = (idx, props) =>
findBlobEdits()
.at(idx)
.vm.$emit('blob-updated', props);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -36,24 +70,232 @@ describe('snippets/components/snippet_blob_actions_edit', () => { ...@@ -36,24 +70,232 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
}); });
describe.each` describe.each`
props | expectedData featureFlag | label | showDelete | showAdd
${{}} | ${[{ blob: null }]} ${true} | ${'Files'} | ${true} | ${true}
${{ blobs: TEST_BLOBS }} | ${TEST_BLOBS.map(blob => ({ blob }))} ${false} | ${'File'} | ${false} | ${false}
`('with $props', ({ props, expectedData }) => { `('with feature flag = $featureFlag', ({ featureFlag, label, showDelete, showAdd }) => {
beforeEach(() => { beforeEach(() => {
createComponent(props); createComponent({}, featureFlag);
});
it('renders label', () => {
expect(findLabel().text()).toBe(label);
});
it(`renders delete button (show=${showDelete})`, () => {
expect(findFirstBlobEdit().props()).toMatchObject({
showDelete,
canDelete: true,
});
});
it(`renders add button (show=${showAdd})`, () => {
expect(findAddButton().exists()).toBe(showAdd);
});
});
describe('with default', () => {
beforeEach(() => {
createComponent();
});
it('emits no actions', () => {
expect(getLastActions()).toEqual([]);
}); });
it('renders blob edit', () => { it('shows blobs', () => {
expect(findBlobEditData()).toEqual(expectedData); expect(findBlobsData()).toEqual(buildBlobsDataExpectation(TEST_BLOBS_UNLOADED));
}); });
it('emits event', () => { it('shows add button', () => {
expect(onEvent).not.toHaveBeenCalled(); const button = findAddButton();
expect(button.text()).toBe(`Add another file ${TEST_BLOBS.length}/${SNIPPET_MAX_BLOBS}`);
expect(button.props('disabled')).toBe(false);
});
describe('when add is clicked', () => {
beforeEach(() => {
findAddButton().vm.$emit('click');
});
it('adds blob with empty content', () => {
expect(findBlobsData()).toEqual(
buildBlobsDataExpectation([
...TEST_BLOBS_UNLOADED,
{
content: '',
isLoaded: true,
path: '',
},
]),
);
});
it('emits action', () => {
expect(getLastActions()).toEqual([
expect.objectContaining({
action: SNIPPET_BLOB_ACTION_CREATE,
}),
]);
});
});
describe('when blob is deleted', () => {
beforeEach(() => {
triggerBlobDelete(1);
});
it('removes blob', () => {
expect(findBlobsData()).toEqual(buildBlobsDataExpectation(TEST_BLOBS_UNLOADED.slice(0, 1)));
});
it('emits action', () => {
expect(getLastActions()).toEqual([
expect.objectContaining({
...testEntries.deleted.diff,
content: '',
}),
]);
});
});
describe('when blob changes path', () => {
beforeEach(() => {
triggerBlobUpdate(0, { path: 'new/path' });
});
it('renames blob', () => {
expect(findBlobsData()[0]).toMatchObject({
blob: {
path: 'new/path',
},
});
});
it('emits action', () => {
expect(getLastActions()).toMatchObject([
{
action: SNIPPET_BLOB_ACTION_MOVE,
filePath: 'new/path',
previousPath: testEntries.updated.diff.filePath,
},
]);
});
});
describe('when blob emits new content', () => {
const { content } = testEntries.updated.diff;
const originalContent = `${content}\noriginal content\n`;
beforeEach(() => {
triggerBlobUpdate(0, { content: originalContent });
});
findBlobEdit().vm.$emit('blob-update', TEST_BLOBS[0]); it('loads new content', () => {
expect(findBlobsData()[0]).toMatchObject({
blob: {
content: originalContent,
isLoaded: true,
},
});
});
it('does not emit an action', () => {
expect(getLastActions()).toEqual([]);
});
it('emits an action when content changes again', async () => {
triggerBlobUpdate(0, { content });
await wrapper.vm.$nextTick();
expect(getLastActions()).toEqual([testEntries.updated.diff]);
});
});
});
describe('with 1 blob', () => {
beforeEach(() => {
createComponent({ initBlobs: [createBlobFromTestEntry(testEntries.created)] });
});
it('disables delete button', () => {
expect(findBlobEdits()).toHaveLength(1);
expect(
findBlobEdits()
.at(0)
.props(),
).toMatchObject({
showDelete: true,
canDelete: false,
});
});
describe(`when added ${SNIPPET_MAX_BLOBS} files`, () => {
let addButton;
beforeEach(() => {
addButton = findAddButton();
times(SNIPPET_MAX_BLOBS - 1, () => addButton.vm.$emit('click'));
});
it('should have blobs', () => {
expect(findBlobsData()).toHaveLength(SNIPPET_MAX_BLOBS);
});
it('should disable add button', () => {
expect(addButton.props('disabled')).toBe(true);
});
});
});
describe('with 0 init blob', () => {
beforeEach(() => {
createComponent({ initBlobs: [] });
});
it('shows 1 blob by default', () => {
expect(findBlobsData()).toEqual([
expect.objectContaining({
blob: {
id: expect.stringMatching('blob_local_'),
content: '',
path: '',
isLoaded: true,
},
}),
]);
});
it('emits create action', () => {
expect(getLastActions()).toEqual([
{
action: SNIPPET_BLOB_ACTION_CREATE,
content: '',
filePath: '',
previousPath: '',
},
]);
});
});
describe(`with ${SNIPPET_MAX_BLOBS} files`, () => {
beforeEach(() => {
const initBlobs = Array(SNIPPET_MAX_BLOBS)
.fill(1)
.map(() => createBlobFromTestEntry(testEntries.created));
createComponent({ initBlobs });
});
it('should have blobs', () => {
expect(findBlobsData()).toHaveLength(SNIPPET_MAX_BLOBS);
});
expect(onEvent).toHaveBeenCalledWith(TEST_BLOBS[0]); it('should disable add button', () => {
expect(findAddButton().props('disabled')).toBe(true);
}); });
}); });
}); });
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 AxiosMockAdapter from 'axios-mock-adapter'; import AxiosMockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue';
...@@ -8,166 +7,162 @@ import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; ...@@ -8,166 +7,162 @@ 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 axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
jest.mock('~/blob/utils', () => jest.fn()); import { TEST_HOST } from 'helpers/test_constants';
jest.mock('~/lib/utils/url_utility', () => ({
getBaseURL: jest.fn().mockReturnValue('foo/'),
joinPaths: jest
.fn()
.mockName('joinPaths')
.mockReturnValue('contentApiURL'),
}));
jest.mock('~/flash'); jest.mock('~/flash');
let flashSpy; const TEST_ID = 'blob_local_7';
const TEST_PATH = 'foo/bar/test.md';
const TEST_RAW_PATH = '/gitlab/raw/path/to/blob/7';
const TEST_FULL_PATH = joinPaths(TEST_HOST, TEST_RAW_PATH);
const TEST_CONTENT = 'Lorem ipsum dolar sit amet,\nconsectetur adipiscing elit.';
const TEST_BLOB = {
id: TEST_ID,
rawPath: TEST_RAW_PATH,
path: TEST_PATH,
content: '',
isLoaded: false,
};
const TEST_BLOB_LOADED = {
...TEST_BLOB,
content: TEST_CONTENT,
isLoaded: true,
};
describe('Snippet Blob Edit component', () => { describe('Snippet Blob Edit component', () => {
let wrapper; let wrapper;
let axiosMock; 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 = {}, data = { isContentLoading: false }) { const createComponent = (props = {}) => {
wrapper = shallowMount(SnippetBlobEdit, { wrapper = shallowMount(SnippetBlobEdit, {
propsData: { propsData: {
blob: TEST_BLOB,
...props, ...props,
}, },
data() {
return {
...data,
};
},
}); });
flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure'); };
}
beforeEach(() => { const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
// This component generates a random id. Soon this will be abstracted away, but for now let's make this deterministic. const findHeader = () => wrapper.find(BlobHeaderEdit);
// see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38855 const findContent = () => wrapper.find(BlobContentEdit);
jest.spyOn(Math, 'random').mockReturnValue(0.04); const getLastUpdatedArgs = () => {
const event = wrapper.emitted()['blob-updated'];
return event?.[event.length - 1][0];
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios); axiosMock = new AxiosMockAdapter(axios);
createComponent(); axiosMock.onGet(TEST_FULL_PATH).reply(200, TEST_CONTENT);
}); });
afterEach(() => { afterEach(() => {
axiosMock.restore();
wrapper.destroy(); wrapper.destroy();
wrapper = null;
axiosMock.restore();
}); });
describe('rendering', () => { describe('with not loaded blob', () => {
it('matches the snapshot', () => { beforeEach(async () => {
createComponent({ blob }); createComponent();
expect(wrapper.element).toMatchSnapshot();
}); });
it('renders required components', () => { it('shows blob header', () => {
expect(findComponent(BlobHeaderEdit).exists()).toBe(true); expect(findHeader().props()).toMatchObject({
expect(findComponent(BlobContentEdit).props()).toEqual({ value: TEST_BLOB.path,
fileGlobalId: expect.any(String),
fileName: '',
value: '',
}); });
expect(findHeader().attributes('id')).toBe(`${TEST_ID}_file_path`);
}); });
it('renders loader if existing blob is supplied but no content is fetched yet', () => { it('emits delete when deleted', () => {
createComponent({ blob }, { isContentLoading: true }); expect(wrapper.emitted().delete).toBeUndefined();
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
expect(findComponent(BlobContentEdit).exists()).toBe(false); findHeader().vm.$emit('delete');
expect(wrapper.emitted().delete).toHaveLength(1);
}); });
it('does not render loader if when blob is not supplied', () => { it('emits update when path changes', () => {
createComponent(); const newPath = 'new/path.md';
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
expect(findComponent(BlobContentEdit).exists()).toBe(true); findHeader().vm.$emit('input', newPath);
expect(getLastUpdatedArgs()).toEqual({ path: newPath });
}); });
});
describe('functionality', () => { it('emits update when content is loaded', async () => {
it('does not fail without blob', () => { await waitForPromises();
const spy = jest.spyOn(global.console, 'error');
createComponent({ blob: undefined });
expect(spy).not.toHaveBeenCalled(); expect(getLastUpdatedArgs()).toEqual({ content: TEST_CONTENT });
expect(findComponent(BlobContentEdit).exists()).toBe(true);
}); });
});
it.each` describe('with error', () => {
emitter | prop beforeEach(() => {
${BlobHeaderEdit} | ${'filePath'} axiosMock.reset();
${BlobContentEdit} | ${'content'} axiosMock.onGet(TEST_FULL_PATH).replyOnce(500);
`('emits "blob-updated" event when the $prop gets changed', ({ emitter, prop }) => { createComponent();
expect(wrapper.emitted('blob-updated')).toBeUndefined();
const newValue = 'foo.bar';
findComponent(emitter).vm.$emit('input', newValue);
return nextTick().then(() => {
expect(wrapper.emitted('blob-updated')[0]).toEqual([
expect.objectContaining({
[prop]: newValue,
}),
]);
});
}); });
describe('fetching blob content', () => { it('should call flash', async () => {
const bootstrapForExistingSnippet = resp => { await waitForPromises();
createComponent({
blob: {
...blob,
content: '',
},
});
if (resp === 500) { expect(createFlash).toHaveBeenCalledWith(
axiosMock.onGet('contentApiURL').reply(500); "Can't fetch content for the blob: Error: Request failed with status code 500",
} else { );
axiosMock.onGet('contentApiURL').reply(200, contentMock); });
} });
};
const bootstrapForNewSnippet = () => { describe('with loaded blob', () => {
createComponent(); beforeEach(() => {
}; createComponent({ blob: TEST_BLOB_LOADED });
});
it('fetches blob content with the additional query', () => { it('matches snapshot', () => {
bootstrapForExistingSnippet(); expect(wrapper.element).toMatchSnapshot();
});
return waitForPromises().then(() => { it('does not make API request', () => {
expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock); expect(axiosMock.history.get).toHaveLength(0);
expect(findComponent(BlobHeaderEdit).props('value')).toBe(pathMock); });
expect(findComponent(BlobContentEdit).props('value')).toBe(contentMock); });
});
});
it('flashes the error message if fetching content fails', () => { describe.each`
bootstrapForExistingSnippet(500); props | showLoading | showContent
${{ blob: TEST_BLOB, canDelete: true, showDelete: true }} | ${true} | ${false}
${{ blob: TEST_BLOB, canDelete: false, showDelete: false }} | ${true} | ${false}
${{ blob: TEST_BLOB_LOADED }} | ${false} | ${true}
`('with $props', ({ props, showLoading, showContent }) => {
beforeEach(() => {
createComponent(props);
});
return waitForPromises().then(() => { it('shows blob header', () => {
expect(flashSpy).toHaveBeenCalled(); const { canDelete = true, showDelete = false } = props;
expect(findComponent(BlobContentEdit).props('value')).toBe('');
}); expect(findHeader().props()).toMatchObject({
canDelete,
showDelete,
}); });
});
it(`handles loading icon (show=${showLoading})`, () => {
expect(findLoadingIcon().exists()).toBe(showLoading);
});
it('does not fetch content for new snippet', () => { it(`handles content (show=${showContent})`, () => {
bootstrapForNewSnippet(); expect(findContent().exists()).toBe(showContent);
return waitForPromises().then(() => { if (showContent) {
// we keep using waitForPromises to make sure we do not run failed test expect(findContent().props()).toEqual({
expect(findComponent(BlobHeaderEdit).props('value')).toBe(''); value: TEST_BLOB_LOADED.content,
expect(findComponent(BlobContentEdit).props('value')).toBe(''); fileGlobalId: TEST_BLOB_LOADED.id,
expect(joinPaths).not.toHaveBeenCalled(); fileName: TEST_BLOB_LOADED.path,
}); });
}); }
}); });
}); });
}); });
import {
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
SNIPPET_BLOB_ACTION_DELETE,
} from '~/snippets/constants';
const CONTENT_1 = 'Lorem ipsum dolar\nSit amit\n\nGoodbye!\n';
const CONTENT_2 = 'Lorem ipsum dolar sit amit.\n\nGoodbye!\n';
export const testEntries = {
created: {
id: 'blob_1',
diff: {
action: SNIPPET_BLOB_ACTION_CREATE,
filePath: '/new/file',
previousPath: '/new/file',
content: CONTENT_1,
},
},
deleted: {
id: 'blob_2',
diff: {
action: SNIPPET_BLOB_ACTION_DELETE,
filePath: '/src/delete/me',
previousPath: '/src/delete/me',
content: CONTENT_1,
},
},
updated: {
id: 'blob_3',
origContent: CONTENT_1,
diff: {
action: SNIPPET_BLOB_ACTION_UPDATE,
filePath: '/lorem.md',
previousPath: '/lorem.md',
content: CONTENT_2,
},
},
renamed: {
id: 'blob_4',
diff: {
action: SNIPPET_BLOB_ACTION_MOVE,
filePath: '/dolar.md',
previousPath: '/ipsum.md',
content: CONTENT_1,
},
},
renamedAndUpdated: {
id: 'blob_5',
origContent: CONTENT_1,
diff: {
action: SNIPPET_BLOB_ACTION_MOVE,
filePath: '/sit.md',
previousPath: '/sit/amit.md',
content: CONTENT_2,
},
},
};
export const createBlobFromTestEntry = ({ diff, origContent }, isOrig = false) => ({
content: isOrig && origContent ? origContent : diff.content,
path: isOrig ? diff.previousPath : diff.filePath,
});
export const createBlobsFromTestEntries = (entries, isOrig = false) =>
entries.reduce(
(acc, entry) =>
Object.assign(acc, {
[entry.id]: {
id: entry.id,
...createBlobFromTestEntry(entry, isOrig),
},
}),
{},
);
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import {
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
SNIPPET_BLOB_ACTION_DELETE,
} from '~/snippets/constants';
import { decorateBlob, createBlob, diffAll } from '~/snippets/utils/blob'; import { decorateBlob, createBlob, diffAll } from '~/snippets/utils/blob';
import { testEntries, createBlobsFromTestEntries } from '../test_utils';
jest.mock('lodash/uniqueId', () => arg => `${arg}fakeUniqueId`); jest.mock('lodash/uniqueId', () => arg => `${arg}fakeUniqueId`);
const TEST_RAW_BLOB = { const TEST_RAW_BLOB = {
rawPath: '/test/blob/7/raw', rawPath: '/test/blob/7/raw',
}; };
const CONTENT_1 = 'Lorem ipsum dolar\nSit amit\n\nGoodbye!\n';
const CONTENT_2 = 'Lorem ipsum dolar sit amit.\n\nGoodbye!\n';
describe('~/snippets/utils/blob', () => { describe('~/snippets/utils/blob', () => {
describe('decorateBlob', () => { describe('decorateBlob', () => {
...@@ -41,70 +34,6 @@ describe('~/snippets/utils/blob', () => { ...@@ -41,70 +34,6 @@ describe('~/snippets/utils/blob', () => {
}); });
describe('diffAll', () => { describe('diffAll', () => {
// This object contains entries that contain an expected "diff" and the `id`
// or `origContent` that should be used to generate the expected diff.
const testEntries = {
created: {
id: 'blob_1',
diff: {
action: SNIPPET_BLOB_ACTION_CREATE,
filePath: '/new/file',
previousPath: '/new/file',
content: CONTENT_1,
},
},
deleted: {
id: 'blob_2',
diff: {
action: SNIPPET_BLOB_ACTION_DELETE,
filePath: '/src/delete/me',
previousPath: '/src/delete/me',
content: CONTENT_1,
},
},
updated: {
id: 'blob_3',
origContent: CONTENT_1,
diff: {
action: SNIPPET_BLOB_ACTION_UPDATE,
filePath: '/lorem.md',
previousPath: '/lorem.md',
content: CONTENT_2,
},
},
renamed: {
id: 'blob_4',
diff: {
action: SNIPPET_BLOB_ACTION_MOVE,
filePath: '/dolar.md',
previousPath: '/ipsum.md',
content: CONTENT_1,
},
},
renamedAndUpdated: {
id: 'blob_5',
origContent: CONTENT_1,
diff: {
action: SNIPPET_BLOB_ACTION_MOVE,
filePath: '/sit.md',
previousPath: '/sit/amit.md',
content: CONTENT_2,
},
},
};
const createBlobsFromTestEntries = (entries, isOrig = false) =>
entries.reduce(
(acc, { id, diff, origContent }) =>
Object.assign(acc, {
[id]: {
id,
content: isOrig && origContent ? origContent : diff.content,
path: isOrig ? diff.previousPath : diff.filePath,
},
}),
{},
);
it('should create diff from original files', () => { it('should create diff from original files', () => {
const origBlobs = createBlobsFromTestEntries( const origBlobs = createBlobsFromTestEntries(
[ [
......
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