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 {
canDelete: {
type: Boolean,
required: false,
default: false,
default: true,
},
showDelete: {
type: Boolean,
......
......@@ -14,9 +14,6 @@ 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 SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
......@@ -56,25 +53,20 @@ export default {
},
data() {
return {
blobsActions: {},
isUpdating: false,
newSnippet: false,
actions: [],
};
},
computed: {
getActionsEntries() {
return Object.values(this.blobsActions);
hasBlobChanges() {
return this.actions.length > 0;
},
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 === '');
hasValidBlobs() {
return this.actions.every(x => x.filePath && x.content);
},
updatePrevented() {
return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating;
return this.snippet.title === '' || !this.hasValidBlobs || this.isUpdating;
},
isProjectSnippet() {
return Boolean(this.projectPath);
......@@ -85,7 +77,7 @@ export default {
title: this.snippet.title,
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
blobActions: this.getActionsEntries.filter(entry => entry.action !== ''),
blobActions: this.actions,
};
},
saveButtonLabel() {
......@@ -120,48 +112,11 @@ export default {
onBeforeUnload(e = {}) {
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 });
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) {
const defaultErrorMsg = this.newSnippet
? SNIPPET_CREATE_MUTATION_ERROR
......@@ -218,7 +173,6 @@ export default {
if (errors.length) {
this.flashAPIFailure(errors[0]);
} else {
this.originalContent = this.content;
redirectTo(baseObj.snippet.webUrl);
}
})
......@@ -226,6 +180,9 @@ export default {
this.flashAPIFailure(e);
});
},
updateActions(actions) {
this.actions = actions;
},
},
newSnippetSchema: {
title: '',
......@@ -261,7 +218,7 @@ export default {
:markdown-preview-path="markdownPreviewPath"
: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
v-model="snippet.visibilityLevel"
......
<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 { SNIPPET_MAX_BLOBS } from '../constants';
import { createBlob, decorateBlob, diffAll } from '../utils/blob';
export default {
components: {
SnippetBlobEdit,
GlButton,
},
mixins: [glFeatureFlagsMixin()],
props: {
blobs: {
initBlobs: {
type: Array,
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>
<template>
<div class="form-group file-editor">
<label for="snippet_file_path">{{ s__('Snippets|File') }}</label>
<template v-if="blobs.length">
<snippet-blob-edit v-for="blob in blobs" :key="blob.name" :blob="blob" v-on="$listeners" />
</template>
<snippet-blob-edit v-else v-on="$listeners" />
<label :for="firstInputId">{{ filesLabel }}</label>
<snippet-blob-edit
v-for="(blobId, index) in blobIds"
:key="blobId"
: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>
</template>
......@@ -8,12 +8,6 @@ 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: {
BlobHeaderEdit,
......@@ -24,49 +18,35 @@ export default {
props: {
blob: {
type: Object,
required: true,
},
canDelete: {
type: Boolean,
required: false,
default: null,
validator: ({ rawPath }) => Boolean(rawPath),
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 });
showDelete: {
type: Boolean,
required: false,
default: false,
},
content() {
this.notifyAboutUpdates();
},
computed: {
inputId() {
return `${this.blob.id}_file_path`;
},
},
mounted() {
if (this.blob) {
if (!this.blob.isLoaded) {
this.fetchBlobContent();
}
},
methods: {
onDelete() {
this.$emit('delete');
},
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,
},
});
this.$emit('blob-updated', args);
},
fetchBlobContent() {
const baseUrl = getBaseURL();
......@@ -75,17 +55,12 @@ export default {
axios
.get(url)
.then(res => {
this.originalContent = res.data;
this.content = res.data;
this.notifyAboutUpdates({ content: res.data });
})
.catch(e => this.flashAPIFailure(e))
.finally(() => {
this.isContentLoading = false;
});
.catch(e => this.flashAPIFailure(e));
},
flashAPIFailure(err) {
Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }));
this.isContentLoading = false;
},
},
};
......@@ -93,16 +68,26 @@ export default {
<template>
<div class="file-holder snippet">
<blob-header-edit
id="snippet_file_path"
v-model="filePath"
:id="inputId"
:value="blob.path"
data-qa-selector="file_name_field"
:can-delete="canDelete"
:show-delete="showDelete"
@input="notifyAboutUpdates({ path: $event })"
@delete="onDelete"
/>
<gl-loading-icon
v-if="isContentLoading"
v-if="!blob.isLoaded"
:label="__('Loading snippet')"
size="lg"
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>
</template>
......@@ -31,3 +31,5 @@ export const SNIPPET_BLOB_ACTION_CREATE = 'create';
export const SNIPPET_BLOB_ACTION_UPDATE = 'update';
export const SNIPPET_BLOB_ACTION_MOVE = 'move';
export const SNIPPET_BLOB_ACTION_DELETE = 'delete';
export const SNIPPET_MAX_BLOBS = 10;
......@@ -14,6 +14,10 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
before_action :authorize_update_snippet!, only: [:edit, :update]
before_action :authorize_admin_snippet!, only: [:destroy]
before_action do
push_frontend_feature_flag(:snippet_multiple_files, current_user)
end
def index
@snippet_counts = ::Snippets::CountService
.new(current_user, project: @project)
......
......@@ -17,6 +17,10 @@ class SnippetsController < Snippets::ApplicationController
layout 'snippets'
before_action do
push_frontend_feature_flag(:snippet_multiple_files, current_user)
end
def index
if params[:username].present?
@user = UserFinder.new(params[:username]).find_by_username!
......
......@@ -22672,6 +22672,9 @@ msgstr ""
msgid "SnippetsEmptyState|There are no snippets to show."
msgstr ""
msgid "Snippets|Add another file %{num}/%{total}"
msgstr ""
msgid "Snippets|Delete file"
msgstr ""
......@@ -22681,6 +22684,9 @@ msgstr ""
msgid "Snippets|File"
msgstr ""
msgid "Snippets|Files"
msgstr ""
msgid "Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"
msgstr ""
......
......@@ -56,9 +56,9 @@ describe('Blob Header Editing', () => {
});
describe.each`
props | expectedDisabled
${{ showDelete: true }} | ${true}
${{ showDelete: true, canDelete: true }} | ${false}
props | expectedDisabled
${{ showDelete: true }} | ${false}
${{ showDelete: true, canDelete: false }} | ${true}
`('with $props', ({ props, expectedDisabled }) => {
beforeEach(() => {
createComponent(props);
......
// 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
class="file-holder snippet"
>
<blob-header-edit-stub
candelete="true"
data-qa-selector="file_name_field"
id="snippet_file_path"
value="lorem.txt"
id="blob_local_7_file_path"
value="foo/bar/test.md"
/>
<blob-content-edit-stub
fileglobalid="0a3d"
filename="lorem.txt"
value="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
fileglobalid="blob_local_7"
filename="foo/bar/test.md"
value="Lorem ipsum dolar sit amet,
consectetur adipiscing elit."
/>
</div>
`;
import { times } from 'lodash';
import { shallowMount } from '@vue/test-utils';
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_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 = [
{ name: 'foo', content: 'abc', rawPath: 'test/raw' },
{ name: 'bar', content: 'def', rawPath: 'test/raw' },
createBlobFromTestEntry(testEntries.updated),
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', () => {
let onEvent;
let wrapper;
const createComponent = (props = {}) => {
const createComponent = (props = {}, snippetMultipleFiles = true) => {
wrapper = shallowMount(SnippetBlobActionsEdit, {
propsData: {
blobs: [],
initBlobs: TEST_BLOBS,
...props,
},
listeners: {
[TEST_EVENT]: onEvent,
provide: {
glFeatures: {
snippetMultipleFiles,
},
},
});
};
const findBlobEdit = () => wrapper.find(SnippetBlobEdit);
const findBlobEditData = () => wrapper.findAll(SnippetBlobEdit).wrappers.map(x => x.props());
beforeEach(() => {
onEvent = jest.fn();
});
const findLabel = () => wrapper.find('label');
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(() => {
wrapper.destroy();
......@@ -36,24 +70,232 @@ describe('snippets/components/snippet_blob_actions_edit', () => {
});
describe.each`
props | expectedData
${{}} | ${[{ blob: null }]}
${{ blobs: TEST_BLOBS }} | ${TEST_BLOBS.map(blob => ({ blob }))}
`('with $props', ({ props, expectedData }) => {
featureFlag | label | showDelete | showAdd
${true} | ${'Files'} | ${true} | ${true}
${false} | ${'File'} | ${false} | ${false}
`('with feature flag = $featureFlag', ({ featureFlag, label, showDelete, showAdd }) => {
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', () => {
expect(findBlobEditData()).toEqual(expectedData);
it('shows blobs', () => {
expect(findBlobsData()).toEqual(buildBlobsDataExpectation(TEST_BLOBS_UNLOADED));
});
it('emits event', () => {
expect(onEvent).not.toHaveBeenCalled();
it('shows add button', () => {
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 { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import AxiosMockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue';
......@@ -8,166 +7,162 @@ import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue';
import BlobContentEdit from '~/blob/components/blob_edit_content.vue';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
jest.mock('~/blob/utils', () => jest.fn());
jest.mock('~/lib/utils/url_utility', () => ({
getBaseURL: jest.fn().mockReturnValue('foo/'),
joinPaths: jest
.fn()
.mockName('joinPaths')
.mockReturnValue('contentApiURL'),
}));
import createFlash from '~/flash';
import { TEST_HOST } from 'helpers/test_constants';
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', () => {
let wrapper;
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, {
propsData: {
blob: TEST_BLOB,
...props,
},
data() {
return {
...data,
};
},
});
flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure');
}
};
beforeEach(() => {
// This component generates a random id. Soon this will be abstracted away, but for now let's make this deterministic.
// see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38855
jest.spyOn(Math, 'random').mockReturnValue(0.04);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findHeader = () => wrapper.find(BlobHeaderEdit);
const findContent = () => wrapper.find(BlobContentEdit);
const getLastUpdatedArgs = () => {
const event = wrapper.emitted()['blob-updated'];
return event?.[event.length - 1][0];
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
createComponent();
axiosMock.onGet(TEST_FULL_PATH).reply(200, TEST_CONTENT);
});
afterEach(() => {
axiosMock.restore();
wrapper.destroy();
wrapper = null;
axiosMock.restore();
});
describe('rendering', () => {
it('matches the snapshot', () => {
createComponent({ blob });
expect(wrapper.element).toMatchSnapshot();
describe('with not loaded blob', () => {
beforeEach(async () => {
createComponent();
});
it('renders required components', () => {
expect(findComponent(BlobHeaderEdit).exists()).toBe(true);
expect(findComponent(BlobContentEdit).props()).toEqual({
fileGlobalId: expect.any(String),
fileName: '',
value: '',
it('shows blob header', () => {
expect(findHeader().props()).toMatchObject({
value: TEST_BLOB.path,
});
expect(findHeader().attributes('id')).toBe(`${TEST_ID}_file_path`);
});
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(findComponent(BlobContentEdit).exists()).toBe(false);
it('emits delete when deleted', () => {
expect(wrapper.emitted().delete).toBeUndefined();
findHeader().vm.$emit('delete');
expect(wrapper.emitted().delete).toHaveLength(1);
});
it('does not render loader if when blob is not supplied', () => {
createComponent();
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
expect(findComponent(BlobContentEdit).exists()).toBe(true);
it('emits update when path changes', () => {
const newPath = 'new/path.md';
findHeader().vm.$emit('input', newPath);
expect(getLastUpdatedArgs()).toEqual({ path: newPath });
});
});
describe('functionality', () => {
it('does not fail without blob', () => {
const spy = jest.spyOn(global.console, 'error');
createComponent({ blob: undefined });
it('emits update when content is loaded', async () => {
await waitForPromises();
expect(spy).not.toHaveBeenCalled();
expect(findComponent(BlobContentEdit).exists()).toBe(true);
expect(getLastUpdatedArgs()).toEqual({ content: TEST_CONTENT });
});
});
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('blob-updated')[0]).toEqual([
expect.objectContaining({
[prop]: newValue,
}),
]);
});
describe('with error', () => {
beforeEach(() => {
axiosMock.reset();
axiosMock.onGet(TEST_FULL_PATH).replyOnce(500);
createComponent();
});
describe('fetching blob content', () => {
const bootstrapForExistingSnippet = resp => {
createComponent({
blob: {
...blob,
content: '',
},
});
it('should call flash', async () => {
await waitForPromises();
if (resp === 500) {
axiosMock.onGet('contentApiURL').reply(500);
} else {
axiosMock.onGet('contentApiURL').reply(200, contentMock);
}
};
expect(createFlash).toHaveBeenCalledWith(
"Can't fetch content for the blob: Error: Request failed with status code 500",
);
});
});
const bootstrapForNewSnippet = () => {
createComponent();
};
describe('with loaded blob', () => {
beforeEach(() => {
createComponent({ blob: TEST_BLOB_LOADED });
});
it('fetches blob content with the additional query', () => {
bootstrapForExistingSnippet();
it('matches snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
return waitForPromises().then(() => {
expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock);
expect(findComponent(BlobHeaderEdit).props('value')).toBe(pathMock);
expect(findComponent(BlobContentEdit).props('value')).toBe(contentMock);
});
});
it('does not make API request', () => {
expect(axiosMock.history.get).toHaveLength(0);
});
});
it('flashes the error message if fetching content fails', () => {
bootstrapForExistingSnippet(500);
describe.each`
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(() => {
expect(flashSpy).toHaveBeenCalled();
expect(findComponent(BlobContentEdit).props('value')).toBe('');
});
it('shows blob header', () => {
const { canDelete = true, showDelete = false } = props;
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', () => {
bootstrapForNewSnippet();
it(`handles content (show=${showContent})`, () => {
expect(findContent().exists()).toBe(showContent);
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();
if (showContent) {
expect(findContent().props()).toEqual({
value: TEST_BLOB_LOADED.content,
fileGlobalId: TEST_BLOB_LOADED.id,
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 {
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 { testEntries, createBlobsFromTestEntries } from '../test_utils';
jest.mock('lodash/uniqueId', () => arg => `${arg}fakeUniqueId`);
const TEST_RAW_BLOB = {
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('decorateBlob', () => {
......@@ -41,70 +34,6 @@ describe('~/snippets/utils/blob', () => {
});
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', () => {
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