Commit d3fc3be0 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent c6c74378
...@@ -45,7 +45,13 @@ export default { ...@@ -45,7 +45,13 @@ export default {
<template v-else> <template v-else>
<blob-content-error v-if="viewerError" :viewer-error="viewerError" /> <blob-content-error v-if="viewerError" :viewer-error="viewerError" />
<component :is="viewer" v-else ref="contentViewer" :content="content" /> <component
:is="viewer"
v-else
ref="contentViewer"
:content="content"
:type="activeViewer.fileType"
/>
</template> </template>
</div> </div>
</template> </template>
...@@ -5,10 +5,43 @@ import { handleLocationHash } from '../../lib/utils/common_utils'; ...@@ -5,10 +5,43 @@ import { handleLocationHash } from '../../lib/utils/common_utils';
import axios from '../../lib/utils/axios_utils'; import axios from '../../lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
const loadRichBlobViewer = type => {
switch (type) {
case 'balsamiq':
return import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer');
case 'notebook':
return import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer');
case 'openapi':
return import(/* webpackChunkName: 'openapi_viewer' */ '../openapi_viewer');
case 'pdf':
return import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer');
case 'sketch':
return import(/* webpackChunkName: 'sketch_viewer' */ '../sketch_viewer');
case 'stl':
return import(/* webpackChunkName: 'stl_viewer' */ '../stl_viewer');
default:
return Promise.resolve();
}
};
export const handleBlobRichViewer = (viewer, type) => {
if (!viewer || !type) return;
loadRichBlobViewer(type)
.then(module => module?.default(viewer))
.catch(error => {
Flash(__('Error loading file viewer.'));
throw error;
});
};
export default class BlobViewer { export default class BlobViewer {
constructor() { constructor() {
const viewer = document.querySelector('.blob-viewer[data-type="rich"]');
const type = viewer?.dataset?.richType;
BlobViewer.initAuxiliaryViewer(); BlobViewer.initAuxiliaryViewer();
BlobViewer.initRichViewer();
handleBlobRichViewer(viewer, type);
this.initMainViewers(); this.initMainViewers();
} }
...@@ -20,42 +53,6 @@ export default class BlobViewer { ...@@ -20,42 +53,6 @@ export default class BlobViewer {
BlobViewer.loadViewer(auxiliaryViewer); BlobViewer.loadViewer(auxiliaryViewer);
} }
static initRichViewer() {
const viewer = document.querySelector('.blob-viewer[data-type="rich"]');
if (!viewer || !viewer.dataset.richType) return;
const initViewer = promise =>
promise
.then(module => module.default(viewer))
.catch(error => {
Flash(__('Error loading file viewer.'));
throw error;
});
switch (viewer.dataset.richType) {
case 'balsamiq':
initViewer(import(/* webpackChunkName: 'balsamiq_viewer' */ '../balsamiq_viewer'));
break;
case 'notebook':
initViewer(import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer'));
break;
case 'openapi':
initViewer(import(/* webpackChunkName: 'openapi_viewer' */ '../openapi_viewer'));
break;
case 'pdf':
initViewer(import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer'));
break;
case 'sketch':
initViewer(import(/* webpackChunkName: 'sketch_viewer' */ '../sketch_viewer'));
break;
case 'stl':
initViewer(import(/* webpackChunkName: 'stl_viewer' */ '../stl_viewer'));
break;
default:
break;
}
}
initMainViewers() { initMainViewers() {
this.$fileHolder = $('.file-holder'); this.$fileHolder = $('.file-holder');
if (!this.$fileHolder.length) return; if (!this.$fileHolder.length) return;
......
...@@ -4,5 +4,9 @@ export default { ...@@ -4,5 +4,9 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
type: {
type: String,
required: true,
},
}, },
}; };
<script> <script>
import ViewerMixin from './mixins'; import ViewerMixin from './mixins';
import { handleBlobRichViewer } from '~/blob/viewer';
export default { export default {
mixins: [ViewerMixin], mixins: [ViewerMixin],
mounted() {
handleBlobRichViewer(this.$refs.content, this.type);
},
}; };
</script> </script>
<template> <template>
<div v-html="content"></div> <div ref="content" v-html="content"></div>
</template> </template>
...@@ -27,7 +27,12 @@ export default { ...@@ -27,7 +27,12 @@ export default {
<span :style="labelStyle" class="badge color-label"> <span :style="labelStyle" class="badge color-label">
{{ label.title }} {{ label.title }}
</span> </span>
<gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport"> <gl-tooltip
v-if="label.description"
:target="() => $refs.regularLabelRef"
placement="top"
boundary="viewport"
>
{{ label.description }} {{ label.description }}
</gl-tooltip> </gl-tooltip>
</a> </a>
......
...@@ -33,7 +33,12 @@ export default { ...@@ -33,7 +33,12 @@ export default {
<span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label"> <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label">
{{ label.title }} {{ label.title }}
</span> </span>
<gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport"> <gl-tooltip
v-if="label.description"
:target="() => $refs.labelTitleRef"
placement="top"
boundary="viewport"
>
<span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span
><br /> ><br />
{{ label.description }} {{ label.description }}
......
<script>
import { mapGetters } from 'vuex';
import { GlButton, GlIcon } from '@gitlab/ui';
export default {
components: {
GlButton,
GlIcon,
},
computed: {
...mapGetters(['dropdownButtonText']),
},
};
</script>
<template>
<gl-button class="labels-select-dropdown-button w-100 text-left">
<span class="dropdown-toggle-text">{{ dropdownButtonText }}</span>
<gl-icon name="chevron-down" class="pull-right" />
</gl-button>
</template>
<script>
import { mapState } from 'vuex';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
export default {
components: {
DropdownContentsLabelsView,
DropdownContentsCreateView,
},
computed: {
...mapState(['showDropdownContentsCreateView']),
dropdownContentsView() {
if (this.showDropdownContentsCreateView) {
return 'dropdown-contents-create-view';
}
return 'dropdown-contents-labels-view';
},
},
};
</script>
<template>
<div
class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute"
>
<component :is="dropdownContentsView" />
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import {
GlTooltipDirective,
GlButton,
GlIcon,
GlFormInput,
GlLink,
GlLoadingIcon,
} from '@gitlab/ui';
export default {
components: {
GlButton,
GlIcon,
GlFormInput,
GlLink,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
data() {
return {
labelTitle: '',
selectedColor: '',
};
},
computed: {
...mapState(['labelsCreateTitle', 'labelCreateInProgress']),
disableCreate() {
return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress;
},
suggestedColors() {
const colorsMap = gon.suggested_label_colors;
return Object.keys(colorsMap).map(color => ({ [color]: colorsMap[color] }));
},
},
methods: {
...mapActions(['toggleDropdownContents', 'toggleDropdownContentsCreateView', 'createLabel']),
getColorCode(color) {
return Object.keys(color).pop();
},
getColorName(color) {
return Object.values(color).pop();
},
handleColorClick(color) {
this.selectedColor = this.getColorCode(color);
},
handleCreateClick() {
this.createLabel({
title: this.labelTitle,
color: this.selectedColor,
});
},
},
};
</script>
<template>
<div class="labels-select-contents-create">
<div class="dropdown-title d-flex align-items-center pt-0 pb-2">
<gl-button
:aria-label="__('Go back')"
variant="link"
size="sm"
class="dropdown-header-button p-0"
@click="toggleDropdownContentsCreateView"
>
<gl-icon name="arrow-left" />
</gl-button>
<span class="flex-grow-1">{{ labelsCreateTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="sm"
class="dropdown-header-button p-0"
@click="toggleDropdownContents"
>
<gl-icon name="close" />
</gl-button>
</div>
<div class="dropdown-input">
<gl-form-input
v-model.trim="labelTitle"
:placeholder="__('Name new label')"
:autofocus="true"
/>
</div>
<div class="dropdown-content px-2">
<div class="suggest-colors suggest-colors-dropdown mt-0 mb-2">
<gl-link
v-for="(color, index) in suggestedColors"
:key="index"
v-gl-tooltip:tooltipcontainer
:style="{ backgroundColor: getColorCode(color) }"
:title="getColorName(color)"
@click.prevent="handleColorClick(color)"
/>
</div>
<div class="color-input-container d-flex">
<span
class="dropdown-label-color-preview position-relative position-relative d-inline-block"
:style="{ backgroundColor: selectedColor }"
></span>
<gl-form-input v-model.trim="selectedColor" :placeholder="__('Use custom color #FF0000')" />
</div>
</div>
<div class="dropdown-actions clearfix pt-2 px-2">
<gl-button
:disabled="disableCreate"
variant="primary"
class="pull-left d-flex align-items-center"
@click="handleCreateClick"
>
<gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
{{ __('Create') }}
</gl-button>
<gl-button class="pull-right" @click="toggleDropdownContentsCreateView">
{{ __('Cancel') }}
</gl-button>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
export default {
components: {
GlLoadingIcon,
GlButton,
GlIcon,
GlSearchBoxByType,
GlLink,
},
data() {
return {
searchKey: '',
currentHighlightItem: -1,
};
},
computed: {
...mapState([
'labelsManagePath',
'labels',
'labelsFetchInProgress',
'labelsListTitle',
'footerCreateLabelTitle',
'footerManageLabelTitle',
]),
...mapGetters(['selectedLabelsList']),
visibleLabels() {
if (this.searchKey) {
return this.labels.filter(label =>
label.title.toLowerCase().includes(this.searchKey.toLowerCase()),
);
}
return this.labels;
},
},
watch: {
searchKey(value) {
// When there is search string present
// and there are matching results,
// highlight first item by default.
if (value && this.visibleLabels.length) {
this.currentHighlightItem = 0;
}
},
},
mounted() {
this.fetchLabels();
},
methods: {
...mapActions([
'toggleDropdownContents',
'toggleDropdownContentsCreateView',
'fetchLabels',
'updateSelectedLabels',
]),
getDropdownLabelBoxStyle(label) {
return {
backgroundColor: label.color,
};
},
isLabelSelected(label) {
return this.selectedLabelsList.includes(label.id);
},
/**
* This method scrolls item from dropdown into
* the view if it is off the viewable area of the
* container.
*/
scrollIntoViewIfNeeded() {
const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused');
if (highlightedLabel) {
const rect = highlightedLabel.getBoundingClientRect();
if (rect.bottom > this.$refs.labelsListContainer.clientHeight) {
highlightedLabel.scrollIntoView(false);
}
if (rect.top < 0) {
highlightedLabel.scrollIntoView();
}
}
},
/**
* This method enables keyboard navigation support for
* the dropdown.
*/
handleKeyDown(e) {
if (e.keyCode === UP_KEY_CODE && this.currentHighlightItem > 0) {
this.currentHighlightItem -= 1;
} else if (
e.keyCode === DOWN_KEY_CODE &&
this.currentHighlightItem < this.visibleLabels.length - 1
) {
this.currentHighlightItem += 1;
} else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]);
} else if (e.keyCode === ESC_KEY_CODE) {
this.toggleDropdownContents();
}
if (e.keyCode !== ESC_KEY_CODE) {
// Scroll the list only after highlighting
// styles are rendered completely.
this.$nextTick(() => {
this.scrollIntoViewIfNeeded();
});
}
},
handleLabelClick(label) {
this.updateSelectedLabels([label]);
},
},
};
</script>
<template>
<div class="labels-select-contents-list" @keydown="handleKeyDown">
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100"
size="md"
/>
<div class="dropdown-title d-flex align-items-center pt-0 pb-2">
<span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="sm"
class="dropdown-header-button p-0"
@click="toggleDropdownContents"
>
<gl-icon name="close" />
</gl-button>
</div>
<div class="dropdown-input">
<gl-search-box-by-type v-model="searchKey" :autofocus="true" />
</div>
<div v-if="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
<ul class="list-unstyled mb-0">
<li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left">
<gl-link
class="d-flex align-items-baseline text-break-word label-item"
:class="{ 'is-focused': index === currentHighlightItem }"
@click="handleLabelClick(label)"
>
<gl-icon v-show="label.set" name="mobile-issue-close" class="mr-2 align-self-center" />
<span v-show="!label.set" class="mr-3 pr-2"></span>
<span class="dropdown-label-box" :style="getDropdownLabelBoxStyle(label)"></span>
<span>{{ label.title }}</span>
</gl-link>
</li>
<li v-if="!visibleLabels.length" class="p-2 text-center">
{{ __('No matching results') }}
</li>
</ul>
</div>
<div class="dropdown-footer">
<ul class="list-unstyled">
<li>
<gl-button
variant="link"
class="d-flex w-100 flex-row text-break-word label-item"
@click="toggleDropdownContentsCreateView"
>{{ footerCreateLabelTitle }}</gl-button
>
</li>
<li>
<gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">
{{ footerManageLabelTitle }}
</gl-link>
</li>
</ul>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlButton,
GlLoadingIcon,
},
props: {
labelsSelectInProgress: {
type: Boolean,
required: true,
},
},
computed: {
...mapState(['allowLabelEdit', 'labelsFetchInProgress']),
},
methods: {
...mapActions(['toggleDropdownContents']),
},
};
</script>
<template>
<div class="title hide-collapsed append-bottom-10">
{{ __('Labels') }}
<template v-if="allowLabelEdit">
<gl-loading-icon v-show="labelsSelectInProgress" inline />
<gl-button
variant="link"
class="pull-right js-sidebar-dropdown-toggle"
data-qa-selector="labels_edit_button"
@click="toggleDropdownContents"
>{{ __('Edit') }}</gl-button
>
</template>
</div>
</template>
<script>
import { mapState } from 'vuex';
import { GlLabel } from '@gitlab/ui';
import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
components: {
GlLabel,
},
computed: {
...mapState([
'selectedLabels',
'allowScopedLabels',
'labelsFilterBasePath',
'scopedLabelsDocumentationPath',
]),
},
methods: {
labelFilterUrl(label) {
return `${this.labelsFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
},
scopedLabel(label) {
return this.allowScopedLabels && isScopedLabel(label);
},
},
};
</script>
<template>
<div
:class="{
'has-labels': selectedLabels.length,
}"
class="hide-collapsed value issuable-show-labels js-value"
>
<span v-if="!selectedLabels.length" class="text-secondary">
<slot></slot>
</span>
<template v-for="label in selectedLabels" v-else>
<gl-label
:key="label.id"
:title="label.title"
:description="label.description"
:background-color="label.color"
:target="labelFilterUrl(label)"
:scoped="scopedLabel(label)"
:scoped-labels-documentation-link="scopedLabelsDocumentationPath"
tooltip-placement="top"
/>
</template>
</div>
</template>
<script>
import Vue from 'vue';
import Vuex, { mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
import labelsSelectModule from './store';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
Vue.use(Vuex);
export default {
store: new Vuex.Store(labelsSelectModule()),
components: {
DropdownTitle,
DropdownValue,
DropdownButton,
DropdownContents,
DropdownValueCollapsed,
},
props: {
allowLabelEdit: {
type: Boolean,
required: true,
},
allowLabelCreate: {
type: Boolean,
required: true,
},
allowScopedLabels: {
type: Boolean,
required: true,
},
dropdownOnly: {
type: Boolean,
required: false,
default: false,
},
selectedLabels: {
type: Array,
required: false,
default: () => [],
},
labelsSelectInProgress: {
type: Boolean,
required: false,
default: false,
},
labelsFetchPath: {
type: String,
required: false,
default: '',
},
labelsManagePath: {
type: String,
required: false,
default: '',
},
labelsFilterBasePath: {
type: String,
required: false,
default: '',
},
scopedLabelsDocumentationPath: {
type: String,
required: false,
default: '',
},
labelsListTitle: {
type: String,
required: false,
default: __('Assign labels'),
},
labelsCreateTitle: {
type: String,
required: false,
default: __('Create group label'),
},
footerCreateLabelTitle: {
type: String,
required: false,
default: __('Create group label'),
},
footerManageLabelTitle: {
type: String,
required: false,
default: __('Manage group labels'),
},
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
},
watch: {
selectedLabels(selectedLabels) {
this.setInitialState({
selectedLabels,
});
},
},
mounted() {
this.setInitialState({
dropdownOnly: this.dropdownOnly,
allowLabelEdit: this.allowLabelEdit,
allowLabelCreate: this.allowLabelCreate,
allowScopedLabels: this.allowScopedLabels,
selectedLabels: this.selectedLabels,
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
labelsFilterBasePath: this.labelsFilterBasePath,
scopedLabelsDocumentationPath: this.scopedLabelsDocumentationPath,
labelsListTitle: this.labelsListTitle,
labelsCreateTitle: this.labelsCreateTitle,
footerCreateLabelTitle: this.footerCreateLabelTitle,
footerManageLabelTitle: this.footerManageLabelTitle,
});
this.$store.subscribeAction({
after: this.handleVuexActionDispatch,
});
},
methods: {
...mapActions(['setInitialState']),
/**
* This method differentiates between
* dispatched actions and calls necessary method.
*/
handleVuexActionDispatch(action, state) {
if (
action.type === 'toggleDropdownContents' &&
!state.showDropdownButton &&
!state.showDropdownContents
) {
this.handleDropdownClose(state.labels.filter(label => label.touched));
}
},
handleDropdownClose(labels) {
// Only emit label updates if there are any labels to update
// on UI.
if (labels.length) this.$emit('updateSelectedLabels', labels);
this.$emit('onDropdownClose');
},
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
},
},
};
</script>
<template>
<div class="labels-select-wrapper position-relative">
<div v-if="!dropdownOnly">
<dropdown-value-collapsed
v-if="allowLabelCreate"
:labels="selectedLabels"
@onValueClick="handleCollapsedValueClick"
/>
<dropdown-title
:allow-label-edit="allowLabelEdit"
:labels-select-in-progress="labelsSelectInProgress"
/>
<dropdown-value v-show="!showDropdownButton">
<slot></slot>
</dropdown-value>
<dropdown-button v-show="showDropdownButton" />
<dropdown-contents v-if="showDropdownButton && showDropdownContents" />
</div>
</div>
</template>
import flash from '~/flash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props);
export const toggleDropdownButton = ({ commit }) => commit(types.TOGGLE_DROPDOWN_BUTTON);
export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS);
export const toggleDropdownContentsCreateView = ({ commit }) =>
commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW);
export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS);
export const receiveLabelsSuccess = ({ commit }, labels) =>
commit(types.RECEIVE_SET_LABELS_SUCCESS, labels);
export const receiveLabelsFailure = ({ commit }) => {
commit(types.RECEIVE_SET_LABELS_FAILURE);
flash(__('Error fetching labels.'));
};
export const fetchLabels = ({ state, dispatch }) => {
dispatch('requestLabels');
axios
.get(state.labelsFetchPath)
.then(({ data }) => {
dispatch('receiveLabelsSuccess', data);
})
.catch(() => dispatch('receiveLabelsFailure'));
};
export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LABEL);
export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS);
export const receiveCreateLabelFailure = ({ commit }) => {
commit(types.RECEIVE_CREATE_LABEL_FAILURE);
flash(__('Error creating label.'));
};
export const createLabel = ({ state, dispatch }, label) => {
dispatch('requestCreateLabel');
axios
.post(state.labelsManagePath, {
label,
})
.then(({ data }) => {
if (data.id) {
dispatch('receiveCreateLabelSuccess');
dispatch('toggleDropdownContentsCreateView');
} else {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
throw new Error('Error Creating Label');
}
})
.catch(() => {
dispatch('receiveCreateLabelFailure');
});
};
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import { __, s__, sprintf } from '~/locale';
/**
* Returns string representing current labels
* selection on dropdown button.
*
* @param {object} state
*/
export const dropdownButtonText = state => {
const selectedLabels = state.labels.filter(label => label.set);
if (!selectedLabels.length) {
return __('Label');
} else if (selectedLabels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: selectedLabels[0].title,
remainingLabelCount: selectedLabels.length - 1,
});
}
return selectedLabels[0].title;
};
/**
* Returns array containing only label IDs from
* selectedLabels array.
* @param {object} state
*/
export const selectedLabelsList = state => state.selectedLabels.map(label => label.id);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default () => ({
namespaced: true,
state: state(),
actions,
getters,
mutations,
});
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const REQUEST_LABELS = 'REQUEST_LABELS';
export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS';
export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS';
export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE';
export const REQUEST_CREATE_LABEL = 'REQUEST_CREATE_LABEL';
export const RECEIVE_CREATE_LABEL_SUCCESS = 'RECEIVE_CREATE_LABEL_SUCCESS';
export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_STATE](state, props) {
Object.assign(state, { ...props });
},
[types.TOGGLE_DROPDOWN_BUTTON](state) {
state.showDropdownButton = !state.showDropdownButton;
},
[types.TOGGLE_DROPDOWN_CONTENTS](state) {
if (!state.dropdownOnly) {
state.showDropdownButton = !state.showDropdownButton;
}
state.showDropdownContents = !state.showDropdownContents;
// Ensure that Create View is hidden by default
// when dropdown contents are revealed.
if (state.showDropdownContents) {
state.showDropdownContentsCreateView = false;
}
},
[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) {
state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView;
},
[types.REQUEST_LABELS](state) {
state.labelsFetchInProgress = true;
},
[types.RECEIVE_SET_LABELS_SUCCESS](state, labels) {
// Iterate over every label and add a `set` prop
// to determine whether it is already a part of
// selectedLabels array.
const selectedLabelIds = state.selectedLabels.map(label => label.id);
state.labelsFetchInProgress = false;
state.labels = labels.reduce((allLabels, label) => {
allLabels.push({
...label,
set: selectedLabelIds.includes(label.id),
});
return allLabels;
}, []);
},
[types.RECEIVE_SET_LABELS_FAILURE](state) {
state.labelsFetchInProgress = false;
},
[types.REQUEST_CREATE_LABEL](state) {
state.labelCreateInProgress = true;
},
[types.RECEIVE_CREATE_LABEL_SUCCESS](state) {
state.labelCreateInProgress = false;
},
[types.RECEIVE_CREATE_LABEL_FAILURE](state) {
state.labelCreateInProgress = false;
},
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
// Iterate over all the labels and update
// `set` prop value to represent their current state.
const labelIds = labels.map(label => label.id);
state.labels = state.labels.reduce((allLabels, label) => {
if (labelIds.includes(label.id)) {
allLabels.push({
...label,
touched: true,
set: !label.set,
});
} else {
allLabels.push(label);
}
return allLabels;
}, []);
},
};
export default () => ({
// Initial Data
labels: [],
selectedLabels: [],
labelsListTitle: '',
labelsCreateTitle: '',
footerCreateLabelTitle: '',
footerManageLabelTitle: '',
// Paths
namespace: '',
labelsFetchPath: '',
labelsFilterBasePath: '',
scopedLabelsDocumentationPath: '#',
// UI Flags
allowLabelCreate: false,
allowLabelEdit: false,
allowScopedLabels: false,
dropdownOnly: false,
showDropdownButton: false,
showDropdownContents: false,
showDropdownContentsCreateView: false,
labelsFetchInProgress: false,
labelCreateInProgress: false,
selectedLabelsUpdated: false,
});
...@@ -1019,3 +1019,54 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { ...@@ -1019,3 +1019,54 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
opacity: 0; opacity: 0;
} }
} }
.labels-select-wrapper {
.labels-select-dropdown-contents {
min-height: $dropdown-min-height;
max-height: 330px;
background-color: $white-light;
border: 1px solid $border-color;
box-shadow: 0 2px 4px $dropdown-shadow-color;
z-index: 2;
.dropdown-content {
height: 135px;
}
}
.labels-fetch-loading {
top: 0;
left: 0;
opacity: 0.5;
background-color: $white-light;
z-index: 1;
}
.dropdown-header-button {
.gl-icon {
color: $dropdown-title-btn-color;
&:hover {
color: $gl-gray-400;
}
}
}
.label-item {
padding: 8px 20px;
&:hover,
&.is-focused {
@include dropdown-item-hover;
text-decoration: none;
}
}
.color-input-container {
.dropdown-label-color-preview {
border: 1px solid $gray-200;
border-right: 0;
}
}
}
...@@ -281,11 +281,10 @@ class Snippet < ApplicationRecord ...@@ -281,11 +281,10 @@ class Snippet < ApplicationRecord
end end
def create_repository def create_repository
return if repository_exists? return if repository_exists? && snippet_repository
repository.create_if_not_exists repository.create_if_not_exists
track_snippet_repository
track_snippet_repository if repository_exists?
end end
def track_snippet_repository def track_snippet_repository
......
...@@ -4,6 +4,9 @@ module Snippets ...@@ -4,6 +4,9 @@ module Snippets
class UpdateService < Snippets::BaseService class UpdateService < Snippets::BaseService
include SpamCheckMethods include SpamCheckMethods
UpdateError = Class.new(StandardError)
CreateRepositoryError = Class.new(StandardError)
def execute(snippet) def execute(snippet)
# check that user is allowed to set specified visibility_level # check that user is allowed to set specified visibility_level
new_visibility = visibility_level new_visibility = visibility_level
...@@ -20,11 +23,7 @@ module Snippets ...@@ -20,11 +23,7 @@ module Snippets
snippet.assign_attributes(params) snippet.assign_attributes(params)
spam_check(snippet, current_user) spam_check(snippet, current_user)
snippet_saved = snippet.with_transaction_returning_status do if save_and_commit(snippet)
snippet.save
end
if snippet_saved
Gitlab::UsageDataCounters::SnippetCounter.count(:update) Gitlab::UsageDataCounters::SnippetCounter.count(:update)
ServiceResponse.success(payload: { snippet: snippet } ) ServiceResponse.success(payload: { snippet: snippet } )
...@@ -32,5 +31,54 @@ module Snippets ...@@ -32,5 +31,54 @@ module Snippets
snippet_error_response(snippet, 400) snippet_error_response(snippet, 400)
end end
end end
private
def save_and_commit(snippet)
snippet.with_transaction_returning_status do
snippet.save.tap do |saved|
break false unless saved
# In order to avoid non migrated snippets scenarios,
# if the snippet does not have a repository we created it
# We don't need to check if the repository exists
# because `create_repository` already handles it
if Feature.enabled?(:version_snippets, current_user)
create_repository_for(snippet)
end
# If the snippet repository exists we commit always
# the changes
create_commit(snippet) if snippet.repository_exists?
end
rescue
snippet.errors.add(:base, 'Error updating the snippet')
false
end
end
def create_repository_for(snippet)
snippet.create_repository
raise CreateRepositoryError, 'Repository could not be created' unless snippet.repository_exists?
end
def create_commit(snippet)
raise UpdateError unless snippet.snippet_repository
commit_attrs = {
branch_name: 'master',
message: 'Update snippet'
}
snippet.snippet_repository.multi_files_action(current_user, snippet_files(snippet), commit_attrs)
end
def snippet_files(snippet)
[{ previous_path: snippet.blobs.first&.path,
file_path: params[:file_name],
content: params[:content] }]
end
end end
end end
---
title: Special handling for the rich viewer on specific file types
merge_request: 26260
author:
type: changed
---
title: Update files when snippet is updated
merge_request: 23993
author:
type: changed
---
title: Make design_management_versions.created_at not null
merge_request: 20182
author: Lee Tickett
type: other
# frozen_string_literal: true
class MakeCreatedAtNotNullInDesignManagementVersions < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
change_column_null :design_management_versions, :created_at, false, Time.now.to_s(:db)
end
def down
change_column_null :design_management_versions, :created_at, true
end
end
...@@ -1446,7 +1446,7 @@ ActiveRecord::Schema.define(version: 2020_03_03_074328) do ...@@ -1446,7 +1446,7 @@ ActiveRecord::Schema.define(version: 2020_03_03_074328) do
create_table "design_management_versions", force: :cascade do |t| create_table "design_management_versions", force: :cascade do |t|
t.binary "sha", null: false t.binary "sha", null: false
t.bigint "issue_id" t.bigint "issue_id"
t.datetime_with_timezone "created_at" t.datetime_with_timezone "created_at", null: false
t.integer "author_id" t.integer "author_id"
t.index ["author_id"], name: "index_design_management_versions_on_author_id", where: "(author_id IS NOT NULL)" t.index ["author_id"], name: "index_design_management_versions_on_author_id", where: "(author_id IS NOT NULL)"
t.index ["issue_id"], name: "index_design_management_versions_on_issue_id" t.index ["issue_id"], name: "index_design_management_versions_on_issue_id"
......
...@@ -71,12 +71,6 @@ the need as part of the product in a future version of GitLab! ...@@ -71,12 +71,6 @@ the need as part of the product in a future version of GitLab!
Implement each task as an isolated piece of functionality and place it in its Implement each task as an isolated piece of functionality and place it in its
own directory under `danger` as `danger/<task-name>/Dangerfile`. own directory under `danger` as `danger/<task-name>/Dangerfile`.
Add a line to the top-level `Dangerfile` to ensure it is loaded like:
```ruby
danger.import_dangerfile('danger/<task-name>')
```
Each task should be isolated from the others, and able to function in isolation. Each task should be isolated from the others, and able to function in isolation.
If there is code that should be shared between multiple tasks, add a plugin to If there is code that should be shared between multiple tasks, add a plugin to
`danger/plugins/...` and require it in each task that needs it. You can also `danger/plugins/...` and require it in each task that needs it. You can also
......
...@@ -7778,6 +7778,9 @@ msgstr "" ...@@ -7778,6 +7778,9 @@ msgstr ""
msgid "Error creating epic" msgid "Error creating epic"
msgstr "" msgstr ""
msgid "Error creating label."
msgstr ""
msgid "Error deleting %{issuableType}" msgid "Error deleting %{issuableType}"
msgstr "" msgstr ""
...@@ -21315,6 +21318,9 @@ msgstr "" ...@@ -21315,6 +21318,9 @@ msgstr ""
msgid "Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)." msgid "Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)."
msgstr "" msgstr ""
msgid "Use custom color #FF0000"
msgstr ""
msgid "Use group milestones to manage issues from multiple projects in the same milestone." msgid "Use group milestones to manage issues from multiple projects in the same milestone."
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue'; import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue';
import { handleBlobRichViewer } from '~/blob/viewer';
jest.mock('~/blob/viewer');
describe('Blob Rich Viewer component', () => { describe('Blob Rich Viewer component', () => {
let wrapper; let wrapper;
const content = '<h1 id="markdown">Foo Bar</h1>'; const content = '<h1 id="markdown">Foo Bar</h1>';
const defaultType = 'markdown';
function createComponent() { function createComponent(type = defaultType) {
wrapper = shallowMount(RichViewer, { wrapper = shallowMount(RichViewer, {
propsData: { propsData: {
content, content,
type,
}, },
}); });
} }
...@@ -24,4 +29,8 @@ describe('Blob Rich Viewer component', () => { ...@@ -24,4 +29,8 @@ describe('Blob Rich Viewer component', () => {
it('renders the passed content without transformations', () => { it('renders the passed content without transformations', () => {
expect(wrapper.html()).toContain(content); expect(wrapper.html()).toContain(content);
}); });
it('queries for advanced viewer', () => {
expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType);
});
}); });
...@@ -10,6 +10,7 @@ describe('Blob Simple Viewer component', () => { ...@@ -10,6 +10,7 @@ describe('Blob Simple Viewer component', () => {
wrapper = shallowMount(SimpleViewer, { wrapper = shallowMount(SimpleViewer, {
propsData: { propsData: {
content, content,
type: 'text',
}, },
}); });
} }
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue';
import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownButton, {
localVue,
store,
});
};
describe('DropdownButton', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders component container element', () => {
expect(wrapper.is('gl-button-stub')).toBe(true);
});
it('renders button text element', () => {
const dropdownTextEl = wrapper.find('.dropdown-toggle-text');
expect(dropdownTextEl.exists()).toBe(true);
expect(dropdownTextEl.text()).toBe('Label');
});
it('renders chevron icon element', () => {
const iconEl = wrapper.find(GlIcon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.props('name')).toBe('chevron-down');
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlIcon, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue';
import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig, mockSuggestedColors } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownContentsCreateView, {
localVue,
store,
});
};
describe('DropdownContentsCreateView', () => {
let wrapper;
const colors = Object.keys(mockSuggestedColors).map(color => ({
[color]: mockSuggestedColors[color],
}));
beforeEach(() => {
gon.suggested_label_colors = mockSuggestedColors;
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('disableCreate', () => {
it('returns `true` when label title and color is not defined', () => {
expect(wrapper.vm.disableCreate).toBe(true);
});
it('returns `true` when `labelCreateInProgress` is true', () => {
wrapper.setData({
labelTitle: 'Foo',
selectedColor: '#ff0000',
});
wrapper.vm.$store.dispatch('requestCreateLabel');
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.disableCreate).toBe(true);
});
});
it('returns `false` when label title and color is defined and create request is not already in progress', () => {
wrapper.setData({
labelTitle: 'Foo',
selectedColor: '#ff0000',
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.disableCreate).toBe(false);
});
});
});
describe('suggestedColors', () => {
it('returns array of color objects containing color code and name', () => {
colors.forEach((color, index) => {
expect(wrapper.vm.suggestedColors[index]).toEqual(expect.objectContaining(color));
});
});
});
});
describe('methods', () => {
describe('getColorCode', () => {
it('returns color code from color object', () => {
expect(wrapper.vm.getColorCode(colors[0])).toBe(Object.keys(colors[0]).pop());
});
});
describe('getColorName', () => {
it('returns color name from color object', () => {
expect(wrapper.vm.getColorName(colors[0])).toBe(Object.values(colors[0]).pop());
});
});
describe('handleColorClick', () => {
it('sets provided `color` param to `selectedColor` prop', () => {
wrapper.vm.handleColorClick(colors[0]);
expect(wrapper.vm.selectedColor).toBe(Object.keys(colors[0]).pop());
});
});
describe('handleCreateClick', () => {
it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', () => {
jest.spyOn(wrapper.vm, 'createLabel').mockImplementation();
wrapper.setData({
labelTitle: 'Foo',
selectedColor: '#ff0000',
});
wrapper.vm.handleCreateClick();
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.createLabel).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Foo',
color: '#ff0000',
}),
);
});
});
});
});
describe('template', () => {
it('renders component container element with class "labels-select-contents-create"', () => {
expect(wrapper.attributes('class')).toContain('labels-select-contents-create');
});
it('renders dropdown back button element', () => {
const backBtnEl = wrapper
.find('.dropdown-title')
.findAll(GlButton)
.at(0);
expect(backBtnEl.exists()).toBe(true);
expect(backBtnEl.attributes('aria-label')).toBe('Go back');
expect(backBtnEl.find(GlIcon).props('name')).toBe('arrow-left');
});
it('renders dropdown title element', () => {
const headerEl = wrapper.find('.dropdown-title > span');
expect(headerEl.exists()).toBe(true);
expect(headerEl.text()).toBe('Create label');
});
it('renders dropdown close button element', () => {
const closeBtnEl = wrapper
.find('.dropdown-title')
.findAll(GlButton)
.at(1);
expect(closeBtnEl.exists()).toBe(true);
expect(closeBtnEl.attributes('aria-label')).toBe('Close');
expect(closeBtnEl.find(GlIcon).props('name')).toBe('close');
});
it('renders label title input element', () => {
const titleInputEl = wrapper.find('.dropdown-input').find(GlFormInput);
expect(titleInputEl.exists()).toBe(true);
expect(titleInputEl.attributes('placeholder')).toBe('Name new label');
expect(titleInputEl.attributes('autofocus')).toBe('true');
});
it('renders color block element for all suggested colors', () => {
const colorBlocksEl = wrapper.find('.dropdown-content').findAll(GlLink);
colorBlocksEl.wrappers.forEach((colorBlock, index) => {
expect(colorBlock.attributes('style')).toContain('background-color');
expect(colorBlock.attributes('title')).toBe(Object.values(colors[index]).pop());
});
});
it('renders color input element', () => {
wrapper.setData({
selectedColor: '#ff0000',
});
return wrapper.vm.$nextTick(() => {
const colorPreviewEl = wrapper.find(
'.color-input-container > .dropdown-label-color-preview',
);
const colorInputEl = wrapper.find('.color-input-container').find(GlFormInput);
expect(colorPreviewEl.exists()).toBe(true);
expect(colorPreviewEl.attributes('style')).toContain('background-color');
expect(colorInputEl.exists()).toBe(true);
expect(colorInputEl.attributes('placeholder')).toBe('Use custom color #FF0000');
expect(colorInputEl.attributes('value')).toBe('#ff0000');
});
});
it('renders create button element', () => {
const createBtnEl = wrapper
.find('.dropdown-actions')
.findAll(GlButton)
.at(0);
expect(createBtnEl.exists()).toBe(true);
expect(createBtnEl.text()).toContain('Create');
});
it('shows gl-loading-icon within create button element when `labelCreateInProgress` is `true`', () => {
wrapper.vm.$store.dispatch('requestCreateLabel');
return wrapper.vm.$nextTick(() => {
const loadingIconEl = wrapper.find('.dropdown-actions').find(GlLoadingIcon);
expect(loadingIconEl.exists()).toBe(true);
expect(loadingIconEl.isVisible()).toBe(true);
});
});
it('renders cancel button element', () => {
const cancelBtnEl = wrapper
.find('.dropdown-actions')
.findAll(GlButton)
.at(1);
expect(cancelBtnEl.exists()).toBe(true);
expect(cancelBtnEl.text()).toContain('Cancel');
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlLoadingIcon, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue';
import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state';
import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations';
import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions';
import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/store/getters';
import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store({
getters,
mutations,
state: {
...defaultState(),
footerCreateLabelTitle: 'Create label',
footerManageLabelTitle: 'Manage labels',
},
actions: {
...actions,
fetchLabels: jest.fn(),
},
});
store.dispatch('setInitialState', initialState);
store.dispatch('receiveLabelsSuccess', mockLabels);
return shallowMount(DropdownContentsLabelsView, {
localVue,
store,
});
};
describe('DropdownContentsLabelsView', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('visibleLabels', () => {
it('returns matching labels filtered with `searchKey`', () => {
wrapper.setData({
searchKey: 'bug',
});
expect(wrapper.vm.visibleLabels.length).toBe(1);
expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
});
it('returns all labels when `searchKey` is empty', () => {
wrapper.setData({
searchKey: '',
});
expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length);
});
});
});
describe('methods', () => {
describe('getDropdownLabelBoxStyle', () => {
it('returns an object containing `backgroundColor` based on provided `label` param', () => {
expect(wrapper.vm.getDropdownLabelBoxStyle(mockRegularLabel)).toEqual(
expect.objectContaining({
backgroundColor: mockRegularLabel.color,
}),
);
});
});
describe('isLabelSelected', () => {
it('returns true when provided `label` param is one of the selected labels', () => {
expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
});
it('returns false when provided `label` param is not one of the selected labels', () => {
expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false);
});
});
describe('handleKeyDown', () => {
it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: UP_KEY_CODE,
});
expect(wrapper.vm.currentHighlightItem).toBe(0);
});
it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => {
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: DOWN_KEY_CODE,
});
expect(wrapper.vm.currentHighlightItem).toBe(2);
});
it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: ENTER_KEY_CODE,
});
expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([
{
...mockLabels[1],
set: true,
},
]);
});
it('calls action `toggleDropdownContents` when Esc key is pressed', () => {
jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation();
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: ESC_KEY_CODE,
});
expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
});
it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => {
jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation();
wrapper.setData({
currentHighlightItem: 1,
});
wrapper.vm.handleKeyDown({
keyCode: DOWN_KEY_CODE,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled();
});
});
});
describe('handleLabelClick', () => {
it('calls action `updateSelectedLabels` with provided `label` param', () => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
wrapper.vm.handleLabelClick(mockRegularLabel);
expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
});
});
});
describe('template', () => {
it('renders component container element with class `labels-select-contents-list`', () => {
expect(wrapper.attributes('class')).toContain('labels-select-contents-list');
});
it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
wrapper.vm.$store.dispatch('requestLabels');
return wrapper.vm.$nextTick(() => {
const loadingIconEl = wrapper.find(GlLoadingIcon);
expect(loadingIconEl.exists()).toBe(true);
expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading');
});
});
it('renders dropdown title element', () => {
const titleEl = wrapper.find('.dropdown-title > span');
expect(titleEl.exists()).toBe(true);
expect(titleEl.text()).toBe('Assign labels');
});
it('renders dropdown close button element', () => {
const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton);
expect(closeButtonEl.exists()).toBe(true);
expect(closeButtonEl.find(GlIcon).exists()).toBe(true);
expect(closeButtonEl.find(GlIcon).props('name')).toBe('close');
});
it('renders label search input element', () => {
const searchInputEl = wrapper.find(GlSearchBoxByType);
expect(searchInputEl.exists()).toBe(true);
expect(searchInputEl.attributes('autofocus')).toBe('true');
});
it('renders label elements for all labels', () => {
const labelsEl = wrapper.findAll('.dropdown-content li');
const labelItemEl = labelsEl.at(0).find(GlLink);
expect(labelsEl.length).toBe(mockLabels.length);
expect(labelItemEl.exists()).toBe(true);
expect(labelItemEl.find(GlIcon).props('name')).toBe('mobile-issue-close');
expect(labelItemEl.find('.dropdown-label-box').attributes('style')).toBe(
'background-color: rgb(186, 218, 85);',
);
expect(labelItemEl.find(GlLink).text()).toContain(mockLabels[0].title);
});
it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => {
wrapper.setData({
currentHighlightItem: 0,
});
return wrapper.vm.$nextTick(() => {
const labelsEl = wrapper.findAll('.dropdown-content li');
const labelItemEl = labelsEl.at(0).find(GlLink);
expect(labelItemEl.attributes('class')).toContain('is-focused');
});
});
it('renders element containing "No matching results" when `searchKey` does not match with any label', () => {
wrapper.setData({
searchKey: 'abc',
});
return wrapper.vm.$nextTick(() => {
const noMatchEl = wrapper.find('.dropdown-content li');
expect(noMatchEl.exists()).toBe(true);
expect(noMatchEl.text()).toContain('No matching results');
});
});
it('renders footer list items', () => {
const createLabelBtn = wrapper.find('.dropdown-footer').find(GlButton);
const manageLabelsLink = wrapper.find('.dropdown-footer').find(GlLink);
expect(createLabelBtn.exists()).toBe(true);
expect(createLabelBtn.text()).toBe('Create label');
expect(manageLabelsLink.exists()).toBe(true);
expect(manageLabelsLink.text()).toBe('Manage labels');
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelsSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownContents, {
localVue,
store,
});
};
describe('DropdownContent', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('dropdownContentsView', () => {
it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => {
wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView');
expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view');
});
it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => {
expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view');
});
});
});
describe('template', () => {
it('renders component container element with class `labels-select-dropdown-contents`', () => {
expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents');
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelsSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownTitle, {
localVue,
store,
propsData: {
labelsSelectInProgress: false,
},
});
};
describe('DropdownTitle', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders component container element with string "Labels"', () => {
expect(wrapper.text()).toContain('Labels');
});
it('renders edit link', () => {
const editBtnEl = wrapper.find(GlButton);
expect(editBtnEl.exists()).toBe(true);
expect(editBtnEl.text()).toBe('Edit');
});
it('renders loading icon element when `labelsSelectInProgress` prop is true', () => {
wrapper.setProps({
labelsSelectInProgress: true,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
});
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLabel } from '@gitlab/ui';
import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (initialState = mockConfig, slots = {}) => {
const store = new Vuex.Store(labelsSelectModule());
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownValue, {
localVue,
store,
slots,
});
};
describe('DropdownValue', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('labelFilterUrl', () => {
it('returns a label filter URL based on provided label param', () => {
expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe(
'/gitlab-org/my-project/issues?label_name[]=Foo%20Label',
);
});
});
describe('scopedLabel', () => {
it('returns `true` when provided label param is a scoped label', () => {
expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true);
});
it('returns `false` when provided label param is a regular label', () => {
expect(wrapper.vm.scopedLabel(mockRegularLabel)).toBe(false);
});
});
});
describe('template', () => {
it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => {
expect(wrapper.attributes('class')).toContain('has-labels');
});
it('renders element containing `None` when `selectedLabels` is empty', () => {
const wrapperNoLabels = createComponent(
{
...mockConfig,
selectedLabels: [],
},
{
default: 'None',
},
);
const noneEl = wrapperNoLabels.find('span.text-secondary');
expect(noneEl.exists()).toBe(true);
expect(noneEl.text()).toBe('None');
wrapperNoLabels.destroy();
});
it('renders labels when `selectedLabels` is not empty', () => {
expect(wrapper.findAll(GlLabel).length).toBe(2);
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue';
import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = (config = mockConfig, slots = {}) =>
shallowMount(LabelsSelectRoot, {
localVue,
slots,
store: new Vuex.Store(labelsSelectModule()),
propsData: config,
});
describe('LabelsSelectRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('handleVuexActionDispatch', () => {
it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => {
jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
wrapper.vm.handleVuexActionDispatch(
{ type: 'toggleDropdownContents' },
{
showDropdownButton: false,
showDropdownContents: false,
labels: [{ id: 1 }, { id: 2, touched: true }],
},
);
expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
expect.arrayContaining([
{
id: 2,
touched: true,
},
]),
);
});
});
describe('handleDropdownClose', () => {
it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => {
wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]);
expect(wrapper.emitted().updateSelectedLabels).toBeTruthy();
expect(wrapper.emitted().onDropdownClose).toBeTruthy();
});
it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => {
wrapper.vm.handleDropdownClose([]);
expect(wrapper.emitted().updateSelectedLabels).toBeFalsy();
expect(wrapper.emitted().onDropdownClose).toBeTruthy();
});
});
describe('handleCollapsedValueClick', () => {
it('emits `toggleCollapse` event on component', () => {
wrapper.vm.handleCollapsedValueClick();
expect(wrapper.emitted().toggleCollapse).toBeTruthy();
});
});
});
describe('template', () => {
it('renders component with classes `labels-select-wrapper position-relative`', () => {
expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
});
it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => {
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
});
it('renders `dropdown-title` component', () => {
expect(wrapper.find(DropdownTitle).exists()).toBe(true);
});
it('renders `dropdown-value` component with slot when `showDropdownButton` prop is `false`', () => {
const wrapperDropdownValue = createComponent(mockConfig, {
default: 'None',
});
const valueComp = wrapperDropdownValue.find(DropdownValue);
expect(valueComp.exists()).toBe(true);
expect(valueComp.text()).toBe('None');
wrapperDropdownValue.destroy();
});
it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => {
wrapper.vm.$store.dispatch('toggleDropdownButton');
expect(wrapper.find(DropdownButton).exists()).toBe(true);
});
it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', () => {
wrapper.vm.$store.dispatch('toggleDropdownContents');
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(DropdownContents).exists()).toBe(true);
});
});
});
});
export const mockRegularLabel = {
id: 26,
title: 'Foo Label',
description: 'Foobar',
color: '#BADA55',
textColor: '#FFFFFF',
};
export const mockScopedLabel = {
id: 27,
title: 'Foo::Bar',
description: 'Foobar',
color: '#0033CC',
textColor: '#FFFFFF',
};
export const mockLabels = [
mockRegularLabel,
mockScopedLabel,
{
id: 28,
title: 'Bug',
description: 'Label for bugs',
color: '#FF0000',
textColor: '#FFFFFF',
},
];
export const mockConfig = {
allowLabelEdit: true,
allowLabelCreate: true,
allowScopedLabels: true,
labelsListTitle: 'Assign labels',
labelsCreateTitle: 'Create label',
dropdownOnly: false,
selectedLabels: [mockRegularLabel, mockScopedLabel],
labelsSelectInProgress: false,
labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
labelsManagePath: '/gitlab-org/my-project/-/labels',
labelsFilterBasePath: '/gitlab-org/my-project/issues',
scopedLabelsDocumentationPath: '/help/user/project/labels.md#scoped-labels-premium',
};
export const mockSuggestedColors = {
'#0033CC': 'UA blue',
'#428BCA': 'Moderate blue',
'#44AD8E': 'Lime green',
'#A8D695': 'Feijoa',
'#5CB85C': 'Slightly desaturated green',
'#69D100': 'Bright green',
'#004E00': 'Very dark lime green',
'#34495E': 'Very dark desaturated blue',
'#7F8C8D': 'Dark grayish cyan',
'#A295D6': 'Slightly desaturated blue',
'#5843AD': 'Dark moderate blue',
'#8E44AD': 'Dark moderate violet',
'#FFECDB': 'Very pale orange',
'#AD4363': 'Dark moderate pink',
'#D10069': 'Strong pink',
'#CC0033': 'Strong red',
'#FF0000': 'Pure red',
'#D9534F': 'Soft red',
'#D1D100': 'Strong yellow',
'#F0AD4E': 'Soft orange',
'#AD8D43': 'Dark moderate orange',
};
import MockAdapter from 'axios-mock-adapter';
import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state';
import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types';
import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
describe('LabelsSelect Actions', () => {
let state;
const mockInitialState = {
labels: [],
selectedLabels: [],
};
beforeEach(() => {
state = Object.assign({}, defaultState());
});
describe('setInitialState', () => {
it('sets initial store state', done => {
testAction(
actions.setInitialState,
mockInitialState,
state,
[{ type: types.SET_INITIAL_STATE, payload: mockInitialState }],
[],
done,
);
});
});
describe('toggleDropdownButton', () => {
it('toggles dropdown button', done => {
testAction(
actions.toggleDropdownButton,
{},
state,
[{ type: types.TOGGLE_DROPDOWN_BUTTON }],
[],
done,
);
});
});
describe('toggleDropdownContents', () => {
it('toggles dropdown contents', done => {
testAction(
actions.toggleDropdownContents,
{},
state,
[{ type: types.TOGGLE_DROPDOWN_CONTENTS }],
[],
done,
);
});
});
describe('toggleDropdownContentsCreateView', () => {
it('toggles dropdown create view', done => {
testAction(
actions.toggleDropdownContentsCreateView,
{},
state,
[{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }],
[],
done,
);
});
});
describe('requestLabels', () => {
it('sets value of `state.labelsFetchInProgress` to `true`', done => {
testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done);
});
});
describe('receiveLabelsSuccess', () => {
it('sets provided labels to `state.labels`', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
testAction(
actions.receiveLabelsSuccess,
labels,
state,
[{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }],
[],
done,
);
});
});
describe('receiveLabelsFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('sets value `state.labelsFetchInProgress` to `false`', done => {
testAction(
actions.receiveLabelsFailure,
{},
state,
[{ type: types.RECEIVE_SET_LABELS_FAILURE }],
[],
done,
);
});
it('shows flash error', () => {
actions.receiveLabelsFailure({ commit: () => {} });
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'Error fetching labels.',
);
});
});
describe('fetchLabels', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
state.labelsFetchPath = 'labels.json';
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
mock.onGet(/labels.json/).replyOnce(200, labels);
testAction(
actions.fetchLabels,
{},
state,
[],
[{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }],
done,
);
});
});
describe('on failure', () => {
it('dispatches `requestLabels` & `receiveLabelsFailure` actions', done => {
mock.onGet(/labels.json/).replyOnce(500, {});
testAction(
actions.fetchLabels,
{},
state,
[],
[{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }],
done,
);
});
});
});
describe('requestCreateLabel', () => {
it('sets value `state.labelCreateInProgress` to `true`', done => {
testAction(
actions.requestCreateLabel,
{},
state,
[{ type: types.REQUEST_CREATE_LABEL }],
[],
done,
);
});
});
describe('receiveCreateLabelSuccess', () => {
it('sets value `state.labelCreateInProgress` to `false`', done => {
testAction(
actions.receiveCreateLabelSuccess,
{},
state,
[{ type: types.RECEIVE_CREATE_LABEL_SUCCESS }],
[],
done,
);
});
});
describe('receiveCreateLabelFailure', () => {
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
it('sets value `state.labelCreateInProgress` to `false`', done => {
testAction(
actions.receiveCreateLabelFailure,
{},
state,
[{ type: types.RECEIVE_CREATE_LABEL_FAILURE }],
[],
done,
);
});
it('shows flash error', () => {
actions.receiveCreateLabelFailure({ commit: () => {} });
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'Error creating label.',
);
});
});
describe('createLabel', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
state.labelsManagePath = 'labels.json';
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
it('dispatches `requestCreateLabel`, `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', done => {
const label = { id: 1 };
mock.onPost(/labels.json/).replyOnce(200, label);
testAction(
actions.createLabel,
{},
state,
[],
[
{ type: 'requestCreateLabel' },
{ type: 'receiveCreateLabelSuccess' },
{ type: 'toggleDropdownContentsCreateView' },
],
done,
);
});
});
describe('on failure', () => {
it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', done => {
mock.onPost(/labels.json/).replyOnce(500, {});
testAction(
actions.createLabel,
{},
state,
[],
[{ type: 'requestCreateLabel' }, { type: 'receiveCreateLabelFailure' }],
done,
);
});
});
});
describe('updateSelectedLabels', () => {
it('updates `state.labels` based on provided `labels` param', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
testAction(
actions.updateSelectedLabels,
labels,
state,
[{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }],
[],
done,
);
});
});
});
import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/store/getters';
describe('LabelsSelect Getters', () => {
describe('dropdownButtonText', () => {
it('returns string "Label" when state.labels has no selected labels', () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
expect(getters.dropdownButtonText({ labels })).toBe('Label');
});
it('returns label title when state.labels has only 1 label', () => {
const labels = [{ id: 1, title: 'Foobar', set: true }];
expect(getters.dropdownButtonText({ labels })).toBe('Foobar');
});
it('returns first label title and remaining labels count when state.labels has more than 1 label', () => {
const labels = [{ id: 1, title: 'Foo', set: true }, { id: 2, title: 'Bar', set: true }];
expect(getters.dropdownButtonText({ labels })).toBe('Foo +1 more');
});
});
describe('selectedLabelsList', () => {
it('returns array of IDs of all labels within `state.selectedLabels`', () => {
const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]);
});
});
});
import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations';
import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types';
describe('LabelsSelect Mutations', () => {
describe(`${types.SET_INITIAL_STATE}`, () => {
it('initializes provided props to store state', () => {
const state = {};
mutations[types.SET_INITIAL_STATE](state, {
labels: 'foo',
});
expect(state.labels).toEqual('foo');
});
});
describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => {
it('toggles value of `state.showDropdownButton`', () => {
const state = {
showDropdownButton: false,
};
mutations[types.TOGGLE_DROPDOWN_BUTTON](state);
expect(state.showDropdownButton).toBe(true);
});
});
describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => {
it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => {
const state = {
dropdownOnly: false,
showDropdownButton: false,
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
expect(state.showDropdownButton).toBe(true);
});
it('toggles value of `state.showDropdownContents`', () => {
const state = {
showDropdownContents: false,
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
expect(state.showDropdownContents).toBe(true);
});
it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => {
const state = {
showDropdownContents: false,
showDropdownContentsCreateView: true,
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
expect(state.showDropdownContentsCreateView).toBe(false);
});
});
describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => {
it('toggles value of `state.showDropdownContentsCreateView`', () => {
const state = {
showDropdownContentsCreateView: false,
};
mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state);
expect(state.showDropdownContentsCreateView).toBe(true);
});
});
describe(`${types.REQUEST_LABELS}`, () => {
it('sets value of `state.labelsFetchInProgress` to true', () => {
const state = {
labelsFetchInProgress: false,
};
mutations[types.REQUEST_LABELS](state);
expect(state.labelsFetchInProgress).toBe(true);
});
});
describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => {
const selectedLabels = [{ id: 2 }, { id: 4 }];
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
it('sets value of `state.labelsFetchInProgress` to false', () => {
const state = {
selectedLabels,
labelsFetchInProgress: true,
};
mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
expect(state.labelsFetchInProgress).toBe(false);
});
it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => {
const selectedLabelIds = selectedLabels.map(label => label.id);
const state = {
selectedLabels,
labelsFetchInProgress: true,
};
mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
state.labels.forEach(label => {
if (selectedLabelIds.includes(label.id)) {
expect(label.set).toBe(true);
}
});
});
});
describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => {
it('sets value of `state.labelsFetchInProgress` to false', () => {
const state = {
labelsFetchInProgress: true,
};
mutations[types.RECEIVE_SET_LABELS_FAILURE](state);
expect(state.labelsFetchInProgress).toBe(false);
});
});
describe(`${types.REQUEST_CREATE_LABEL}`, () => {
it('sets value of `state.labelCreateInProgress` to true', () => {
const state = {
labelCreateInProgress: false,
};
mutations[types.REQUEST_CREATE_LABEL](state);
expect(state.labelCreateInProgress).toBe(true);
});
});
describe(`${types.RECEIVE_CREATE_LABEL_SUCCESS}`, () => {
it('sets value of `state.labelCreateInProgress` to false', () => {
const state = {
labelCreateInProgress: false,
};
mutations[types.RECEIVE_CREATE_LABEL_SUCCESS](state);
expect(state.labelCreateInProgress).toBe(false);
});
});
describe(`${types.RECEIVE_CREATE_LABEL_FAILURE}`, () => {
it('sets value of `state.labelCreateInProgress` to false', () => {
const state = {
labelCreateInProgress: false,
};
mutations[types.RECEIVE_CREATE_LABEL_FAILURE](state);
expect(state.labelCreateInProgress).toBe(false);
});
});
describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
const updatedLabelIds = [2, 4];
const state = {
labels,
};
mutations[types.UPDATE_SELECTED_LABELS](state, { labels });
state.labels.forEach(label => {
if (updatedLabelIds.includes(label.id)) {
expect(label.touched).toBe(true);
expect(label.set).toBe(true);
}
});
});
});
});
...@@ -601,10 +601,23 @@ describe Snippet do ...@@ -601,10 +601,23 @@ describe Snippet do
expect(snippet.create_repository).to be_nil expect(snippet.create_repository).to be_nil
end end
it 'does not track snippet repository' do context 'when snippet_repository exists' do
expect do it 'does not create a new snippet repository' do
snippet.create_repository expect do
end.not_to change(SnippetRepository, :count) snippet.create_repository
end.not_to change(SnippetRepository, :count)
end
end
context 'when snippet_repository does not exist' do
it 'creates a snippet_repository' do
snippet.snippet_repository.destroy
snippet.reload
expect do
snippet.create_repository
end.to change(SnippetRepository, :count).by(1)
end
end end
end end
end end
......
...@@ -91,7 +91,7 @@ describe 'Updating a Snippet' do ...@@ -91,7 +91,7 @@ describe 'Updating a Snippet' do
describe 'PersonalSnippet' do describe 'PersonalSnippet' do
it_behaves_like 'graphql update actions' do it_behaves_like 'graphql update actions' do
let_it_be(:snippet) do let(:snippet) do
create(:personal_snippet, create(:personal_snippet,
:private, :private,
file_name: original_file_name, file_name: original_file_name,
...@@ -104,7 +104,7 @@ describe 'Updating a Snippet' do ...@@ -104,7 +104,7 @@ describe 'Updating a Snippet' do
describe 'ProjectSnippet' do describe 'ProjectSnippet' do
let_it_be(:project) { create(:project, :private) } let_it_be(:project) { create(:project, :private) }
let_it_be(:snippet) do let(:snippet) do
create(:project_snippet, create(:project_snippet,
:private, :private,
project: project, project: project,
......
...@@ -278,13 +278,13 @@ describe API::ProjectSnippets do ...@@ -278,13 +278,13 @@ describe API::ProjectSnippets do
describe 'PUT /projects/:project_id/snippets/:id/' do describe 'PUT /projects/:project_id/snippets/:id/' do
let(:visibility_level) { Snippet::PUBLIC } let(:visibility_level) { Snippet::PUBLIC }
let(:snippet) { create(:project_snippet, author: admin, visibility_level: visibility_level, project: project) } let(:snippet) { create(:project_snippet, :repository, author: admin, visibility_level: visibility_level, project: project) }
it 'updates snippet' do it 'updates snippet' do
new_content = 'New content' new_content = 'New content'
new_description = 'New description' new_description = 'New description'
put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), params: { code: new_content, description: new_description, visibility: 'private' } update_snippet(params: { code: new_content, description: new_description, visibility: 'private' })
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
snippet.reload snippet.reload
...@@ -297,7 +297,7 @@ describe API::ProjectSnippets do ...@@ -297,7 +297,7 @@ describe API::ProjectSnippets do
new_content = 'New content' new_content = 'New content'
new_description = 'New description' new_description = 'New description'
put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), params: { content: new_content, description: new_description } update_snippet(params: { content: new_content, description: new_description })
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
snippet.reload snippet.reload
...@@ -306,21 +306,21 @@ describe API::ProjectSnippets do ...@@ -306,21 +306,21 @@ describe API::ProjectSnippets do
end end
it 'returns 400 when both code and content parameters specified' do it 'returns 400 when both code and content parameters specified' do
put api("/projects/#{snippet.project.id}/snippets/1234", admin), params: { code: 'some content', content: 'other content' } update_snippet(params: { code: 'some content', content: 'other content' })
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('code, content are mutually exclusive') expect(json_response['error']).to eq('code, content are mutually exclusive')
end end
it 'returns 404 for invalid snippet id' do it 'returns 404 for invalid snippet id' do
put api("/projects/#{snippet.project.id}/snippets/1234", admin), params: { title: 'foo' } update_snippet(snippet_id: '1234', params: { title: 'foo' })
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Snippet Not Found') expect(json_response['message']).to eq('404 Snippet Not Found')
end end
it 'returns 400 for missing parameters' do it 'returns 400 for missing parameters' do
put api("/projects/#{project.id}/snippets/1234", admin) update_snippet
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
end end
...@@ -328,16 +328,16 @@ describe API::ProjectSnippets do ...@@ -328,16 +328,16 @@ describe API::ProjectSnippets do
it 'returns 400 for empty code field' do it 'returns 400 for empty code field' do
new_content = '' new_content = ''
put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin), params: { code: new_content } update_snippet(params: { code: new_content })
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
end end
context 'when the snippet is spam' do it_behaves_like 'update with repository actions' do
def update_snippet(snippet_params = {}) let(:snippet_without_repo) { create(:project_snippet, author: admin, project: project, visibility_level: visibility_level) }
put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}", admin), params: snippet_params end
end
context 'when the snippet is spam' do
before do before do
allow_next_instance_of(Spam::AkismetService) do |instance| allow_next_instance_of(Spam::AkismetService) do |instance|
allow(instance).to receive(:spam?).and_return(true) allow(instance).to receive(:spam?).and_return(true)
...@@ -348,7 +348,7 @@ describe API::ProjectSnippets do ...@@ -348,7 +348,7 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PRIVATE } let(:visibility_level) { Snippet::PRIVATE }
it 'creates the snippet' do it 'creates the snippet' do
expect { update_snippet(title: 'Foo') } expect { update_snippet(params: { title: 'Foo' }) }
.to change { snippet.reload.title }.to('Foo') .to change { snippet.reload.title }.to('Foo')
end end
end end
...@@ -357,12 +357,12 @@ describe API::ProjectSnippets do ...@@ -357,12 +357,12 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PUBLIC } let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the snippet' do it 'rejects the snippet' do
expect { update_snippet(title: 'Foo') } expect { update_snippet(params: { title: 'Foo' }) }
.not_to change { snippet.reload.title } .not_to change { snippet.reload.title }
end end
it 'creates a spam log' do it 'creates a spam log' do
expect { update_snippet(title: 'Foo') } expect { update_snippet(params: { title: 'Foo' }) }
.to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet') .to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet')
end end
end end
...@@ -371,7 +371,7 @@ describe API::ProjectSnippets do ...@@ -371,7 +371,7 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PRIVATE } let(:visibility_level) { Snippet::PRIVATE }
it 'rejects the snippet' do it 'rejects the snippet' do
expect { update_snippet(title: 'Foo', visibility: 'public') } expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) }
.not_to change { snippet.reload.title } .not_to change { snippet.reload.title }
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
...@@ -379,7 +379,7 @@ describe API::ProjectSnippets do ...@@ -379,7 +379,7 @@ describe API::ProjectSnippets do
end end
it 'creates a spam log' do it 'creates a spam log' do
expect { update_snippet(title: 'Foo', visibility: 'public') } expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) }
.to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet') .to log_spam(title: 'Foo', user_id: admin.id, noteable_type: 'ProjectSnippet')
end end
end end
...@@ -390,6 +390,10 @@ describe API::ProjectSnippets do ...@@ -390,6 +390,10 @@ describe API::ProjectSnippets do
let(:request) { put api("/projects/#{project_no_snippets.id}/snippets/123", admin), params: { description: 'foo' } } let(:request) { put api("/projects/#{project_no_snippets.id}/snippets/123", admin), params: { description: 'foo' } }
end end
end end
def update_snippet(snippet_id: snippet.id, params: {})
put api("/projects/#{snippet.project.id}/snippets/#{snippet_id}", admin), params: params
end
end end
describe 'DELETE /projects/:project_id/snippets/:id/' do describe 'DELETE /projects/:project_id/snippets/:id/' do
......
...@@ -301,7 +301,7 @@ describe API::Snippets do ...@@ -301,7 +301,7 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PUBLIC } let(:visibility_level) { Snippet::PUBLIC }
let(:other_user) { create(:user) } let(:other_user) { create(:user) }
let(:snippet) do let(:snippet) do
create(:personal_snippet, author: user, visibility_level: visibility_level) create(:personal_snippet, :repository, author: user, visibility_level: visibility_level)
end end
shared_examples 'snippet updates' do shared_examples 'snippet updates' do
...@@ -309,7 +309,7 @@ describe API::Snippets do ...@@ -309,7 +309,7 @@ describe API::Snippets do
new_content = 'New content' new_content = 'New content'
new_description = 'New description' new_description = 'New description'
put api("/snippets/#{snippet.id}", user), params: { content: new_content, description: new_description, visibility: 'internal' } update_snippet(params: { content: new_content, description: new_description, visibility: 'internal' })
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
snippet.reload snippet.reload
...@@ -332,30 +332,30 @@ describe API::Snippets do ...@@ -332,30 +332,30 @@ describe API::Snippets do
it_behaves_like 'snippet updates' it_behaves_like 'snippet updates'
it 'returns 404 for invalid snippet id' do it 'returns 404 for invalid snippet id' do
put api("/snippets/1234", user), params: { title: 'foo' } update_snippet(snippet_id: '1234', params: { title: 'Foo' })
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Snippet Not Found') expect(json_response['message']).to eq('404 Snippet Not Found')
end end
it "returns 404 for another user's snippet" do it "returns 404 for another user's snippet" do
put api("/snippets/#{snippet.id}", other_user), params: { title: 'fubar' } update_snippet(requester: other_user, params: { title: 'foobar' })
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 Snippet Not Found') expect(json_response['message']).to eq('404 Snippet Not Found')
end end
it 'returns 400 for missing parameters' do it 'returns 400 for missing parameters' do
put api("/snippets/1234", user) update_snippet
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
end end
context 'when the snippet is spam' do it_behaves_like 'update with repository actions' do
def update_snippet(snippet_params = {}) let(:snippet_without_repo) { create(:personal_snippet, author: user, visibility_level: visibility_level) }
put api("/snippets/#{snippet.id}", user), params: snippet_params end
end
context 'when the snippet is spam' do
before do before do
allow_next_instance_of(Spam::AkismetService) do |instance| allow_next_instance_of(Spam::AkismetService) do |instance|
allow(instance).to receive(:spam?).and_return(true) allow(instance).to receive(:spam?).and_return(true)
...@@ -366,7 +366,7 @@ describe API::Snippets do ...@@ -366,7 +366,7 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PRIVATE } let(:visibility_level) { Snippet::PRIVATE }
it 'updates the snippet' do it 'updates the snippet' do
expect { update_snippet(title: 'Foo') } expect { update_snippet(params: { title: 'Foo' }) }
.to change { snippet.reload.title }.to('Foo') .to change { snippet.reload.title }.to('Foo')
end end
end end
...@@ -375,7 +375,7 @@ describe API::Snippets do ...@@ -375,7 +375,7 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PUBLIC } let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the shippet' do it 'rejects the shippet' do
expect { update_snippet(title: 'Foo') } expect { update_snippet(params: { title: 'Foo' }) }
.not_to change { snippet.reload.title } .not_to change { snippet.reload.title }
expect(response).to have_gitlab_http_status(:bad_request) expect(response).to have_gitlab_http_status(:bad_request)
...@@ -383,7 +383,7 @@ describe API::Snippets do ...@@ -383,7 +383,7 @@ describe API::Snippets do
end end
it 'creates a spam log' do it 'creates a spam log' do
expect { update_snippet(title: 'Foo') }.to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'PersonalSnippet') expect { update_snippet(params: { title: 'Foo' }) }.to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'PersonalSnippet')
end end
end end
...@@ -391,16 +391,20 @@ describe API::Snippets do ...@@ -391,16 +391,20 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PRIVATE } let(:visibility_level) { Snippet::PRIVATE }
it 'rejects the snippet' do it 'rejects the snippet' do
expect { update_snippet(title: 'Foo', visibility: 'public') } expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) }
.not_to change { snippet.reload.title } .not_to change { snippet.reload.title }
end end
it 'creates a spam log' do it 'creates a spam log' do
expect { update_snippet(title: 'Foo', visibility: 'public') } expect { update_snippet(params: { title: 'Foo', visibility: 'public' }) }
.to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'PersonalSnippet') .to log_spam(title: 'Foo', user_id: user.id, noteable_type: 'PersonalSnippet')
end end
end end
end end
def update_snippet(snippet_id: snippet.id, params: {}, requester: user)
put api("/snippets/#{snippet_id}", requester), params: params
end
end end
describe 'DELETE /snippets/:id' do describe 'DELETE /snippets/:id' do
......
...@@ -16,14 +16,9 @@ describe Snippets::UpdateService do ...@@ -16,14 +16,9 @@ describe Snippets::UpdateService do
} }
end end
let(:updater) { user } let(:updater) { user }
let(:service) { Snippets::UpdateService.new(project, updater, options) }
subject do subject { service.execute(snippet) }
described_class.new(
project,
updater,
options
).execute(snippet)
end
shared_examples 'a service that updates a snippet' do shared_examples 'a service that updates a snippet' do
it 'updates a snippet with the provided attributes' do it 'updates a snippet with the provided attributes' do
...@@ -98,9 +93,109 @@ describe Snippets::UpdateService do ...@@ -98,9 +93,109 @@ describe Snippets::UpdateService do
end end
end end
shared_examples 'creates repository and creates file' do
it 'creates repository' do
expect(snippet.repository).not_to exist
subject
expect(snippet.repository).to exist
end
it 'commits the files to the repository' do
subject
expect(snippet.blobs.count).to eq 1
blob = snippet.repository.blob_at('master', options[:file_name])
expect(blob.data).to eq options[:content]
end
context 'when the repository does not exist' do
it 'does not try to commit file' do
allow(snippet).to receive(:repository_exists?).and_return(false)
expect(service).not_to receive(:create_commit)
subject
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(version_snippets: false)
end
it 'does not create repository' do
subject
expect(snippet.repository).not_to exist
end
it 'does not try to commit file' do
expect(service).not_to receive(:create_commit)
subject
end
end
it 'returns error when the commit action fails' do
allow_next_instance_of(SnippetRepository) do |instance|
allow(instance).to receive(:multi_files_action).and_raise(SnippetRepository::CommitError)
end
response = subject
expect(response).to be_error
expect(response.payload[:snippet].errors.full_messages).to eq ['Error updating the snippet']
end
end
shared_examples 'updates repository content' do
it 'commit the files to the repository' do
blob = snippet.blobs.first
options[:file_name] = blob.path + '_new'
expect(blob.data).not_to eq(options[:content])
subject
blob = snippet.blobs.first
expect(blob.path).to eq(options[:file_name])
expect(blob.data).to eq(options[:content])
end
it 'returns error when the commit action fails' do
allow(snippet.snippet_repository).to receive(:multi_files_action).and_raise(SnippetRepository::CommitError)
response = subject
expect(response).to be_error
expect(response.payload[:snippet].errors.full_messages).to eq ['Error updating the snippet']
end
it 'returns error if snippet does not have a snippet_repository' do
allow(snippet).to receive(:snippet_repository).and_return(nil)
expect(subject).to be_error
end
context 'when the repository does not exist' do
it 'does not try to commit file' do
allow(snippet).to receive(:repository_exists?).and_return(false)
expect(service).not_to receive(:create_commit)
subject
end
end
end
context 'when Project Snippet' do context 'when Project Snippet' do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let!(:snippet) { create(:project_snippet, author: user, project: project) } let!(:snippet) { create(:project_snippet, :repository, author: user, project: project) }
before do before do
project.add_developer(user) project.add_developer(user)
...@@ -109,15 +204,29 @@ describe Snippets::UpdateService do ...@@ -109,15 +204,29 @@ describe Snippets::UpdateService do
it_behaves_like 'a service that updates a snippet' it_behaves_like 'a service that updates a snippet'
it_behaves_like 'public visibility level restrictions apply' it_behaves_like 'public visibility level restrictions apply'
it_behaves_like 'snippet update data is tracked' it_behaves_like 'snippet update data is tracked'
it_behaves_like 'updates repository content'
context 'when snippet does not have a repository' do
let!(:snippet) { create(:project_snippet, author: user, project: project) }
it_behaves_like 'creates repository and creates file'
end
end end
context 'when PersonalSnippet' do context 'when PersonalSnippet' do
let(:project) { nil } let(:project) { nil }
let!(:snippet) { create(:personal_snippet, author: user) } let!(:snippet) { create(:personal_snippet, :repository, author: user) }
it_behaves_like 'a service that updates a snippet' it_behaves_like 'a service that updates a snippet'
it_behaves_like 'public visibility level restrictions apply' it_behaves_like 'public visibility level restrictions apply'
it_behaves_like 'snippet update data is tracked' it_behaves_like 'snippet update data is tracked'
it_behaves_like 'updates repository content'
context 'when snippet does not have a repository' do
let!(:snippet) { create(:personal_snippet, author: user, project: project) }
it_behaves_like 'creates repository and creates file'
end
end end
end end
end end
# frozen_string_literal: true
RSpec.shared_examples 'update with repository actions' do
context 'when the repository exists' do
it 'commits the changes to the repository' do
existing_blob = snippet.blobs.first
new_file_name = existing_blob.path + '_new'
new_content = 'New content'
update_snippet(params: { content: new_content, file_name: new_file_name })
aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(snippet.repository.blob_at('master', existing_blob.path)).to be_nil
blob = snippet.repository.blob_at('master', new_file_name)
expect(blob).not_to be_nil
expect(blob.data).to eq(new_content)
end
end
end
context 'when the repository does not exist' do
let(:snippet) { snippet_without_repo }
it 'creates the repository' do
update_snippet(snippet_id: snippet.id, params: { title: 'foo' })
expect(snippet.repository).to exist
end
it 'commits the file to the repository' do
content = 'New Content'
file_name = 'file_name.rb'
update_snippet(snippet_id: snippet.id, params: { content: content, file_name: file_name })
blob = snippet.repository.blob_at('master', file_name)
expect(blob).not_to be_nil
expect(blob.data).to eq content
end
end
end
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