summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue8
-rw-r--r--app/assets/javascripts/blob/viewer/index.js71
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue124
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue178
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue39
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue53
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue173
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js61
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js30
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/index.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js20
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js76
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js27
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss51
-rw-r--r--app/models/snippet.rb5
-rw-r--r--app/services/snippets/update_service.rb58
-rw-r--r--changelogs/unreleased/dmishunov-rich-viewers.yml5
-rw-r--r--changelogs/unreleased/fj-39265-update-snippet-repository-content.yml5
-rw-r--r--changelogs/unreleased/make_design_management_versions_created_at_not_null.yml5
-rw-r--r--db/migrate/20191114201118_make_created_at_not_null_in_design_management_versions.rb15
-rw-r--r--db/schema.rb2
-rw-r--r--doc/development/dangerbot.md6
-rw-r--r--locale/gitlab.pot6
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js55
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js223
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js265
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js54
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js84
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js127
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js66
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js276
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js31
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js172
-rw-r--r--spec/models/snippet_spec.rb21
-rw-r--r--spec/requests/api/graphql/mutations/snippets/update_spec.rb4
-rw-r--r--spec/requests/api/project_snippets_spec.rb36
-rw-r--r--spec/requests/api/snippets_spec.rb32
-rw-r--r--spec/services/snippets/update_service_spec.rb127
-rw-r--r--spec/support/shared_examples/requests/snippet_shared_examples.rb43
48 files changed, 2692 insertions, 102 deletions
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index 2639a099093..7d5d48cfc31 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -45,7 +45,13 @@ export default {
<template v-else>
<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>
</div>
</template>
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 742404da46c..3ac419557eb 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -5,10 +5,43 @@ import { handleLocationHash } from '../../lib/utils/common_utils';
import axios from '../../lib/utils/axios_utils';
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 {
constructor() {
+ const viewer = document.querySelector('.blob-viewer[data-type="rich"]');
+ const type = viewer?.dataset?.richType;
BlobViewer.initAuxiliaryViewer();
- BlobViewer.initRichViewer();
+
+ handleBlobRichViewer(viewer, type);
this.initMainViewers();
}
@@ -20,42 +53,6 @@ export default class BlobViewer {
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() {
this.$fileHolder = $('.file-holder');
if (!this.$fileHolder.length) return;
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
index 582213ee8d3..27f1a4f75d5 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -4,5 +4,9 @@ export default {
type: String,
required: true,
},
+ type: {
+ type: String,
+ required: true,
+ },
},
};
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index b3a1df8f303..afbfb1e0ee2 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -1,10 +1,14 @@
<script>
import ViewerMixin from './mixins';
+import { handleBlobRichViewer } from '~/blob/viewer';
export default {
mixins: [ViewerMixin],
+ mounted() {
+ handleBlobRichViewer(this.$refs.content, this.type);
+ },
};
</script>
<template>
- <div v-html="content"></div>
+ <div ref="content" v-html="content"></div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
index f519f90445e..839117becd9 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
@@ -27,7 +27,12 @@ export default {
<span :style="labelStyle" class="badge color-label">
{{ label.title }}
</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 }}
</gl-tooltip>
</a>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue
index ad5a86de166..94587e1cbab 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue
@@ -33,7 +33,12 @@ export default {
<span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label">
{{ label.title }}
</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
><br />
{{ label.description }}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
new file mode 100644
index 00000000000..b9c611d2764
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
@@ -0,0 +1,21 @@
+<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>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
new file mode 100644
index 00000000000..ef8218b5135
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
@@ -0,0 +1,30 @@
+<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>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
new file mode 100644
index 00000000000..285a0fe9ffb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
@@ -0,0 +1,124 @@
+<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>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
new file mode 100644
index 00000000000..7ec420fa908
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -0,0 +1,178 @@
+<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>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
new file mode 100644
index 00000000000..57f7962dfe1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
@@ -0,0 +1,39 @@
+<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>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
new file mode 100644
index 00000000000..695af775750
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -0,0 +1,53 @@
+<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>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
new file mode 100644
index 00000000000..b90f441b8ec
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -0,0 +1,173 @@
+<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>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
new file mode 100644
index 00000000000..145ec7dc566
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
@@ -0,0 +1,61 @@
+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 () => {};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
new file mode 100644
index 00000000000..c08a8a8ea58
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
@@ -0,0 +1,30 @@
+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 () => {};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/index.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/index.js
new file mode 100644
index 00000000000..5f61cb732c8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/index.js
@@ -0,0 +1,12 @@
+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,
+});
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js
new file mode 100644
index 00000000000..2e044dc3b3c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js
@@ -0,0 +1,20 @@
+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';
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
new file mode 100644
index 00000000000..32a78507e88
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
@@ -0,0 +1,76 @@
+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;
+ }, []);
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
new file mode 100644
index 00000000000..ceabc696693
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
@@ -0,0 +1,27 @@
+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,
+});
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index efcbd63626d..1804f70b37c 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -1019,3 +1019,54 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
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;
+ }
+ }
+}
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index a927235317c..201cd719ee9 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -281,11 +281,10 @@ class Snippet < ApplicationRecord
end
def create_repository
- return if repository_exists?
+ return if repository_exists? && snippet_repository
repository.create_if_not_exists
-
- track_snippet_repository if repository_exists?
+ track_snippet_repository
end
def track_snippet_repository
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index c0c0aec2050..c2949ebadbf 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -4,6 +4,9 @@ module Snippets
class UpdateService < Snippets::BaseService
include SpamCheckMethods
+ UpdateError = Class.new(StandardError)
+ CreateRepositoryError = Class.new(StandardError)
+
def execute(snippet)
# check that user is allowed to set specified visibility_level
new_visibility = visibility_level
@@ -20,11 +23,7 @@ module Snippets
snippet.assign_attributes(params)
spam_check(snippet, current_user)
- snippet_saved = snippet.with_transaction_returning_status do
- snippet.save
- end
-
- if snippet_saved
+ if save_and_commit(snippet)
Gitlab::UsageDataCounters::SnippetCounter.count(:update)
ServiceResponse.success(payload: { snippet: snippet } )
@@ -32,5 +31,54 @@ module Snippets
snippet_error_response(snippet, 400)
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
diff --git a/changelogs/unreleased/dmishunov-rich-viewers.yml b/changelogs/unreleased/dmishunov-rich-viewers.yml
new file mode 100644
index 00000000000..9634e570215
--- /dev/null
+++ b/changelogs/unreleased/dmishunov-rich-viewers.yml
@@ -0,0 +1,5 @@
+---
+title: Special handling for the rich viewer on specific file types
+merge_request: 26260
+author:
+type: changed
diff --git a/changelogs/unreleased/fj-39265-update-snippet-repository-content.yml b/changelogs/unreleased/fj-39265-update-snippet-repository-content.yml
new file mode 100644
index 00000000000..3a8b3684531
--- /dev/null
+++ b/changelogs/unreleased/fj-39265-update-snippet-repository-content.yml
@@ -0,0 +1,5 @@
+---
+title: Update files when snippet is updated
+merge_request: 23993
+author:
+type: changed
diff --git a/changelogs/unreleased/make_design_management_versions_created_at_not_null.yml b/changelogs/unreleased/make_design_management_versions_created_at_not_null.yml
new file mode 100644
index 00000000000..ee748c25c79
--- /dev/null
+++ b/changelogs/unreleased/make_design_management_versions_created_at_not_null.yml
@@ -0,0 +1,5 @@
+---
+title: Make design_management_versions.created_at not null
+merge_request: 20182
+author: Lee Tickett
+type: other
diff --git a/db/migrate/20191114201118_make_created_at_not_null_in_design_management_versions.rb b/db/migrate/20191114201118_make_created_at_not_null_in_design_management_versions.rb
new file mode 100644
index 00000000000..7b9d70c1a50
--- /dev/null
+++ b/db/migrate/20191114201118_make_created_at_not_null_in_design_management_versions.rb
@@ -0,0 +1,15 @@
+# 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
diff --git a/db/schema.rb b/db/schema.rb
index 32fab15182c..5cf32dc5752 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1446,7 +1446,7 @@ ActiveRecord::Schema.define(version: 2020_03_03_074328) do
create_table "design_management_versions", force: :cascade do |t|
t.binary "sha", null: false
t.bigint "issue_id"
- t.datetime_with_timezone "created_at"
+ t.datetime_with_timezone "created_at", null: false
t.integer "author_id"
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"
diff --git a/doc/development/dangerbot.md b/doc/development/dangerbot.md
index cd884a023ca..a201c8ec184 100644
--- a/doc/development/dangerbot.md
+++ b/doc/development/dangerbot.md
@@ -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
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.
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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index aaa1618b1ca..0a63aca949d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -7778,6 +7778,9 @@ msgstr ""
msgid "Error creating epic"
msgstr ""
+msgid "Error creating label."
+msgstr ""
+
msgid "Error deleting %{issuableType}"
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)."
msgstr ""
+msgid "Use custom color #FF0000"
+msgstr ""
+
msgid "Use group milestones to manage issues from multiple projects in the same milestone."
msgstr ""
diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
index 17ea78b5826..ce3f289eb6e 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
@@ -1,14 +1,19 @@
import { shallowMount } from '@vue/test-utils';
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', () => {
let wrapper;
const content = '<h1 id="markdown">Foo Bar</h1>';
+ const defaultType = 'markdown';
- function createComponent() {
+ function createComponent(type = defaultType) {
wrapper = shallowMount(RichViewer, {
propsData: {
content,
+ type,
},
});
}
@@ -24,4 +29,8 @@ describe('Blob Rich Viewer component', () => {
it('renders the passed content without transformations', () => {
expect(wrapper.html()).toContain(content);
});
+
+ it('queries for advanced viewer', () => {
+ expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType);
+ });
});
diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
index d12bfc5c686..79195aa1350 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
@@ -10,6 +10,7 @@ describe('Blob Simple Viewer component', () => {
wrapper = shallowMount(SimpleViewer, {
propsData: {
content,
+ type: 'text',
},
});
}
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
new file mode 100644
index 00000000000..d996f48f9cc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
@@ -0,0 +1,55 @@
+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');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
new file mode 100644
index 00000000000..9bc01d8723f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
@@ -0,0 +1,223 @@
+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');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
new file mode 100644
index 00000000000..487b917852e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
@@ -0,0 +1,265 @@
+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');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js
new file mode 100644
index 00000000000..bb462acf11c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js
@@ -0,0 +1,54 @@
+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');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js
new file mode 100644
index 00000000000..c1d9be7393c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js
@@ -0,0 +1,61 @@
+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);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js
new file mode 100644
index 00000000000..70311f8235f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js
@@ -0,0 +1,84 @@
+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);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
new file mode 100644
index 00000000000..126fd5438c4
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
@@ -0,0 +1,127 @@
+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);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
new file mode 100644
index 00000000000..a863cddbaee
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
@@ -0,0 +1,66 @@
+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',
+};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
new file mode 100644
index 00000000000..6e2363ba96f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
@@ -0,0 +1,276 @@
+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,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
new file mode 100644
index 00000000000..bfceaa0828b
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js
@@ -0,0 +1,31 @@
+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]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
new file mode 100644
index 00000000000..f6ca98fcc71
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
@@ -0,0 +1,172 @@
+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);
+ }
+ });
+ });
+ });
+});
diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb
index cb7b9961880..87ace7b51f4 100644
--- a/spec/models/snippet_spec.rb
+++ b/spec/models/snippet_spec.rb
@@ -601,10 +601,23 @@ describe Snippet do
expect(snippet.create_repository).to be_nil
end
- it 'does not track snippet repository' do
- expect do
- snippet.create_repository
- end.not_to change(SnippetRepository, :count)
+ context 'when snippet_repository exists' do
+ it 'does not create a new snippet repository' do
+ expect do
+ 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
diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
index 820c97e8341..1035e3346e1 100644
--- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
@@ -91,7 +91,7 @@ describe 'Updating a Snippet' do
describe 'PersonalSnippet' do
it_behaves_like 'graphql update actions' do
- let_it_be(:snippet) do
+ let(:snippet) do
create(:personal_snippet,
:private,
file_name: original_file_name,
@@ -104,7 +104,7 @@ describe 'Updating a Snippet' do
describe 'ProjectSnippet' do
let_it_be(:project) { create(:project, :private) }
- let_it_be(:snippet) do
+ let(:snippet) do
create(:project_snippet,
:private,
project: project,
diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb
index ba5de430f7d..e018a4643db 100644
--- a/spec/requests/api/project_snippets_spec.rb
+++ b/spec/requests/api/project_snippets_spec.rb
@@ -278,13 +278,13 @@ describe API::ProjectSnippets do
describe 'PUT /projects/:project_id/snippets/:id/' do
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
new_content = 'New content'
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)
snippet.reload
@@ -297,7 +297,7 @@ describe API::ProjectSnippets do
new_content = 'New content'
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)
snippet.reload
@@ -306,21 +306,21 @@ describe API::ProjectSnippets do
end
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(json_response['error']).to eq('code, content are mutually exclusive')
end
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(json_response['message']).to eq('404 Snippet Not Found')
end
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)
end
@@ -328,16 +328,16 @@ describe API::ProjectSnippets do
it 'returns 400 for empty code field' do
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)
end
- context 'when the snippet is spam' do
- def update_snippet(snippet_params = {})
- put api("/projects/#{snippet.project.id}/snippets/#{snippet.id}", admin), params: snippet_params
- end
+ it_behaves_like 'update with repository actions' do
+ let(:snippet_without_repo) { create(:project_snippet, author: admin, project: project, visibility_level: visibility_level) }
+ end
+ context 'when the snippet is spam' do
before do
allow_next_instance_of(Spam::AkismetService) do |instance|
allow(instance).to receive(:spam?).and_return(true)
@@ -348,7 +348,7 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PRIVATE }
it 'creates the snippet' do
- expect { update_snippet(title: 'Foo') }
+ expect { update_snippet(params: { title: 'Foo' }) }
.to change { snippet.reload.title }.to('Foo')
end
end
@@ -357,12 +357,12 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the snippet' do
- expect { update_snippet(title: 'Foo') }
+ expect { update_snippet(params: { title: 'Foo' }) }
.not_to change { snippet.reload.title }
end
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')
end
end
@@ -371,7 +371,7 @@ describe API::ProjectSnippets do
let(:visibility_level) { Snippet::PRIVATE }
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 }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -379,7 +379,7 @@ describe API::ProjectSnippets do
end
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')
end
end
@@ -390,6 +390,10 @@ describe API::ProjectSnippets do
let(:request) { put api("/projects/#{project_no_snippets.id}/snippets/123", admin), params: { description: 'foo' } }
end
end
+
+ def update_snippet(snippet_id: snippet.id, params: {})
+ put api("/projects/#{snippet.project.id}/snippets/#{snippet_id}", admin), params: params
+ end
end
describe 'DELETE /projects/:project_id/snippets/:id/' do
diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb
index d399c2b3f1c..627611c10ce 100644
--- a/spec/requests/api/snippets_spec.rb
+++ b/spec/requests/api/snippets_spec.rb
@@ -301,7 +301,7 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PUBLIC }
let(:other_user) { create(:user) }
let(:snippet) do
- create(:personal_snippet, author: user, visibility_level: visibility_level)
+ create(:personal_snippet, :repository, author: user, visibility_level: visibility_level)
end
shared_examples 'snippet updates' do
@@ -309,7 +309,7 @@ describe API::Snippets do
new_content = 'New content'
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)
snippet.reload
@@ -332,30 +332,30 @@ describe API::Snippets do
it_behaves_like 'snippet updates'
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(json_response['message']).to eq('404 Snippet Not Found')
end
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(json_response['message']).to eq('404 Snippet Not Found')
end
it 'returns 400 for missing parameters' do
- put api("/snippets/1234", user)
+ update_snippet
expect(response).to have_gitlab_http_status(:bad_request)
end
- context 'when the snippet is spam' do
- def update_snippet(snippet_params = {})
- put api("/snippets/#{snippet.id}", user), params: snippet_params
- end
+ it_behaves_like 'update with repository actions' do
+ let(:snippet_without_repo) { create(:personal_snippet, author: user, visibility_level: visibility_level) }
+ end
+ context 'when the snippet is spam' do
before do
allow_next_instance_of(Spam::AkismetService) do |instance|
allow(instance).to receive(:spam?).and_return(true)
@@ -366,7 +366,7 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PRIVATE }
it 'updates the snippet' do
- expect { update_snippet(title: 'Foo') }
+ expect { update_snippet(params: { title: 'Foo' }) }
.to change { snippet.reload.title }.to('Foo')
end
end
@@ -375,7 +375,7 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PUBLIC }
it 'rejects the shippet' do
- expect { update_snippet(title: 'Foo') }
+ expect { update_snippet(params: { title: 'Foo' }) }
.not_to change { snippet.reload.title }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -383,7 +383,7 @@ describe API::Snippets do
end
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
@@ -391,16 +391,20 @@ describe API::Snippets do
let(:visibility_level) { Snippet::PRIVATE }
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 }
end
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')
end
end
end
+
+ def update_snippet(snippet_id: snippet.id, params: {}, requester: user)
+ put api("/snippets/#{snippet_id}", requester), params: params
+ end
end
describe 'DELETE /snippets/:id' do
diff --git a/spec/services/snippets/update_service_spec.rb b/spec/services/snippets/update_service_spec.rb
index 4858a0512ad..2c70cce767d 100644
--- a/spec/services/snippets/update_service_spec.rb
+++ b/spec/services/snippets/update_service_spec.rb
@@ -16,14 +16,9 @@ describe Snippets::UpdateService do
}
end
let(:updater) { user }
+ let(:service) { Snippets::UpdateService.new(project, updater, options) }
- subject do
- described_class.new(
- project,
- updater,
- options
- ).execute(snippet)
- end
+ subject { service.execute(snippet) }
shared_examples 'a service that updates a snippet' do
it 'updates a snippet with the provided attributes' do
@@ -98,9 +93,109 @@ describe Snippets::UpdateService do
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
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
project.add_developer(user)
@@ -109,15 +204,29 @@ describe Snippets::UpdateService do
it_behaves_like 'a service that updates a snippet'
it_behaves_like 'public visibility level restrictions apply'
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
context 'when PersonalSnippet' do
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 'public visibility level restrictions apply'
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
diff --git a/spec/support/shared_examples/requests/snippet_shared_examples.rb b/spec/support/shared_examples/requests/snippet_shared_examples.rb
new file mode 100644
index 00000000000..f2df97a35d9
--- /dev/null
+++ b/spec/support/shared_examples/requests/snippet_shared_examples.rb
@@ -0,0 +1,43 @@
+# 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