Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-05-12 09:09:31 +00:00
parent 143f7be045
commit 0e1a6f6a2b
46 changed files with 887 additions and 347 deletions

View File

@ -1,4 +1,6 @@
import { inactiveListId } from '~/boards/constants';
export default () => ({
isShowingLabels: true,
activeListId: 0,
activeListId: inactiveListId,
});

View File

@ -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>

View File

@ -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') },

View File

@ -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

View File

@ -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,
});
},
};

View File

@ -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: [],
});

View File

@ -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);

View File

@ -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,
);

View File

@ -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]),
});
}

View File

@ -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();

View File

@ -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

View File

@ -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>

View File

@ -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');

View File

@ -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"
/>
</div>
<image-list
v-if="images.length"
:images="images"
:pagination="pagination"
@pageChange="requestImagesList({ pagination: { page: $event }, name: search })"
@delete="deleteImage"
/>
<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>

View File

@ -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 });
})

View File

@ -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;
}
}
}

View File

@ -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.

View File

@ -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 = {})

View File

@ -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

View File

@ -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)

View File

@ -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" }

View File

@ -0,0 +1,6 @@
---
title: Add migration to import changes to the system dashboard Prometheus queries
into DB
merge_request: 31618
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Add search bar to container registry image list
merge_request: 31322
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Migrate from Vue event hub to Mitt in issuables list
merge_request: 31652
author: Arun Kumar Mohan
type: changed

View File

@ -0,0 +1,5 @@
---
title: Add ability to add or remove MR labels via API
merge_request: 31522
author: Lee Tickett
type: changed

View File

@ -0,0 +1,5 @@
---
title: Move Browser-Perfomance-Testing.gitlab-ci.yml to `rules` syntax
merge_request: 31413
author:
type: changed

View File

@ -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

View File

@ -13765,5 +13765,6 @@ COPY "schema_migrations" (version) FROM STDIN;
20200506085748
20200506125731
20200507221434
20200511145545
\.

View File

@ -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 |

View File

@ -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"
}
}
}
]
}
```

View File

@ -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'

View File

@ -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'

View File

@ -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 ""

View File

@ -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);

View File

@ -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,
};

View File

@ -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();
});

View File

@ -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,
},
}),
);

View File

@ -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]]);
});
});
});

View File

@ -87,3 +87,11 @@ export const tagsListResponse = {
],
headers,
};
export const imagePagination = {
perPage: 10,
page: 1,
total: 14,
totalPages: 2,
nextPage: 2,
};

View File

@ -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);
});
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('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 });
});
});
});
});
describe('modal', () => {
it('exists', () => {
expect(findDeleteModal().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('contains a description with the path of the item to delete', () => {
wrapper.setData({ itemToDelete: { path: 'foo' } });
it('performs a search', () => {
mountComponent();
findSearchBox().vm.$emit('submit', 'foo');
expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
name: 'foo',
});
});
it('when search result is empty displays an empty search message', () => {
mountComponent();
store.commit(SET_IMAGES_LIST_SUCCESS, []);
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain('foo');
expect(findEmptySearchMessage().exists()).toBe(true);
});
});
});
describe('tracking', () => {
const testTrackingCall = action => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
label: 'registry_repository_delete',
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,
});
};
beforeEach(() => {
jest.spyOn(Tracking, 'event');
dispatchSpy.mockResolvedValue();
});
it('send an event when delete button is clicked', () => {
const deleteBtn = findDeleteBtn();
deleteBtn.vm.$emit('click');
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');
});
});
});
describe('modal', () => {
beforeEach(() => {
mountComponent();
});
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('tracking', () => {
beforeEach(() => {
mountComponent();
});
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');
});
});
});

View File

@ -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 },

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }