diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-12 09:09:31 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-12 09:09:31 +0000 |
commit | 0e1a6f6a2b28464e6ad151da4dced6d603bd11b0 (patch) | |
tree | b84d68dca1be62e789da50841ed283d99a4284b5 | |
parent | 143f7be045960f8d51dea738781535d614956f84 (diff) | |
download | gitlab-ce-0e1a6f6a2b28464e6ad151da4dced6d603bd11b0.tar.gz |
Add latest changes from gitlab-org/gitlab@master
46 files changed, 879 insertions, 339 deletions
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 731aea996fb..10aac2f649e 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,4 +1,6 @@ +import { inactiveListId } from '~/boards/constants'; + export default () => ({ isShowingLabels: true, - activeListId: 0, + activeListId: inactiveListId, }); diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 46dacf30f39..eb575b9ed6c 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlTable, GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import { GlTable, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui'; import tooltip from '~/vue_shared/directives/tooltip'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import { __, sprintf } from '~/locale'; @@ -8,54 +8,58 @@ import { __, sprintf } from '~/locale'; export default { components: { GlTable, + GlLink, GlLoadingIcon, GlBadge, }, directives: { tooltip, }, - fields: [ - { - key: 'name', - label: __('Kubernetes cluster'), - }, - { - key: 'environmentScope', - label: __('Environment scope'), - }, - { - key: 'size', - label: __('Size'), - }, - { - key: 'cpu', - label: __('Total cores (vCPUs)'), - }, - { - key: 'memory', - label: __('Total memory (GB)'), - }, - { - key: 'clusterType', - label: __('Cluster level'), - formatter: value => CLUSTER_TYPES[value], - }, - ], computed: { ...mapState(['clusters', 'loading']), + fields() { + return [ + { + key: 'name', + label: __('Kubernetes cluster'), + }, + { + key: 'environment_scope', + label: __('Environment scope'), + }, + // Wait for backend to send these fields + // { + // key: 'size', + // label: __('Size'), + // }, + // { + // key: 'cpu', + // label: __('Total cores (vCPUs)'), + // }, + // { + // key: 'memory', + // label: __('Total memory (GB)'), + // }, + { + key: 'cluster_type', + label: __('Cluster level'), + formatter: value => CLUSTER_TYPES[value], + }, + ]; + }, }, mounted() { - // TODO - uncomment this once integrated with BE - // this.fetchClusters(); + this.fetchClusters(); }, methods: { ...mapActions(['fetchClusters']), statusClass(status) { - return STATUSES[status].className; + const iconClass = STATUSES[status] || STATUSES.default; + return iconClass.className; }, statusTitle(status) { - const { title } = STATUSES[status]; - return sprintf(__('Status: %{title}'), { title }, false); + const iconTitle = STATUSES[status] || STATUSES.default; + return sprintf(__('Status: %{title}'), { title: iconTitle.title }, false); }, }, }; @@ -63,17 +67,13 @@ export default { <template> <gl-loading-icon v-if="loading" size="md" class="mt-3" /> - <gl-table - v-else - :items="clusters" - :fields="$options.fields" - stacked="md" - variant="light" - class="qa-clusters-table" - > + <gl-table v-else :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table"> <template #cell(name)="{ item }"> <div class="d-flex flex-row-reverse flex-md-row js-status"> - {{ item.name }} + <gl-link data-qa-selector="cluster" :data-qa-cluster-name="item.name" :href="item.path"> + {{ item.name }} + </gl-link> + <gl-loading-icon v-if="item.status === 'deleting'" v-tooltip @@ -84,13 +84,13 @@ export default { <div v-else v-tooltip - class="cluster-status-indicator rounded-circle align-self-center gl-w-8 gl-h-8 mr-2 ml-md-2" + class="cluster-status-indicator rounded-circle align-self-center gl-w-4 gl-h-4 mr-2 ml-md-2" :class="statusClass(item.status)" :title="statusTitle(item.status)" ></div> </div> </template> - <template #cell(clusterType)="{value}"> + <template #cell(cluster_type)="{value}"> <gl-badge variant="light"> {{ value }} </gl-badge> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 9428f08176c..eebcaa086f9 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -7,8 +7,9 @@ export const CLUSTER_TYPES = { }; export const STATUSES = { + default: { className: 'bg-white', title: __('Unknown') }, disabled: { className: 'disabled', title: __('Disabled') }, - connected: { className: 'bg-success', title: __('Connected') }, + created: { className: 'bg-success', title: __('Connected') }, unreachable: { className: 'bg-danger', title: __('Unreachable') }, authentication_failure: { className: 'bg-warning', title: __('Authentication Failure') }, deleting: { title: __('Deleting') }, diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 79bc9932438..d0ad92f5536 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -1,7 +1,5 @@ -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; import axios from '~/lib/utils/axios_utils'; -import Visibility from 'visibilityjs'; import flash from '~/flash'; import { __ } from '~/locale'; import * as types from './mutation_types'; @@ -14,23 +12,16 @@ export const fetchClusters = ({ state, commit }) => { data: state.endpoint, method: 'fetchClusters', successCallback: ({ data }) => { - commit(types.SET_CLUSTERS_DATA, convertObjectPropsToCamelCase(data, { deep: true })); - commit(types.SET_LOADING_STATE, false); + if (data.clusters) { + commit(types.SET_CLUSTERS_DATA, data); + commit(types.SET_LOADING_STATE, false); + poll.stop(); + } }, errorCallback: () => flash(__('An error occurred while loading clusters')), }); - if (!Visibility.hidden()) { - poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - poll.restart(); - } else { - poll.stop(); - } - }); + poll.makeRequest(); }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js index ffd3c4601bf..ce53a033628 100644 --- a/app/assets/javascripts/clusters_list/store/mutations.js +++ b/app/assets/javascripts/clusters_list/store/mutations.js @@ -4,9 +4,10 @@ export default { [types.SET_LOADING_STATE](state, value) { state.loading = value; }, - [types.SET_CLUSTERS_DATA](state, clusters) { + [types.SET_CLUSTERS_DATA](state, data) { Object.assign(state, { - clusters, + clusters: data.clusters, + hasAncestorClusters: data.has_ancestor_clusters, }); }, }; diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js index ed032ed8435..31e73558c2e 100644 --- a/app/assets/javascripts/clusters_list/store/state.js +++ b/app/assets/javascripts/clusters_list/store/state.js @@ -1,5 +1,6 @@ export default (initialState = {}) => ({ endpoint: initialState.endpoint, - loading: false, // TODO - set this to true once integrated with BE + hasAncestorClusters: false, + loading: true, clusters: [], }); diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index 63e59f816d7..f0b02d07d93 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -7,7 +7,7 @@ import getDesignListQuery from './graphql/queries/get_design_list.query.graphql' import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants'; export default () => { - const el = document.getElementById('js-design-management'); + const el = document.querySelector('.js-design-management'); const badge = document.querySelector('.js-designs-count'); const { issueIid, projectPath, issuePath } = el.dataset; const router = createRouter(issuePath); diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 3e56379dc47..acfa97f625c 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -259,8 +259,10 @@ export default { }); }, trackEvent() { + // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue trackDesignDetailView( 'issue-design-collection', + 'issue', this.$route.query.version || this.latestVersionId, this.isLatestVersion, ); diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js index c94aa83ecc0..39c20376271 100644 --- a/app/assets/javascripts/design_management/utils/tracking.js +++ b/app/assets/javascripts/design_management/utils/tracking.js @@ -4,8 +4,9 @@ function assembleDesignPayload(payloadArr) { return { value: { 'internal-object-refrerer': payloadArr[0], - 'version-number': payloadArr[1], - 'current-version': payloadArr[2], + 'design-collection-owner': payloadArr[1], + 'design-version-number': payloadArr[2], + 'design-is-current-version': payloadArr[3], }, }; } @@ -14,9 +15,14 @@ function assembleDesignPayload(payloadArr) { const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design'; // eslint-disable-next-line import/prefer-default-export -export function trackDesignDetailView(refrerer = '', designVersion = 1, latestVersion = false) { +export function trackDesignDetailView( + referer = '', + owner = '', + designVersion = 1, + latestVersion = false, +) { Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', { label: 'design_viewed', - ...assembleDesignPayload([refrerer, designVersion, latestVersion]), + ...assembleDesignPayload([referer, owner, designVersion, latestVersion]), }); } diff --git a/app/assets/javascripts/issuables_list/eventhub.js b/app/assets/javascripts/issuables_list/eventhub.js index d1601a7d8f3..e31806ad199 100644 --- a/app/assets/javascripts/issuables_list/eventhub.js +++ b/app/assets/javascripts/issuables_list/eventhub.js @@ -1,5 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -const issueablesEventBus = new Vue(); - -export default issueablesEventBus; +export default createEventHub(); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 75df80a0f6c..46c9b2fe0af 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -12,6 +12,16 @@ export default function() { initIssueableApp(); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); + + // .js-design-management is currently EE-only. + // This will be moved to CE as part of https://gitlab.com/gitlab-org/gitlab/-/issues/212566#frontend + // at which point this conditional can be removed. + if (document.querySelector('.js-design-management')) { + import(/* webpackChunkName: 'design_management' */ '~/design_management') + .then(module => module.default()) + .catch(() => {}); + } + new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/registry/explorer/components/image_list.vue b/app/assets/javascripts/registry/explorer/components/image_list.vue new file mode 100644 index 00000000000..bc209b12738 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/image_list.vue @@ -0,0 +1,124 @@ +<script> +import { GlPagination, GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +import { + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + ROW_SCHEDULED_FOR_DELETION, +} from '../constants'; + +export default { + name: 'ImageList', + components: { + GlPagination, + ClipboardButton, + GlDeprecatedButton, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + images: { + type: Array, + required: true, + }, + pagination: { + type: Object, + required: true, + }, + }, + i18n: { + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + ROW_SCHEDULED_FOR_DELETION, + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + }, + computed: { + currentPage: { + get() { + return this.pagination.page; + }, + set(page) { + this.$emit('pageChange', page); + }, + }, + }, + methods: { + encodeListItem(item) { + const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); + return window.btoa(params); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column"> + <div + v-for="(listItem, index) in images" + :key="index" + v-gl-tooltip="{ + placement: 'left', + disabled: !listItem.deleting, + title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, + }" + data-testid="rowItem" + > + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 border-bottom" + :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }" + > + <div class="gl-display-flex gl-align-items-center"> + <router-link + data-testid="detailsLink" + :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" + > + {{ listItem.path }} + </router-link> + <clipboard-button + v-if="listItem.location" + :disabled="listItem.deleting" + :text="listItem.location" + :title="listItem.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + <gl-icon + v-if="listItem.failedDelete" + v-gl-tooltip + :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE" + name="warning" + class="text-warning align-middle" + /> + </div> + <div + v-gl-tooltip="{ disabled: listItem.destroy_path }" + class="d-none d-sm-block" + :title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" + > + <gl-deprecated-button + v-gl-tooltip + data-testid="deleteImageButton" + :disabled="!listItem.destroy_path || listItem.deleting" + :title="$options.i18n.REMOVE_REPOSITORY_LABEL" + :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL" + class="btn-inverted" + variant="danger" + @click="$emit('delete', listItem)" + > + <gl-icon name="remove" /> + </gl-deprecated-button> + </div> + </div> + </div> + <gl-pagination + v-model="currentPage" + :per-page="pagination.perPage" + :total-items="pagination.total" + align="center" + class="w-100 gl-mt-2" + /> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js index 4ca4c7088a6..7cbe657bfc0 100644 --- a/app/assets/javascripts/registry/explorer/constants.js +++ b/app/assets/javascripts/registry/explorer/constants.js @@ -37,6 +37,15 @@ export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( 'ContainerRegistry|%{title} was successfully scheduled for deletion', ); +export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories'); + +export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name'); + +export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.'); +export const EMPTY_RESULT_MESSAGE = s__( + 'ContainerRegistry|To widen your search, change or remove the filters above.', +); + // Image details page export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index e932544feb8..4efa6f08d84 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -2,53 +2,52 @@ import { mapState, mapActions } from 'vuex'; import { GlEmptyState, - GlPagination, GlTooltipDirective, - GlDeprecatedButton, - GlIcon, GlModal, GlSprintf, GlLink, GlAlert, GlSkeletonLoader, + GlSearchBoxByClick, } from '@gitlab/ui'; import Tracking from '~/tracking'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + import ProjectEmptyState from '../components/project_empty_state.vue'; import GroupEmptyState from '../components/group_empty_state.vue'; import ProjectPolicyAlert from '../components/project_policy_alert.vue'; import QuickstartDropdown from '../components/quickstart_dropdown.vue'; +import ImageList from '../components/image_list.vue'; + import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, CONTAINER_REGISTRY_TITLE, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, LIST_INTRO_TEXT, - LIST_DELETE_BUTTON_DISABLED, - REMOVE_REPOSITORY_LABEL, REMOVE_REPOSITORY_MODAL_TEXT, - ROW_SCHEDULED_FOR_DELETION, + REMOVE_REPOSITORY_LABEL, + SEARCH_PLACEHOLDER_TEXT, + IMAGE_REPOSITORY_LIST_LABEL, + EMPTY_RESULT_TITLE, + EMPTY_RESULT_MESSAGE, } from '../constants'; export default { name: 'RegistryListApp', components: { GlEmptyState, - GlPagination, ProjectEmptyState, GroupEmptyState, ProjectPolicyAlert, - ClipboardButton, QuickstartDropdown, - GlDeprecatedButton, - GlIcon, + ImageList, GlModal, GlSprintf, GlLink, GlAlert, GlSkeletonLoader, + GlSearchBoxByClick, }, directives: { GlTooltip: GlTooltipDirective, @@ -60,20 +59,23 @@ export default { height: 40, }, i18n: { - containerRegistryTitle: CONTAINER_REGISTRY_TITLE, - connectionErrorTitle: CONNECTION_ERROR_TITLE, - connectionErrorMessage: CONNECTION_ERROR_MESSAGE, - introText: LIST_INTRO_TEXT, - deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED, - removeRepositoryLabel: REMOVE_REPOSITORY_LABEL, - removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT, - rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION, - asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CONTAINER_REGISTRY_TITLE, + CONNECTION_ERROR_TITLE, + CONNECTION_ERROR_MESSAGE, + LIST_INTRO_TEXT, + REMOVE_REPOSITORY_MODAL_TEXT, + REMOVE_REPOSITORY_LABEL, + SEARCH_PLACEHOLDER_TEXT, + IMAGE_REPOSITORY_LIST_LABEL, + EMPTY_RESULT_TITLE, + EMPTY_RESULT_MESSAGE, }, data() { return { itemToDelete: {}, deleteAlertType: null, + search: null, + isEmpty: false, }; }, computed: { @@ -83,14 +85,6 @@ export default { label: 'registry_repository_delete', }; }, - currentPage: { - get() { - return this.pagination.page; - }, - set(page) { - this.requestImagesList({ page }); - }, - }, showQuickStartDropdown() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, @@ -110,8 +104,11 @@ export default { ...mapActions(['requestImagesList', 'requestDeleteImage']), loadImageList(fromName) { if (!fromName || !this.images?.length) { - this.requestImagesList(); + return this.requestImagesList().then(() => { + this.isEmpty = this.images.length === 0; + }); } + return Promise.resolve(); }, deleteImage(item) { this.track('click_button'); @@ -128,10 +125,6 @@ export default { this.deleteAlertType = 'danger'; }); }, - encodeListItem(item) { - const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); - return window.btoa(params); - }, dismissDeleteAlert() { this.deleteAlertType = null; this.itemToDelete = {}; @@ -160,12 +153,12 @@ export default { <gl-empty-state v-if="config.characterError" - :title="$options.i18n.connectionErrorTitle" + :title="$options.i18n.CONNECTION_ERROR_TITLE" :svg-path="config.containersErrorImage" > <template #description> <p> - <gl-sprintf :message="$options.i18n.connectionErrorMessage"> + <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE"> <template #docLink="{content}"> <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank"> {{ content }} @@ -179,11 +172,11 @@ export default { <template v-else> <div> <div class="d-flex justify-content-between align-items-center"> - <h4>{{ $options.i18n.containerRegistryTitle }}</h4> + <h4>{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4> <quickstart-dropdown v-if="showQuickStartDropdown" class="d-none d-sm-block" /> </div> <p> - <gl-sprintf :message="$options.i18n.introText"> + <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> <template #docLink="{content}"> <gl-link :href="config.helpPagePath" target="_blank"> {{ content }} @@ -207,73 +200,40 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <div v-if="images.length" ref="imagesList" class="d-flex flex-column"> - <div - v-for="(listItem, index) in images" - :key="index" - ref="rowItem" - v-gl-tooltip="{ - placement: 'left', - disabled: !listItem.deleting, - title: $options.i18n.rowScheduledForDeletion, - }" - > - <div - class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom" - :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }" - > - <div class="d-felx align-items-center"> - <router-link - ref="detailsLink" - :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" - > - {{ listItem.path }} - </router-link> - <clipboard-button - v-if="listItem.location" - ref="clipboardButton" - :disabled="listItem.deleting" - :text="listItem.location" - :title="listItem.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - <gl-icon - v-if="listItem.failedDelete" - v-gl-tooltip - :title="$options.i18n.asyncDeleteErrorMessage" - name="warning" - class="text-warning align-middle" - /> - </div> - <div - v-gl-tooltip="{ disabled: listItem.destroy_path }" - class="d-none d-sm-block" - :title="$options.i18n.deleteButtonDisabled" - > - <gl-deprecated-button - ref="deleteImageButton" - v-gl-tooltip - :disabled="!listItem.destroy_path || listItem.deleting" - :title="$options.i18n.removeRepositoryLabel" - :aria-label="$options.i18n.removeRepositoryLabel" - class="btn-inverted" - variant="danger" - @click="deleteImage(listItem)" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </div> + <template v-if="!isEmpty"> + <div class="gl-display-flex gl-p-1" data-testid="listHeader"> + <div class="gl-flex-fill-1"> + <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5> + </div> + <div> + <gl-search-box-by-click + v-model="search" + :placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT" + @submit="requestImagesList({ name: $event })" + /> </div> </div> - <gl-pagination - v-model="currentPage" - :per-page="pagination.perPage" - :total-items="pagination.total" - align="center" - class="w-100 mt-2" + + <image-list + v-if="images.length" + :images="images" + :pagination="pagination" + @pageChange="requestImagesList({ pagination: { page: $event }, name: search })" + @delete="deleteImage" /> - </div> + <gl-empty-state + v-else + :svg-path="config.noContainersImage" + data-testid="emptySearch" + :title="$options.i18n.EMPTY_RESULT_TITLE" + class="container-message" + > + <template #description> + {{ $options.i18n.EMPTY_RESULT_MESSAGE }} + </template> + </gl-empty-state> + </template> <template v-else> <project-empty-state v-if="!config.isGroupPage" /> <group-empty-state v-else /> @@ -287,9 +247,9 @@ export default { @ok="handleDeleteImage" @cancel="track('cancel_delete')" > - <template #modal-title>{{ $options.i18n.removeRepositoryLabel }}</template> + <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template> <p> - <gl-sprintf :message="$options.i18n.removeRepositoryModalText"> + <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT"> <template #title> <b>{{ itemToDelete.path }}</b> </template> diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 6e3cf3f0c80..7f80bc21d6e 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -23,12 +23,15 @@ export const receiveTagsListSuccess = ({ commit }, { data, headers }) => { commit(types.SET_TAGS_PAGINATION, headers); }; -export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) => { +export const requestImagesList = ( + { commit, dispatch, state }, + { pagination = {}, name = null } = {}, +) => { commit(types.SET_MAIN_LOADING, true); const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; return axios - .get(state.config.endpoint, { params: { page, per_page: perPage } }) + .get(state.config.endpoint, { params: { page, per_page: perPage, name } }) .then(({ data, headers }) => { dispatch('receiveImagesListSuccess', { data, headers }); }) diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index ce1039832d3..e4466b44358 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -69,11 +69,6 @@ $item-weight-max-width: 48px; font-weight: $gl-font-weight-bold; } - .issue-token-state-icon-open, - .issue-token-state-icon-closed { - display: none; - } - .sortable-link { color: $gray-900; font-weight: normal; @@ -92,7 +87,8 @@ $item-weight-max-width: 48px; @include media-breakpoint-down(lg) { .issue-count-badge { - padding-left: 0; + padding: 0; + padding-right: 8px; } } } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index e24384156c9..3270c7c131f 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1317,6 +1317,14 @@ class MergeRequest < ApplicationRecord actual_head_pipeline&.has_reports?(Ci::JobArtifact.terraform_reports) end + def compare_accessibility_reports + unless has_accessibility_reports? + return { status: :error, status_reason: _('This merge request does not have accessibility reports') } + end + + compare_reports(Ci::CompareAccessibilityReportsService) + end + # TODO: this method and compare_test_reports use the same # result type, which is handled by the controller's #reports_response. # we should minimize mistakes by isolating the common parts. diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 6b1d82e7557..5e669ff2e50 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -21,8 +21,8 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated can?(current_user, :create_cluster, clusterable) end - def index_path - polymorphic_path([clusterable, :clusters]) + def index_path(options = {}) + polymorphic_path([clusterable, :clusters], options) end def new_path(options = {}) diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb index 0c267fd5735..41071bc7bc7 100644 --- a/app/presenters/instance_clusterable_presenter.rb +++ b/app/presenters/instance_clusterable_presenter.rb @@ -13,8 +13,8 @@ class InstanceClusterablePresenter < ClusterablePresenter end override :index_path - def index_path - admin_clusters_path + def index_path(options = {}) + admin_clusters_path(options) end override :new_path diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 5f3dfdacc14..50431e50110 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -22,7 +22,9 @@ class IssuableBaseService < BaseService params.delete(:milestone_id) params.delete(:labels) params.delete(:add_label_ids) + params.delete(:add_labels) params.delete(:remove_label_ids) + params.delete(:remove_labels) params.delete(:label_ids) params.delete(:assignee_ids) params.delete(:assignee_id) diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 28002dbff92..86194842664 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -19,7 +19,7 @@ = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence') - if Feature.enabled?(:clusters_list_redesign) - #js-clusters-list-app{ data: { endpoint: 'todo/add/endpoint' } } + #js-clusters-list-app{ data: { endpoint: clusterable.index_path(format: :json) } } - else .clusters-table.js-clusters-list .gl-responsive-table-row.table-row-header{ role: "row" } diff --git a/changelogs/unreleased/215563-migration-to-import-common-metrics.yml b/changelogs/unreleased/215563-migration-to-import-common-metrics.yml new file mode 100644 index 00000000000..eef492ea1ac --- /dev/null +++ b/changelogs/unreleased/215563-migration-to-import-common-metrics.yml @@ -0,0 +1,6 @@ +--- +title: Add migration to import changes to the system dashboard Prometheus queries + into DB +merge_request: 31618 +author: +type: changed diff --git a/changelogs/unreleased/216122-use-search-to-quickly-find-and-discover-images-hosted-in-the-gitla.yml b/changelogs/unreleased/216122-use-search-to-quickly-find-and-discover-images-hosted-in-the-gitla.yml new file mode 100644 index 00000000000..cca9be40473 --- /dev/null +++ b/changelogs/unreleased/216122-use-search-to-quickly-find-and-discover-images-hosted-in-the-gitla.yml @@ -0,0 +1,5 @@ +--- +title: Add search bar to container registry image list +merge_request: 31322 +author: +type: added diff --git a/changelogs/unreleased/chore-mitt-migration-issuables-list.yml b/changelogs/unreleased/chore-mitt-migration-issuables-list.yml new file mode 100644 index 00000000000..b60f9f7557c --- /dev/null +++ b/changelogs/unreleased/chore-mitt-migration-issuables-list.yml @@ -0,0 +1,5 @@ +--- +title: Migrate from Vue event hub to Mitt in issuables list +merge_request: 31652 +author: Arun Kumar Mohan +type: changed diff --git a/changelogs/unreleased/improve_add_remove_labels_api.yml b/changelogs/unreleased/improve_add_remove_labels_api.yml new file mode 100644 index 00000000000..13b502edef7 --- /dev/null +++ b/changelogs/unreleased/improve_add_remove_labels_api.yml @@ -0,0 +1,5 @@ +--- +title: Add ability to add or remove MR labels via API +merge_request: 31522 +author: Lee Tickett +type: changed diff --git a/changelogs/unreleased/move-browser-performance-testing-to-rules-syntax.yml b/changelogs/unreleased/move-browser-performance-testing-to-rules-syntax.yml new file mode 100644 index 00000000000..4809d9f0de7 --- /dev/null +++ b/changelogs/unreleased/move-browser-performance-testing-to-rules-syntax.yml @@ -0,0 +1,5 @@ +--- +title: Move Browser-Perfomance-Testing.gitlab-ci.yml to `rules` syntax +merge_request: 31413 +author: +type: changed diff --git a/db/post_migrate/20200511145545_change_variable_interpolation_format_in_common_metrics.rb b/db/post_migrate/20200511145545_change_variable_interpolation_format_in_common_metrics.rb new file mode 100644 index 00000000000..ac3c545350d --- /dev/null +++ b/db/post_migrate/20200511145545_change_variable_interpolation_format_in_common_metrics.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ChangeVariableInterpolationFormatInCommonMetrics < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def up + ::Gitlab::DatabaseImporters::CommonMetrics::Importer.new.execute + end + + def down + # no-op + # The import cannot be reversed since we do not know the state that the + # common metrics in the PrometheusMetric table were in before the import. + end +end diff --git a/db/structure.sql b/db/structure.sql index 5f0da8d7558..ebaec8eee10 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -13765,5 +13765,6 @@ COPY "schema_migrations" (version) FROM STDIN; 20200506085748 20200506125731 20200507221434 +20200511145545 \. diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 86c558c582c..ffe73638dc1 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -1130,6 +1130,8 @@ PUT /projects/:id/merge_requests/:merge_request_iid | `assignee_ids` | integer array | no | The ID of the user(s) to assign the MR to. Set to `0` or provide an empty value to unassign all assignees. | | `milestone_id` | integer | no | The global ID of a milestone to assign the merge request to. Set to `0` or provide an empty value to unassign a milestone.| | `labels` | string | no | Comma-separated label names for a merge request. Set to an empty string to unassign all labels. | +| `add_labels` | string | no | Comma-separated label names to add to a merge request. | +| `remove_labels` | string | no | Comma-separated label names to remove from a merge request. | | `description` | string | no | Description of MR. Limited to 1,048,576 characters. | | `state_event` | string | no | New state (close/reopen) | | `remove_source_branch` | boolean | no | Flag indicating if a merge request should remove the source branch when merging | diff --git a/doc/api/packages.md b/doc/api/packages.md index c68c16e92a7..784343d29fd 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -188,7 +188,27 @@ Example response: "name": "Administrator", "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon" } - } + }, + "versions": [ + { + "id":2, + "version":"2.0-SNAPSHOT", + "created_at":"2020-04-28T04:42:11.573Z", + "pipeline": { + "id": 234, + "status": "pending", + "ref": "new-pipeline", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "web_url": "https://example.com/foo/bar/pipelines/58", + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + "user": { + "name": "Administrator", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon" + } + } + } + ] } ``` diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index d45786cdd3d..0284a055e2d 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -26,6 +26,8 @@ module API assignee_ids description labels + add_labels + remove_labels milestone_id remove_source_branch state_event @@ -180,6 +182,8 @@ module API optional :assignee_ids, type: Array[Integer], desc: 'The array of user IDs to assign issue' optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign the merge request' optional :labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names' + optional :add_labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names' + optional :remove_labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names' optional :remove_source_branch, type: Boolean, desc: 'Remove source branch when merging' optional :allow_collaboration, type: Boolean, desc: 'Allow commits from members who can merge to the target branch' optional :allow_maintainer_to_push, type: Boolean, as: :allow_collaboration, desc: '[deprecated] See allow_collaboration' diff --git a/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml index d85078c0a40..adbf9731e43 100644 --- a/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml @@ -30,11 +30,9 @@ performance: paths: - performance.json - sitespeed-results/ - only: - refs: - - branches - - tags - kubernetes: active - except: - variables: - - $PERFORMANCE_DISABLED + rules: + - if: '$CI_KUBERNETES_ACTIVE == null || $CI_KUBERNETES_ACTIVE == ""' + when: never + - if: '$PERFORMANCE_DISABLED' + when: never + - if: '$CI_COMMIT_TAG || $CI_COMMIT_BRANCH' diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 804f4e08f91..133c5a3659d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5692,12 +5692,18 @@ msgstr "" msgid "ContainerRegistry|Expiration schedule:" msgstr "" +msgid "ContainerRegistry|Filter by name" +msgstr "" + msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password." msgstr "" msgid "ContainerRegistry|Image ID" msgstr "" +msgid "ContainerRegistry|Image Repositories" +msgstr "" + msgid "ContainerRegistry|Keep and protect the images that matter most." msgstr "" @@ -5766,6 +5772,9 @@ msgstr "" msgid "ContainerRegistry|Something went wrong while updating the expiration policy." msgstr "" +msgid "ContainerRegistry|Sorry, your filter produced no results." +msgstr "" + msgid "ContainerRegistry|Tag" msgstr "" @@ -5817,6 +5826,9 @@ msgstr "" msgid "ContainerRegistry|This image repository is scheduled for deletion" msgstr "" +msgid "ContainerRegistry|To widen your search, change or remove the filters above." +msgstr "" + msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}" msgstr "" @@ -9308,9 +9320,6 @@ msgstr "" msgid "Filter" msgstr "" -msgid "Filter by %{issuable_type} that are currently archived." -msgstr "" - msgid "Filter by %{issuable_type} that are currently closed." msgstr "" @@ -9326,6 +9335,12 @@ msgstr "" msgid "Filter by name" msgstr "" +msgid "Filter by requirements that are currently archived." +msgstr "" + +msgid "Filter by requirements that are currently opened." +msgstr "" + msgid "Filter by status" msgstr "" @@ -19152,15 +19167,15 @@ msgstr "" msgid "Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account." msgstr "" -msgid "Show all %{issuable_type}." -msgstr "" - msgid "Show all activity" msgstr "" msgid "Show all members" msgstr "" +msgid "Show all requirements." +msgstr "" + msgid "Show archived projects" msgstr "" @@ -19535,6 +19550,9 @@ msgstr "" msgid "Something went wrong while fetching related merge requests." msgstr "" +msgid "Something went wrong while fetching requirements count." +msgstr "" + msgid "Something went wrong while fetching requirements list." msgstr "" @@ -21629,6 +21647,9 @@ msgstr "" msgid "This means you can not push code until you create an empty repository or import existing one." msgstr "" +msgid "This merge request does not have accessibility reports" +msgstr "" + msgid "This merge request is locked." msgstr "" @@ -22263,15 +22284,9 @@ msgstr "" msgid "Total artifacts size: %{total_size}" msgstr "" -msgid "Total cores (vCPUs)" -msgstr "" - msgid "Total issues" msgstr "" -msgid "Total memory (GB)" -msgstr "" - msgid "Total test time for all commits/merges" msgstr "" diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index 85c86b2c0a9..6ebaab5178c 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -1,46 +1,63 @@ -import Vuex from 'vuex'; -import { createLocalVue, mount } from '@vue/test-utils'; -import { GlTable, GlLoadingIcon } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; import Clusters from '~/clusters_list/components/clusters.vue'; -import mockData from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); +import ClusterStore from '~/clusters_list/store'; +import MockAdapter from 'axios-mock-adapter'; +import { apiData } from '../mock_data'; +import { mount } from '@vue/test-utils'; +import { GlTable, GlLoadingIcon } from '@gitlab/ui'; describe('Clusters', () => { + let mock; + let store; let wrapper; - const findTable = () => wrapper.find(GlTable); + const endpoint = 'some/endpoint'; + const findLoader = () => wrapper.find(GlLoadingIcon); + const findTable = () => wrapper.find(GlTable); const findStatuses = () => findTable().findAll('.js-status'); - const mountComponent = _state => { - const state = { clusters: mockData, endpoint: 'some/endpoint', ..._state }; - const store = new Vuex.Store({ - state, - }); + const mockPollingApi = (response, body, header) => { + mock.onGet(endpoint).reply(response, body, header); + }; - wrapper = mount(Clusters, { localVue, store }); + const mountWrapper = () => { + store = ClusterStore({ endpoint }); + wrapper = mount(Clusters, { store }); + return axios.waitForAll(); }; beforeEach(() => { - mountComponent({ loading: false }); + mock = new MockAdapter(axios); + mockPollingApi(200, apiData, {}); + + return mountWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); }); describe('clusters table', () => { - it('displays a loader instead of the table while loading', () => { - mountComponent({ loading: true }); - expect(findLoader().exists()).toBe(true); - expect(findTable().exists()).toBe(false); + describe('when data is loading', () => { + beforeEach(() => { + wrapper.vm.$store.state.loading = true; + return wrapper.vm.$nextTick(); + }); + + it('displays a loader instead of the table while loading', () => { + expect(findLoader().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + }); }); it('displays a table component', () => { expect(findTable().exists()).toBe(true); - expect(findTable().exists()).toBe(true); }); it('renders the correct table headers', () => { - const tableHeaders = wrapper.vm.$options.fields; + const tableHeaders = wrapper.vm.fields; const headers = findTable().findAll('th'); expect(headers.length).toBe(tableHeaders.length); @@ -62,7 +79,8 @@ describe('Clusters', () => { ${'unreachable'} | ${'bg-danger'} | ${1} ${'authentication_failure'} | ${'bg-warning'} | ${2} ${'deleting'} | ${null} | ${3} - ${'connected'} | ${'bg-success'} | ${4} + ${'created'} | ${'bg-success'} | ${4} + ${'default'} | ${'bg-white'} | ${5} `('renders a status for each cluster', ({ statusName, className, lineNumber }) => { const statuses = findStatuses(); const status = statuses.at(lineNumber); diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js index 5398975d81c..9a90a378f31 100644 --- a/spec/frontend/clusters_list/mock_data.js +++ b/spec/frontend/clusters_list/mock_data.js @@ -1,4 +1,4 @@ -export default [ +export const clusterList = [ { name: 'My Cluster 1', environmentScope: '*', @@ -40,8 +40,22 @@ export default [ environmentScope: 'development', size: '12', clusterType: 'project_type', - status: 'connected', + status: 'created', + cpu: '6 (100% free)', + memory: '20.12 (35% free)', + }, + { + name: 'My Cluster 6', + environmentScope: '*', + size: '1', + clusterType: 'project_type', + status: 'cleanup_ongoing', cpu: '6 (100% free)', memory: '20.12 (35% free)', }, ]; + +export const apiData = { + clusters: clusterList, + has_ancestor_clusters: false, +}; diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 46132f701be..2d164b7cc4a 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import flashError from '~/flash'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; +import { apiData } from '../mock_data'; import * as types from '~/clusters_list/store/mutation_types'; import * as actions from '~/clusters_list/store/actions'; @@ -10,8 +11,6 @@ jest.mock('~/flash.js'); describe('Clusters store actions', () => { describe('fetchClusters', () => { let mock; - const endpoint = '/clusters'; - const clusters = [{ name: 'test' }]; beforeEach(() => { mock = new MockAdapter(axios); @@ -20,14 +19,14 @@ describe('Clusters store actions', () => { afterEach(() => mock.restore()); it('should commit SET_CLUSTERS_DATA with received response', done => { - mock.onGet().reply(200, clusters); + mock.onGet().reply(200, apiData); testAction( actions.fetchClusters, - { endpoint }, + { endpoint: apiData.endpoint }, {}, [ - { type: types.SET_CLUSTERS_DATA, payload: clusters }, + { type: types.SET_CLUSTERS_DATA, payload: apiData }, { type: types.SET_LOADING_STATE, payload: false }, ], [], @@ -38,7 +37,7 @@ describe('Clusters store actions', () => { it('should show flash on API error', done => { mock.onGet().reply(400, 'Not Found'); - testAction(actions.fetchClusters, { endpoint }, {}, [], [], () => { + testAction(actions.fetchClusters, { endpoint: apiData.endpoint }, {}, [], [], () => { expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); done(); }); diff --git a/spec/frontend/design_management/utils/tracking_spec.js b/spec/frontend/design_management/utils/tracking_spec.js index ab540587c01..9fa5eae55b3 100644 --- a/spec/frontend/design_management/utils/tracking_spec.js +++ b/spec/frontend/design_management/utils/tracking_spec.js @@ -22,8 +22,9 @@ describe('Tracking Events', () => { label: eventName, value: { 'internal-object-refrerer': '', - 'version-number': 1, - 'current-version': false, + 'design-collection-owner': '', + 'design-version-number': 1, + 'design-is-current-version': false, }, }), ); @@ -32,7 +33,7 @@ describe('Tracking Events', () => { it('trackDesignDetailView allows to customize the value payload', () => { const trackingSpy = getTrackingSpy(eventKey); - trackDesignDetailView('from-a-test', 100, true); + trackDesignDetailView('from-a-test', 'test', 100, true); expect(trackingSpy).toHaveBeenCalledWith( eventKey, @@ -41,8 +42,9 @@ describe('Tracking Events', () => { label: eventName, value: { 'internal-object-refrerer': 'from-a-test', - 'version-number': 100, - 'current-version': true, + 'design-collection-owner': 'test', + 'design-version-number': 100, + 'design-is-current-version': true, }, }), ); diff --git a/spec/frontend/registry/explorer/components/image_list_spec.js b/spec/frontend/registry/explorer/components/image_list_spec.js new file mode 100644 index 00000000000..12f0fbe0c87 --- /dev/null +++ b/spec/frontend/registry/explorer/components/image_list_spec.js @@ -0,0 +1,74 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlPagination } from '@gitlab/ui'; +import Component from '~/registry/explorer/components/image_list.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { RouterLink } from '../stubs'; +import { imagesListResponse, imagePagination } from '../mock_data'; + +describe('Image List', () => { + let wrapper; + + const firstElement = imagesListResponse.data[0]; + + const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]'); + const findRowItems = () => wrapper.findAll('[data-testid="rowItem"]'); + const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]'); + const findClipboardButton = () => wrapper.find(ClipboardButton); + const findPagination = () => wrapper.find(GlPagination); + + const mountComponent = () => { + wrapper = shallowMount(Component, { + stubs: { + RouterLink, + }, + propsData: { + images: imagesListResponse.data, + pagination: imagePagination, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + it('contains one list element for each image', () => { + expect(findRowItems().length).toBe(imagesListResponse.data.length); + }); + + it('contains a link to the details page', () => { + const link = findDetailsLink(); + expect(link.html()).toContain(firstElement.path); + expect(link.props('to').name).toBe('details'); + }); + + it('contains a clipboard button', () => { + const button = findClipboardButton(); + expect(button.exists()).toBe(true); + expect(button.props('text')).toBe(firstElement.location); + expect(button.props('title')).toBe(firstElement.location); + }); + + it('should be possible to delete a repo', () => { + const deleteBtn = findDeleteBtn(); + expect(deleteBtn.exists()).toBe(true); + }); + + describe('pagination', () => { + it('exists', () => { + expect(findPagination().exists()).toBe(true); + }); + + it('is wired to the correct pagination props', () => { + const pagination = findPagination(); + expect(pagination.props('perPage')).toBe(imagePagination.perPage); + expect(pagination.props('totalItems')).toBe(imagePagination.total); + expect(pagination.props('value')).toBe(imagePagination.page); + }); + + it('emits a pageChange event when the page change', () => { + wrapper.setData({ currentPage: 2 }); + expect(wrapper.emitted('pageChange')).toEqual([[2]]); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index 2d8cd4e42bc..f6beccda9b1 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -87,3 +87,11 @@ export const tagsListResponse = { ], headers, }; + +export const imagePagination = { + perPage: 10, + page: 1, + total: 14, + totalPages: 2, + nextPage: 2, +}; diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index 1d530483093..97742b9e9b3 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -1,11 +1,13 @@ import { shallowMount } from '@vue/test-utils'; -import { GlPagination, GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; +import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui'; import Tracking from '~/tracking'; +import waitForPromises from 'helpers/wait_for_promises'; import component from '~/registry/explorer/pages/list.vue'; import QuickstartDropdown from '~/registry/explorer/components/quickstart_dropdown.vue'; import GroupEmptyState from '~/registry/explorer/components/group_empty_state.vue'; import ProjectEmptyState from '~/registry/explorer/components/project_empty_state.vue'; import ProjectPolicyAlert from '~/registry/explorer/components/project_policy_alert.vue'; +import ImageList from '~/registry/explorer/components/image_list.vue'; import { createStore } from '~/registry/explorer/stores/'; import { SET_MAIN_LOADING, @@ -16,9 +18,11 @@ import { import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, + IMAGE_REPOSITORY_LIST_LABEL, + SEARCH_PLACEHOLDER_TEXT, } from '~/registry/explorer/constants'; import { imagesListResponse } from '../mock_data'; -import { GlModal, GlEmptyState, RouterLink } from '../stubs'; +import { GlModal, GlEmptyState } from '../stubs'; import { $toast } from '../../shared/mocks'; describe('List Page', () => { @@ -26,20 +30,21 @@ describe('List Page', () => { let dispatchSpy; let store; - const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' }); const findDeleteModal = () => wrapper.find(GlModal); const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); const findImagesList = () => wrapper.find({ ref: 'imagesList' }); - const findRowItems = () => wrapper.findAll({ ref: 'rowItem' }); + const findEmptyState = () => wrapper.find(GlEmptyState); - const findDetailsLink = () => wrapper.find({ ref: 'detailsLink' }); - const findClipboardButton = () => wrapper.find({ ref: 'clipboardButton' }); - const findPagination = () => wrapper.find(GlPagination); + const findQuickStartDropdown = () => wrapper.find(QuickstartDropdown); const findProjectEmptyState = () => wrapper.find(ProjectEmptyState); const findGroupEmptyState = () => wrapper.find(GroupEmptyState); const findProjectPolicyAlert = () => wrapper.find(ProjectPolicyAlert); const findDeleteAlert = () => wrapper.find(GlAlert); + const findImageList = () => wrapper.find(ImageList); + const findListHeader = () => wrapper.find('[data-testid="listHeader"]'); + const findSearchBox = () => wrapper.find(GlSearchBoxByClick); + const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); const mountComponent = ({ mocks } = {}) => { wrapper = shallowMount(component, { @@ -48,7 +53,6 @@ describe('List Page', () => { GlModal, GlEmptyState, GlSprintf, - RouterLink, }, mocks: { $toast, @@ -164,6 +168,7 @@ describe('List Page', () => { beforeEach(() => { store.commit(SET_IMAGES_LIST_SUCCESS, []); mountComponent(); + return waitForPromises(); }); it('quick start is not visible', () => { @@ -191,54 +196,39 @@ describe('List Page', () => { it('quick start is not visible', () => { expect(findQuickStartDropdown().exists()).toBe(false); }); + + it('list header is not visible', () => { + expect(findListHeader().exists()).toBe(false); + }); }); }); describe('list is not empty', () => { - beforeEach(() => { - mountComponent(); - }); - - it('quick start is visible', () => { - expect(findQuickStartDropdown().exists()).toBe(true); - }); - - describe('listElement', () => { - let listElements; - let firstElement; - + describe('unfiltered state', () => { beforeEach(() => { - listElements = findRowItems(); - [firstElement] = store.state.images; + mountComponent(); }); - it('contains one list element for each image', () => { - expect(listElements.length).toBe(store.state.images.length); + it('quick start is visible', () => { + expect(findQuickStartDropdown().exists()).toBe(true); }); - it('contains a link to the details page', () => { - const link = findDetailsLink(); - expect(link.html()).toContain(firstElement.path); - expect(link.props('to').name).toBe('details'); + it('list component is visible', () => { + expect(findImageList().exists()).toBe(true); }); - it('contains a clipboard button', () => { - const button = findClipboardButton(); - expect(button.exists()).toBe(true); - expect(button.props('text')).toBe(firstElement.location); - expect(button.props('title')).toBe(firstElement.location); + it('list header is visible', () => { + const header = findListHeader(); + expect(header.exists()).toBe(true); + expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL); }); describe('delete image', () => { - it('should be possible to delete a repo', () => { - const deleteBtn = findDeleteBtn(); - expect(deleteBtn.exists()).toBe(true); - }); - + const itemToDelete = { path: 'bar' }; it('should call deleteItem when confirming deletion', () => { dispatchSpy.mockResolvedValue(); - findDeleteBtn().vm.$emit('click'); - expect(wrapper.vm.itemToDelete).not.toEqual({}); + findImageList().vm.$emit('delete', itemToDelete); + expect(wrapper.vm.itemToDelete).toEqual(itemToDelete); findDeleteModal().vm.$emit('ok'); expect(store.dispatch).toHaveBeenCalledWith( 'requestDeleteImage', @@ -248,8 +238,8 @@ describe('List Page', () => { it('should show a success alert when delete request is successful', () => { dispatchSpy.mockResolvedValue(); - findDeleteBtn().vm.$emit('click'); - expect(wrapper.vm.itemToDelete).not.toEqual({}); + findImageList().vm.$emit('delete', itemToDelete); + expect(wrapper.vm.itemToDelete).toEqual(itemToDelete); return wrapper.vm.handleDeleteImage().then(() => { const alert = findDeleteAlert(); expect(alert.exists()).toBe(true); @@ -261,8 +251,8 @@ describe('List Page', () => { it('should show an error alert when delete request fails', () => { dispatchSpy.mockRejectedValue(); - findDeleteBtn().vm.$emit('click'); - expect(wrapper.vm.itemToDelete).not.toEqual({}); + findImageList().vm.$emit('delete', itemToDelete); + expect(wrapper.vm.itemToDelete).toEqual(itemToDelete); return wrapper.vm.handleDeleteImage().then(() => { const alert = findDeleteAlert(); expect(alert.exists()).toBe(true); @@ -272,71 +262,93 @@ describe('List Page', () => { }); }); }); + }); - describe('pagination', () => { - it('exists', () => { - expect(findPagination().exists()).toBe(true); - }); + describe('search', () => { + it('has a search box element', () => { + mountComponent(); + const searchBox = findSearchBox(); + expect(searchBox.exists()).toBe(true); + expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT); + }); - it('is wired to the correct pagination props', () => { - const pagination = findPagination(); - expect(pagination.props('perPage')).toBe(store.state.pagination.perPage); - expect(pagination.props('totalItems')).toBe(store.state.pagination.total); - expect(pagination.props('value')).toBe(store.state.pagination.page); + it('performs a search', () => { + mountComponent(); + findSearchBox().vm.$emit('submit', 'foo'); + expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { + name: 'foo', }); + }); - it('fetch the data from the API when the v-model changes', () => { - dispatchSpy.mockReturnValue(); - wrapper.setData({ currentPage: 2 }); - return wrapper.vm.$nextTick().then(() => { - expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { page: 2 }); - }); + it('when search result is empty displays an empty search message', () => { + mountComponent(); + store.commit(SET_IMAGES_LIST_SUCCESS, []); + return wrapper.vm.$nextTick().then(() => { + expect(findEmptySearchMessage().exists()).toBe(true); }); }); }); - describe('modal', () => { - it('exists', () => { - expect(findDeleteModal().exists()).toBe(true); - }); - - it('contains a description with the path of the item to delete', () => { - wrapper.setData({ itemToDelete: { path: 'foo' } }); - return wrapper.vm.$nextTick().then(() => { - expect(findDeleteModal().html()).toContain('foo'); + describe('pagination', () => { + it('pageChange event triggers the appropriate store function', () => { + mountComponent(); + findImageList().vm.$emit('pageChange', 2); + expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { + pagination: { page: 2 }, + name: wrapper.vm.search, }); }); }); + }); - describe('tracking', () => { - const testTrackingCall = action => { - expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { - label: 'registry_repository_delete', - }); - }; + describe('modal', () => { + beforeEach(() => { + mountComponent(); + }); - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - dispatchSpy.mockResolvedValue(); - }); + it('exists', () => { + expect(findDeleteModal().exists()).toBe(true); + }); - it('send an event when delete button is clicked', () => { - const deleteBtn = findDeleteBtn(); - deleteBtn.vm.$emit('click'); - testTrackingCall('click_button'); + it('contains a description with the path of the item to delete', () => { + wrapper.setData({ itemToDelete: { path: 'foo' } }); + return wrapper.vm.$nextTick().then(() => { + expect(findDeleteModal().html()).toContain('foo'); }); + }); + }); - it('send an event when cancel is pressed on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('cancel'); - testTrackingCall('cancel_delete'); - }); + describe('tracking', () => { + beforeEach(() => { + mountComponent(); + }); - it('send an event when confirm is clicked on modal', () => { - const deleteModal = findDeleteModal(); - deleteModal.vm.$emit('ok'); - testTrackingCall('confirm_delete'); + const testTrackingCall = action => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { + label: 'registry_repository_delete', }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + dispatchSpy.mockResolvedValue(); + }); + + it('send an event when delete button is clicked', () => { + findImageList().vm.$emit('delete', {}); + testTrackingCall('click_button'); + }); + + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + testTrackingCall('cancel_delete'); + }); + + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + testTrackingCall('confirm_delete'); }); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 4e4f99f09e7..9ba429c3d20 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -920,8 +920,8 @@ describe('ReadyToMerge', () => { }); }); - describe('Commit message area', () => { - describe('when using merge commits', () => { + describe('Merge request project settings', () => { + describe('when the merge commit merge method is enabled', () => { beforeEach(() => { vm = createComponent({ mr: { ffOnlyEnabled: false }, @@ -937,7 +937,7 @@ describe('ReadyToMerge', () => { }); }); - describe('when fast-forward merge is enabled', () => { + describe('when the fast-forward merge method is enabled', () => { beforeEach(() => { vm = createComponent({ mr: { ffOnlyEnabled: true }, diff --git a/spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb new file mode 100644 index 00000000000..54c3500b0a0 --- /dev/null +++ b/spec/lib/gitlab/ci/templates/Jobs/browser_performance_testing_gitlab_ci_yaml_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Jobs/Browser-Performance-Testing.gitlab-ci.yml' do + subject(:template) do + <<~YAML + stages: + - test + - performance + + include: + - template: 'Jobs/Browser-Performance-Testing.gitlab-ci.yml' + + placeholder: + script: + - keep pipeline validator happy by having a job when stages are intentionally empty + YAML + end + + describe 'the created pipeline' do + let(:user) { create(:admin) } + let(:project) do + create(:project, :repository, variables: [ + build(:ci_variable, key: 'CI_KUBERNETES_ACTIVE', value: 'true') + ]) + end + + let(:default_branch) { 'master' } + let(:pipeline_ref) { default_branch } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) } + let(:pipeline) { service.execute!(:push) } + let(:build_names) { pipeline.builds.pluck(:name) } + + before do + stub_ci_pipeline_yaml_file(template) + + allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true) + allow(project).to receive(:default_branch).and_return(default_branch) + end + + it 'has no errors' do + expect(pipeline.errors).to be_empty + end + + shared_examples_for 'performance job on tag or branch' do + it 'by default' do + expect(build_names).to include('performance') + end + + it 'when PERFORMANCE_DISABLED' do + create(:ci_variable, project: project, key: 'PERFORMANCE_DISABLED', value: '1') + + expect(build_names).not_to include('performance') + end + end + + context 'on master' do + it_behaves_like 'performance job on tag or branch' + end + + context 'on another branch' do + let(:pipeline_ref) { 'feature' } + + it_behaves_like 'performance job on tag or branch' + end + + context 'on tag' do + let(:pipeline_ref) { 'v1.0.0' } + + it_behaves_like 'performance job on tag or branch' + end + + context 'on merge request' do + let(:service) { MergeRequests::CreatePipelineService.new(project, user) } + let(:merge_request) { create(:merge_request, :simple, source_project: project) } + let(:pipeline) { service.execute(merge_request) } + + it 'has no jobs' do + expect(pipeline).to be_merge_request_event + expect(build_names).to be_empty + end + end + end +end diff --git a/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb b/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb new file mode 100644 index 00000000000..f9e8a7ee6e9 --- /dev/null +++ b/spec/migrations/20200511145545_change_variable_interpolation_format_in_common_metrics_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20200511145545_change_variable_interpolation_format_in_common_metrics') + +describe ChangeVariableInterpolationFormatInCommonMetrics, :migration do + let(:prometheus_metrics) { table(:prometheus_metrics) } + + let!(:common_metric) do + prometheus_metrics.create!( + identifier: 'system_metrics_kubernetes_container_memory_total', + query: 'avg(sum(container_memory_usage_bytes{container_name!="POD",' \ + 'pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"})' \ + ' by (job)) without (job) /1024/1024/1024', + project_id: nil, + title: 'Memory Usage (Total)', + y_label: 'Total Memory Used (GB)', + unit: 'GB', + legend: 'Total (GB)', + group: -5, + common: true + ) + end + + it 'updates query to use {{}}' do + expected_query = 'avg(sum(container_memory_usage_bytes{container_name!="POD",' \ + 'pod_name=~"^{{ci_environment_slug}}-(.*)",namespace="{{kube_namespace}}"})' \ + ' by (job)) without (job) /1024/1024/1024' + + migrate! + + expect(common_metric.reload.query).to eq(expected_query) + end +end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index e8025fef877..5fe0a9052cf 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1889,6 +1889,62 @@ describe MergeRequest do end end + describe '#compare_accessibility_reports' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:merge_request, reload: true) { create(:merge_request, :with_accessibility_reports, source_project: project) } + let_it_be(:pipeline) { merge_request.head_pipeline } + + subject { merge_request.compare_accessibility_reports } + + context 'when head pipeline has accessibility reports' do + let(:job) do + create(:ci_build, options: { artifacts: { reports: { pa11y: ['accessibility.json'] } } }, pipeline: pipeline) + end + + let(:artifacts_metadata) { create(:ci_job_artifact, :metadata, job: job) } + + context 'when reactive cache worker is parsing results asynchronously' do + it 'returns parsing status' do + expect(subject[:status]).to eq(:parsing) + end + end + + context 'when reactive cache worker is inline' do + before do + synchronous_reactive_cache(merge_request) + end + + it 'returns parsed status' do + expect(subject[:status]).to eq(:parsed) + expect(subject[:data]).to be_present + end + + context 'when an error occurrs' do + before do + merge_request.update!(head_pipeline: nil) + end + + it 'returns an error status' do + expect(subject[:status]).to eq(:error) + expect(subject[:status_reason]).to eq("This merge request does not have accessibility reports") + end + end + + context 'when cached result is not latest' do + before do + allow_next_instance_of(Ci::CompareAccessibilityReportsService) do |service| + allow(service).to receive(:latest?).and_return(false) + end + end + + it 'raises an InvalidateReactiveCache error' do + expect { subject }.to raise_error(ReactiveCaching::InvalidateReactiveCache) + end + end + end + end + end + describe '#all_commit_shas' do context 'when merge request is persisted' do let(:all_commit_shas) do diff --git a/spec/presenters/clusterable_presenter_spec.rb b/spec/presenters/clusterable_presenter_spec.rb index 47ccc59ae45..2c0a7f3e9b2 100644 --- a/spec/presenters/clusterable_presenter_spec.rb +++ b/spec/presenters/clusterable_presenter_spec.rb @@ -87,4 +87,20 @@ describe ClusterablePresenter do it { is_expected.to be_nil } end + + describe '#index_path' do + let(:clusterable) { create(:group) } + + context 'without options' do + subject { described_class.new(clusterable).index_path } + + it { is_expected.to eq(group_clusters_path(clusterable)) } + end + + context 'with options' do + subject { described_class.new(clusterable).index_path(format: :json) } + + it { is_expected.to eq(group_clusters_path(clusterable, format: :json)) } + end + end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index af2ce7f7aef..d3999d1ef87 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -2308,6 +2308,33 @@ describe API::MergeRequests do end end + context 'with labels' do + include_context 'with labels' + + let(:api_base) { api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) } + + it 'when adding labels, keeps existing labels and adds new' do + put api_base, params: { add_labels: '1, 2' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['labels']).to contain_exactly(label.title, label2.title, '1', '2') + end + + it 'when removing labels, only removes those specified' do + put api_base, params: { remove_labels: "#{label.title}" } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['labels']).to eq([label2.title]) + end + + it 'when removing all labels, keeps no labels' do + put api_base, params: { remove_labels: "#{label.title}, #{label2.title}" } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['labels']).to be_empty + end + end + it 'does not update state when title is empty' do put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), params: { state_event: 'close', title: nil } |