diff options
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 |