Commit 19487598 authored by Nathan Friend's avatar Nathan Friend

Merge branch '217801-step-3-setup-for-multi-file' into 'master'

Step 3 - Setup snippet utils and components for multi file

See merge request gitlab-org/gitlab!39302
parents 4fb00f23 e501fb59
<script>
import { GlFormInput } from '@gitlab/ui';
import { GlFormInput, GlButton } from '@gitlab/ui';
export default {
components: {
GlFormInput,
GlButton,
},
inheritAttrs: false,
props: {
......@@ -11,6 +12,16 @@ export default {
type: String,
required: true,
},
canDelete: {
type: Boolean,
required: false,
default: false,
},
showDelete: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -21,17 +32,27 @@ export default {
</script>
<template>
<div class="js-file-title file-title-flex-parent">
<gl-form-input
id="snippet_file_name"
v-model="name"
:placeholder="
s__('Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby')
"
name="snippet_file_name"
class="form-control js-snippet-file-name"
type="text"
v-bind="$attrs"
@change="$emit('input', name)"
/>
<div class="gl-display-flex gl-align-items-center gl-w-full">
<gl-form-input
v-model="name"
:placeholder="
s__('Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby')
"
name="snippet_file_name"
class="form-control js-snippet-file-name"
type="text"
v-bind="$attrs"
@change="$emit('input', name)"
/>
<gl-button
v-if="showDelete"
class="gl-ml-4"
variant="danger"
category="secondary"
:disabled="!canDelete"
@click="$emit('delete')"
>{{ s__('Snippets|Delete file') }}</gl-button
>
</div>
</div>
</template>
......@@ -18,7 +18,7 @@ import {
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
} from '../constants';
import SnippetBlobEdit from './snippet_blob_edit.vue';
import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
import SnippetDescriptionEdit from './snippet_description_edit.vue';
import { SNIPPET_MARK_EDIT_APP_START } from '~/performance_constants';
......@@ -27,7 +27,7 @@ export default {
components: {
SnippetDescriptionEdit,
SnippetVisibilityEdit,
SnippetBlobEdit,
SnippetBlobActionsEdit,
TitleField,
FormFooterActions,
GlButton,
......@@ -261,15 +261,7 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
/>
<template v-if="blobs.length">
<snippet-blob-edit
v-for="blob in blobs"
:key="blob.name"
:blob="blob"
@blob-updated="updateBlobActions"
/>
</template>
<snippet-blob-edit v-else @blob-updated="updateBlobActions" />
<snippet-blob-actions-edit :blobs="blobs" @blob-updated="updateBlobActions" />
<snippet-visibility-edit
v-model="snippet.visibilityLevel"
......
<script>
import SnippetBlobEdit from './snippet_blob_edit.vue';
export default {
components: {
SnippetBlobEdit,
},
props: {
blobs: {
type: Array,
required: true,
},
},
};
</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" />
</div>
</template>
......@@ -91,17 +91,18 @@ export default {
};
</script>
<template>
<div class="form-group file-editor">
<label>{{ s__('Snippets|File') }}</label>
<div class="file-holder snippet">
<blob-header-edit v-model="filePath" data-qa-selector="file_name_field" />
<gl-loading-icon
v-if="isContentLoading"
: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" />
</div>
<div class="file-holder snippet">
<blob-header-edit
id="snippet_file_path"
v-model="filePath"
data-qa-selector="file_name_field"
/>
<gl-loading-icon
v-if="isContentLoading"
: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" />
</div>
</template>
......@@ -30,3 +30,4 @@ export const SNIPPET_BLOB_CONTENT_FETCH_ERROR = __("Can't fetch content for the
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';
import { uniqueId } from 'lodash';
import {
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
SNIPPET_BLOB_ACTION_DELETE,
} from '../constants';
const createLocalId = () => uniqueId('blob_local_');
export const decorateBlob = blob => ({
...blob,
id: createLocalId(),
isLoaded: false,
content: '',
});
export const createBlob = () => ({
id: createLocalId(),
content: '',
path: '',
isLoaded: true,
});
const diff = ({ content, path }, origBlob) => {
if (!origBlob) {
return {
action: SNIPPET_BLOB_ACTION_CREATE,
previousPath: path,
content,
filePath: path,
};
} else if (origBlob.path !== path || origBlob.content !== content) {
return {
action: origBlob.path === path ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE,
previousPath: origBlob.path,
content,
filePath: path,
};
}
return null;
};
/**
* This function returns an array of diff actions (to be sent to the BE) based on the current vs. original blobs
*
* @param {Object} blobs
* @param {Object} origBlobs
*/
export const diffAll = (blobs, origBlobs) => {
const deletedEntries = Object.values(origBlobs)
.filter(x => !blobs[x.id])
.map(({ path, content }) => ({
action: SNIPPET_BLOB_ACTION_DELETE,
previousPath: path,
filePath: path,
content,
}));
const newEntries = Object.values(blobs)
.map(blob => diff(blob, origBlobs[blob.id]))
.filter(x => x);
return [...deletedEntries, ...newEntries];
};
......@@ -22549,6 +22549,9 @@ msgstr ""
msgid "SnippetsEmptyState|There are no snippets to show."
msgstr ""
msgid "Snippets|Delete file"
msgstr ""
msgid "Snippets|Description (optional)"
msgstr ""
......
......@@ -4,13 +4,18 @@ exports[`Blob Header Editing rendering matches the snapshot 1`] = `
<div
class="js-file-title file-title-flex-parent"
>
<gl-form-input-stub
class="form-control js-snippet-file-name"
id="snippet_file_name"
name="snippet_file_name"
placeholder="Give your file a name to add code highlighting, e.g. example.rb for Ruby"
type="text"
value="foo.md"
/>
<div
class="gl-display-flex gl-align-items-center gl-w-full"
>
<gl-form-input-stub
class="form-control js-snippet-file-name"
name="snippet_file_name"
placeholder="Give your file a name to add code highlighting, e.g. example.rb for Ruby"
type="text"
value="foo.md"
/>
<!---->
</div>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import BlobEditHeader from '~/blob/components/blob_edit_header.vue';
import { GlFormInput } from '@gitlab/ui';
import { GlFormInput, GlButton } from '@gitlab/ui';
describe('Blob Header Editing', () => {
let wrapper;
const value = 'foo.md';
function createComponent() {
const createComponent = (props = {}) => {
wrapper = shallowMount(BlobEditHeader, {
propsData: {
value,
...props,
},
});
}
};
const findDeleteButton = () =>
wrapper.findAll(GlButton).wrappers.find(x => x.text() === 'Delete file');
beforeEach(() => {
createComponent();
......@@ -30,6 +33,10 @@ describe('Blob Header Editing', () => {
it('contains a form input field', () => {
expect(wrapper.contains(GlFormInput)).toBe(true);
});
it('does not show delete button', () => {
expect(findDeleteButton()).toBeUndefined();
});
});
describe('functionality', () => {
......@@ -47,4 +54,35 @@ describe('Blob Header Editing', () => {
});
});
});
describe.each`
props | expectedDisabled
${{ showDelete: true }} | ${true}
${{ showDelete: true, canDelete: true }} | ${false}
`('with $props', ({ props, expectedDisabled }) => {
beforeEach(() => {
createComponent(props);
});
it(`shows delete button (disabled=${expectedDisabled})`, () => {
const deleteButton = findDeleteButton();
expect(deleteButton.exists()).toBe(true);
expect(deleteButton.props('disabled')).toBe(expectedDisabled);
});
});
describe('with delete button', () => {
beforeEach(() => {
createComponent({ showDelete: true, canDelete: true });
});
it('emits delete when clicked', () => {
expect(wrapper.emitted().delete).toBeUndefined();
findDeleteButton().vm.$emit('click');
expect(wrapper.emitted().delete).toEqual([[]]);
});
});
});
......@@ -2,25 +2,18 @@
exports[`Snippet Blob Edit component rendering matches the snapshot 1`] = `
<div
class="form-group file-editor"
class="file-holder snippet"
>
<label>
File
</label>
<blob-header-edit-stub
data-qa-selector="file_name_field"
id="snippet_file_path"
value="lorem.txt"
/>
<div
class="file-holder snippet"
>
<blob-header-edit-stub
data-qa-selector="file_name_field"
value="lorem.txt"
/>
<blob-content-edit-stub
fileglobalid="0a3d"
filename="lorem.txt"
value="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/>
</div>
<blob-content-edit-stub
fileglobalid="0a3d"
filename="lorem.txt"
value="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/>
</div>
`;
......@@ -7,7 +7,7 @@ import { redirectTo } from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue';
import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue';
import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue';
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
import TitleField from '~/vue_shared/components/form/title.vue';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '~/snippets/constants';
......@@ -141,7 +141,7 @@ describe('Snippet Edit app', () => {
expect(wrapper.contains(TitleField)).toBe(true);
expect(wrapper.contains(SnippetDescriptionEdit)).toBe(true);
expect(wrapper.contains(SnippetBlobEdit)).toBe(true);
expect(wrapper.contains(SnippetBlobActionsEdit)).toBe(true);
expect(wrapper.contains(SnippetVisibilityEdit)).toBe(true);
expect(wrapper.contains(FormFooterActions)).toBe(true);
});
......
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';
const TEST_BLOBS = [
{ name: 'foo', content: 'abc', rawPath: 'test/raw' },
{ name: 'bar', content: 'def', rawPath: 'test/raw' },
];
const TEST_EVENT = 'blob-update';
describe('snippets/components/snippet_blob_actions_edit', () => {
let onEvent;
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(SnippetBlobActionsEdit, {
propsData: {
blobs: [],
...props,
},
listeners: {
[TEST_EVENT]: onEvent,
},
});
};
const findBlobEdit = () => wrapper.find(SnippetBlobEdit);
const findBlobEditData = () => wrapper.findAll(SnippetBlobEdit).wrappers.map(x => x.props());
beforeEach(() => {
onEvent = jest.fn();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each`
props | expectedData
${{}} | ${[{ blob: null }]}
${{ blobs: TEST_BLOBS }} | ${TEST_BLOBS.map(blob => ({ blob }))}
`('with $props', ({ props, expectedData }) => {
beforeEach(() => {
createComponent(props);
});
it('renders blob edit', () => {
expect(findBlobEditData()).toEqual(expectedData);
});
it('emits event', () => {
expect(onEvent).not.toHaveBeenCalled();
findBlobEdit().vm.$emit('blob-update', TEST_BLOBS[0]);
expect(onEvent).toHaveBeenCalledWith(TEST_BLOBS[0]);
});
});
});
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';
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', () => {
it('should decorate the given object with local blob properties', () => {
const orig = cloneDeep(TEST_RAW_BLOB);
expect(decorateBlob(orig)).toEqual({
...TEST_RAW_BLOB,
id: 'blob_local_fakeUniqueId',
isLoaded: false,
content: '',
});
});
});
describe('createBlob', () => {
it('should create an empty local blob', () => {
expect(createBlob()).toEqual({
id: 'blob_local_fakeUniqueId',
isLoaded: true,
content: '',
path: '',
});
});
});
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(
[
testEntries.deleted,
testEntries.updated,
testEntries.renamed,
testEntries.renamedAndUpdated,
],
true,
);
const blobs = createBlobsFromTestEntries([
testEntries.created,
testEntries.updated,
testEntries.renamed,
testEntries.renamedAndUpdated,
]);
expect(diffAll(blobs, origBlobs)).toEqual([
testEntries.deleted.diff,
testEntries.created.diff,
testEntries.updated.diff,
testEntries.renamed.diff,
testEntries.renamedAndUpdated.diff,
]);
});
});
});
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