Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-05-26 12:10:41 +00:00
parent 47d07def16
commit 04f9cef437
100 changed files with 2179 additions and 897 deletions

View File

@ -334,3 +334,11 @@ Dangerfile @gl-quality/eng-prod
[Application Security]
/lib/gitlab/content_security_policy/ @gitlab-com/gl-security/appsec
[Gitaly]
lib/gitlab/git_access.rb @proglottis @toon @zj-gitlab
lib/gitlab/git_access_*.rb @proglottis @toon @zj-gitlab
ee/lib/ee/gitlab/git_access.rb @proglottis @toon @zj-gitlab
ee/lib/ee/gitlab/git_access_*.rb @proglottis @toon @zj-gitlab
ee/lib/ee/gitlab/checks/** @proglottis @toon @zj-gitlab
lib/gitlab/checks/** @proglottis @toon @zj-gitlab

View File

@ -1,14 +1,10 @@
<script>
import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab } from '@gitlab/ui';
import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon } from '@gitlab/ui';
export default {
components: { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTab },
components: { GlAlert, GlEmptyState, GlLink, GlLoadingIcon },
inject: ['errorStateSvgPath', 'featureFlagsHelpPagePath'],
props: {
title: {
required: true,
type: String,
},
count: {
required: false,
type: Number,
@ -56,18 +52,11 @@ export default {
clearAlert(index) {
this.$emit('dismissAlert', index);
},
onClick(event) {
return this.$emit('changeTab', event);
},
},
};
</script>
<template>
<gl-tab @click="onClick">
<template #title>
<span data-testid="feature-flags-tab-title">{{ title }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge">{{ itemCount }}</gl-badge>
</template>
<div>
<gl-alert
v-for="(message, index) in alerts"
:key="index"
@ -83,7 +72,7 @@ export default {
<gl-empty-state
v-else-if="errorState"
:title="errorTitle"
:description="s__(`FeatureFlags|Try again in a few moments or contact your support team.`)"
:description="s__('FeatureFlags|Try again in a few moments or contact your support team.')"
:svg-path="errorStateSvgPath"
data-testid="error-state"
/>
@ -101,6 +90,6 @@ export default {
</gl-link>
</template>
</gl-empty-state>
<slot> </slot>
</gl-tab>
<slot v-else> </slot>
</div>
</template>

View File

@ -1,5 +1,5 @@
<script>
import { GlAlert, GlButton, GlModalDirective, GlSprintf, GlTabs } from '@gitlab/ui';
import { GlAlert, GlBadge, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapActions } from 'vuex';
@ -9,50 +9,40 @@ import {
historyPushState,
} from '~/lib/utils/common_utils';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants';
import ConfigureFeatureFlagsModal from './configure_feature_flags_modal.vue';
import FeatureFlagsTab from './feature_flags_tab.vue';
import EmptyState from './empty_state.vue';
import FeatureFlagsTable from './feature_flags_table.vue';
import UserListsTable from './user_lists_table.vue';
const SCOPES = { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE };
export default {
components: {
ConfigureFeatureFlagsModal,
FeatureFlagsTab,
EmptyState,
FeatureFlagsTable,
GlAlert,
GlBadge,
GlButton,
GlSprintf,
GlTabs,
TablePagination,
UserListsTable,
},
directives: {
GlModal: GlModalDirective,
},
inject: {
newUserListPath: { default: '' },
userListPath: { default: '' },
newFeatureFlagPath: { default: '' },
canUserConfigure: {},
featureFlagsLimitExceeded: {},
featureFlagsLimit: {},
},
data() {
const scope = getParameterByName('scope') || SCOPES.FEATURE_FLAG_SCOPE;
return {
scope,
page: getParameterByName('page') || '1',
isUserListAlertDismissed: false,
shouldShowFeatureFlagsLimitWarning: this.featureFlagsLimitExceeded,
selectedTab: Object.values(SCOPES).indexOf(scope),
};
},
computed: {
...mapState([
FEATURE_FLAG_SCOPE,
USER_LIST_SCOPE,
'featureFlags',
'alerts',
'count',
'pageInfo',
@ -69,64 +59,41 @@ export default {
canUserRotateToken() {
return this.rotateInstanceIdPath !== '';
},
currentlyDisplayedData() {
return this.dataForScope(this.scope);
},
shouldRenderPagination() {
return (
!this.isLoading &&
!this.hasError &&
this.currentlyDisplayedData.length > 0 &&
this.pageInfo[this.scope].total > this.pageInfo[this.scope].perPage
this.featureFlags.length > 0 &&
this.pageInfo.total > this.pageInfo.perPage
);
},
shouldShowEmptyState() {
return !this.isLoading && !this.hasError && this.currentlyDisplayedData.length === 0;
return !this.isLoading && !this.hasError && this.featureFlags.length === 0;
},
shouldRenderErrorState() {
return this.hasError && !this.isLoading;
},
shouldRenderFeatureFlags() {
return this.shouldRenderTable(SCOPES.FEATURE_FLAG_SCOPE);
},
shouldRenderUserLists() {
return this.shouldRenderTable(SCOPES.USER_LIST_SCOPE);
return !this.isLoading && this.featureFlags.length > 0 && !this.hasError;
},
hasNewPath() {
return !isEmpty(this.newFeatureFlagPath);
},
},
created() {
this.setFeatureFlagsOptions({ scope: this.scope, page: this.page });
this.setFeatureFlagsOptions({ page: this.page });
this.fetchFeatureFlags();
this.fetchUserLists();
},
methods: {
...mapActions([
'setFeatureFlagsOptions',
'fetchFeatureFlags',
'fetchUserLists',
'rotateInstanceId',
'toggleFeatureFlag',
'deleteUserList',
'clearAlert',
]),
onChangeTab(scope) {
this.scope = scope;
this.updateFeatureFlagOptions({
scope,
page: '1',
});
},
onFeatureFlagsTab() {
this.onChangeTab(SCOPES.FEATURE_FLAG_SCOPE);
},
onUserListsTab() {
this.onChangeTab(SCOPES.USER_LIST_SCOPE);
},
onChangePage(page) {
this.updateFeatureFlagOptions({
scope: this.scope,
/* URLS parameters are strings, we need to parse to match types */
page: Number(page).toString(),
});
@ -141,22 +108,7 @@ export default {
historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
this.setFeatureFlagsOptions(parameters);
if (this.scope === SCOPES.FEATURE_FLAG_SCOPE) {
this.fetchFeatureFlags();
} else {
this.fetchUserLists();
}
},
shouldRenderTable(scope) {
return (
!this.isLoading &&
this.dataForScope(scope).length > 0 &&
!this.hasError &&
this.scope === scope
);
},
dataForScope(scope) {
return this[scope];
this.fetchFeatureFlags();
},
onDismissFeatureFlagsLimitWarning() {
this.shouldShowFeatureFlagsLimitWarning = false;
@ -199,6 +151,16 @@ export default {
/>
<div :class="topAreaBaseClasses">
<div class="gl-display-flex gl-flex-direction-column gl-md-display-none!">
<gl-button
v-if="userListPath"
:href="userListPath"
variant="confirm"
category="tertiary"
class="gl-mb-3"
data-testid="ff-new-list-button"
>
{{ s__('FeatureFlags|View user lists') }}
</gl-button>
<gl-button
v-if="canUserConfigure"
v-gl-modal="'configure-feature-flags'"
@ -211,17 +173,6 @@ export default {
{{ s__('FeatureFlags|Configure') }}
</gl-button>
<gl-button
v-if="newUserListPath"
:href="newUserListPath"
variant="confirm"
category="secondary"
class="gl-mb-3"
data-testid="ff-new-list-button"
>
{{ s__('FeatureFlags|New user list') }}
</gl-button>
<gl-button
v-if="hasNewPath"
:href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
@ -232,101 +183,70 @@ export default {
{{ s__('FeatureFlags|New feature flag') }}
</gl-button>
</div>
<gl-tabs v-model="selectedTab" class="gl-align-items-center gl-w-full">
<feature-flags-tab
:title="s__('FeatureFlags|Feature Flags')"
:count="count.featureFlags"
:alerts="alerts"
:is-loading="isLoading"
:loading-label="s__('FeatureFlags|Loading feature flags')"
:error-state="shouldRenderErrorState"
:error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)"
:empty-state="shouldShowEmptyState"
:empty-title="s__('FeatureFlags|Get started with feature flags')"
:empty-description="
s__(
'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
)
"
data-testid="feature-flags-tab"
@dismissAlert="clearAlert"
@changeTab="onFeatureFlagsTab"
<div
class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
>
<div class="gl-display-flex gl-align-items-center">
<h2 data-testid="feature-flags-tab-title" class="gl-font-size-h2 gl-my-0">
{{ s__('FeatureFlags|Feature Flags') }}
</h2>
<gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge>
</div>
<div
class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"
>
<feature-flags-table
v-if="shouldRenderFeatureFlags"
:feature-flags="featureFlags"
@toggle-flag="toggleFeatureFlag"
/>
</feature-flags-tab>
<feature-flags-tab
:title="s__('FeatureFlags|User Lists')"
:count="count.userLists"
:alerts="alerts"
:is-loading="isLoading"
:loading-label="s__('FeatureFlags|Loading user lists')"
:error-state="shouldRenderErrorState"
:error-title="s__(`FeatureFlags|There was an error fetching the user lists.`)"
:empty-state="shouldShowEmptyState"
:empty-title="s__('FeatureFlags|Get started with user lists')"
:empty-description="
s__(
'FeatureFlags|User lists allow you to define a set of users to use with Feature Flags.',
)
"
data-testid="user-lists-tab"
@dismissAlert="clearAlert"
@changeTab="onUserListsTab"
>
<user-lists-table
v-if="shouldRenderUserLists"
:user-lists="userLists"
@delete="deleteUserList"
/>
</feature-flags-tab>
<template #tabs-end>
<li
class="gl-display-none gl-md-display-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end"
<gl-button
v-if="userListPath"
:href="userListPath"
variant="confirm"
category="tertiary"
class="gl-mb-0 gl-mr-4"
data-testid="ff-user-list-button"
>
<gl-button
v-if="canUserConfigure"
v-gl-modal="'configure-feature-flags'"
variant="info"
category="secondary"
data-qa-selector="configure_feature_flags_button"
data-testid="ff-configure-button"
class="gl-mb-0 gl-mr-4"
>
{{ s__('FeatureFlags|Configure') }}
</gl-button>
{{ s__('FeatureFlags|View user lists') }}
</gl-button>
<gl-button
v-if="canUserConfigure"
v-gl-modal="'configure-feature-flags'"
variant="info"
category="secondary"
data-qa-selector="configure_feature_flags_button"
data-testid="ff-configure-button"
class="gl-mb-0 gl-mr-4"
>
{{ s__('FeatureFlags|Configure') }}
</gl-button>
<gl-button
v-if="newUserListPath"
:href="newUserListPath"
variant="confirm"
category="secondary"
class="gl-mb-0 gl-mr-4"
data-testid="ff-new-list-button"
>
{{ s__('FeatureFlags|New user list') }}
</gl-button>
<gl-button
v-if="hasNewPath"
:href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
variant="confirm"
data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
>
{{ s__('FeatureFlags|New feature flag') }}
</gl-button>
</li>
</template>
</gl-tabs>
<gl-button
v-if="hasNewPath"
:href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath"
variant="confirm"
data-testid="ff-new-button"
@click="onNewFeatureFlagCLick"
>
{{ s__('FeatureFlags|New feature flag') }}
</gl-button>
</div>
</div>
<empty-state
:alerts="alerts"
:is-loading="isLoading"
:loading-label="s__('FeatureFlags|Loading feature flags')"
:error-state="shouldRenderErrorState"
:error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)"
:empty-state="shouldShowEmptyState"
:empty-title="s__('FeatureFlags|Get started with feature flags')"
:empty-description="
s__(
'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
)
"
data-testid="feature-flags-tab"
@dismissAlert="clearAlert"
>
<feature-flags-table :feature-flags="featureFlags" @toggle-flag="toggleFeatureFlag" />
</empty-state>
</div>
<table-pagination
v-if="shouldRenderPagination"
:change="onChangePage"
:page-info="pageInfo[scope]"
/>
<table-pagination v-if="shouldRenderPagination" :change="onChangePage" :page-info="pageInfo" />
</div>
</template>

View File

@ -21,9 +21,6 @@ export const fetchUserIdParams = property(['parameters', 'userIds']);
export const NEW_VERSION_FLAG = 'new_version_flag';
export const LEGACY_FLAG = 'legacy_flag';
export const FEATURE_FLAG_SCOPE = 'featureFlags';
export const USER_LIST_SCOPE = 'userLists';
export const EMPTY_PARAMETERS = { parameters: {}, userListId: undefined };
export const STRATEGY_SELECTIONS = [

View File

@ -22,7 +22,7 @@ export default () => {
unleashApiUrl,
canUserAdminFeatureFlag,
newFeatureFlagPath,
newUserListPath,
userListPath,
featureFlagsLimitExceeded,
featureFlagsLimit,
} = el.dataset;
@ -40,9 +40,9 @@ export default () => {
csrfToken: csrf.token,
canUserConfigure: canUserAdminFeatureFlag !== undefined,
newFeatureFlagPath,
newUserListPath,
featureFlagsLimitExceeded: featureFlagsLimitExceeded !== undefined,
featureFlagsLimit,
userListPath,
},
render(createElement) {
return createElement(FeatureFlagsComponent);

View File

@ -1,4 +1,3 @@
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
@ -26,19 +25,6 @@ export const receiveFeatureFlagsSuccess = ({ commit }, response) =>
commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response);
export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR);
export const fetchUserLists = ({ state, dispatch }) => {
dispatch('requestUserLists');
return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page)
.then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers }))
.catch(() => dispatch('receiveUserListsError'));
};
export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS);
export const receiveUserListsSuccess = ({ commit }, response) =>
commit(types.RECEIVE_USER_LISTS_SUCCESS, response);
export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR);
export const toggleFeatureFlag = ({ dispatch }, flag) => {
dispatch('updateFeatureFlag', flag);
@ -57,26 +43,6 @@ export const receiveUpdateFeatureFlagSuccess = ({ commit }, data) =>
export const receiveUpdateFeatureFlagError = ({ commit }, id) =>
commit(types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, id);
export const deleteUserList = ({ state, dispatch }, list) => {
dispatch('requestDeleteUserList', list);
return Api.deleteFeatureFlagUserList(state.projectId, list.iid)
.then(() => dispatch('fetchUserLists'))
.catch((error) =>
dispatch('receiveDeleteUserListError', {
list,
error: error?.response?.data ?? error,
}),
);
};
export const requestDeleteUserList = ({ commit }, list) =>
commit(types.REQUEST_DELETE_USER_LIST, list);
export const receiveDeleteUserListError = ({ commit }, { error, list }) => {
commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list });
};
export const rotateInstanceId = ({ state, dispatch }) => {
dispatch('requestRotateInstanceId');

View File

@ -4,13 +4,6 @@ export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS';
export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS';
export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR';
export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS';
export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS';
export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR';
export const REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST';
export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR';
export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG';
export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS';
export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR';

View File

@ -1,17 +1,16 @@
import Vue from 'vue';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../constants';
import { mapToScopesViewModel } from '../helpers';
import * as types from './mutation_types';
const mapFlag = (flag) => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) });
const updateFlag = (state, flag) => {
const index = state[FEATURE_FLAG_SCOPE].findIndex(({ id }) => id === flag.id);
Vue.set(state[FEATURE_FLAG_SCOPE], index, flag);
const index = state.featureFlags.findIndex(({ id }) => id === flag.id);
Vue.set(state.featureFlags, index, flag);
};
const createPaginationInfo = (state, headers) => {
const createPaginationInfo = (headers) => {
let paginationInfo;
if (Object.keys(headers).length) {
const normalizedHeaders = normalizeHeaders(headers);
@ -32,44 +31,16 @@ export default {
[types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) {
state.isLoading = false;
state.hasError = false;
state[FEATURE_FLAG_SCOPE] = (response.data.feature_flags || []).map(mapFlag);
state.featureFlags = (response.data.feature_flags || []).map(mapFlag);
const paginationInfo = createPaginationInfo(state, response.headers);
state.count = {
...state.count,
[FEATURE_FLAG_SCOPE]: paginationInfo?.total ?? state[FEATURE_FLAG_SCOPE].length,
};
state.pageInfo = {
...state.pageInfo,
[FEATURE_FLAG_SCOPE]: paginationInfo,
};
const paginationInfo = createPaginationInfo(response.headers);
state.count = paginationInfo?.total ?? state.featureFlags.length;
state.pageInfo = paginationInfo;
},
[types.RECEIVE_FEATURE_FLAGS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
},
[types.REQUEST_USER_LISTS](state) {
state.isLoading = true;
},
[types.RECEIVE_USER_LISTS_SUCCESS](state, response) {
state.isLoading = false;
state.hasError = false;
state[USER_LIST_SCOPE] = response.data || [];
const paginationInfo = createPaginationInfo(state, response.headers);
state.count = {
...state.count,
[USER_LIST_SCOPE]: paginationInfo?.total ?? state[USER_LIST_SCOPE].length,
};
state.pageInfo = {
...state.pageInfo,
[USER_LIST_SCOPE]: paginationInfo,
};
},
[types.RECEIVE_USER_LISTS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
},
[types.REQUEST_ROTATE_INSTANCE_ID](state) {
state.isRotating = true;
state.hasRotateError = false;
@ -90,18 +61,9 @@ export default {
updateFlag(state, mapFlag(data));
},
[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) {
const flag = state[FEATURE_FLAG_SCOPE].find(({ id }) => i === id);
const flag = state.featureFlags.find(({ id }) => i === id);
updateFlag(state, { ...flag, active: !flag.active });
},
[types.REQUEST_DELETE_USER_LIST](state, list) {
state.userLists = state.userLists.filter((l) => l !== list);
},
[types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) {
state.isLoading = false;
state.hasError = false;
state.alerts = [].concat(error.message);
state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid);
},
[types.RECEIVE_CLEAR_ALERT](state, index) {
state.alerts.splice(index, 1);
},

View File

@ -1,11 +1,8 @@
import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../constants';
export default ({ endpoint, projectId, unleashApiInstanceId, rotateInstanceIdPath }) => ({
[FEATURE_FLAG_SCOPE]: [],
[USER_LIST_SCOPE]: [],
featureFlags: [],
alerts: [],
count: {},
pageInfo: { [FEATURE_FLAG_SCOPE]: {}, [USER_LIST_SCOPE]: {} },
count: 0,
pageInfo: {},
isLoading: true,
hasError: false,
endpoint,

View File

@ -0,0 +1,25 @@
/* eslint-disable no-new */
import Vue from 'vue';
import Vuex from 'vuex';
import UserLists from '~/user_lists/components/user_lists.vue';
import createStore from '~/user_lists/store/index';
Vue.use(Vuex);
const el = document.querySelector('#js-user-lists');
const { featureFlagsHelpPagePath, errorStateSvgPath, projectId, newUserListPath } = el.dataset;
new Vue({
el,
store: createStore({ projectId }),
provide: {
featureFlagsHelpPagePath,
errorStateSvgPath,
newUserListPath,
},
render(createElement) {
return createElement(UserLists);
},
});

View File

@ -146,7 +146,6 @@ export default {
<template>
<gl-dropdown
v-if="showBranchSwitcher"
class="gl-ml-2"
:header-text="$options.i18n.dropdownHeader"
:text="currentBranch"
icon="branch"

View File

@ -15,7 +15,7 @@ export default {
};
</script>
<template>
<div class="gl-mb-5">
<div class="gl-mb-4">
<branch-switcher v-if="showBranchSwitcher" v-on="$listeners" />
</div>
</template>

View File

@ -0,0 +1,143 @@
<script>
import { GlFilteredSearchToken } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import {
STATUS_ACTIVE,
STATUS_PAUSED,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NOT_CONNECTED,
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
CREATED_DESC,
CREATED_ASC,
CONTACTED_DESC,
CONTACTED_ASC,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
} from '../constants';
const searchTokens = [
{
icon: 'status',
title: __('Status'),
type: PARAM_KEY_STATUS,
token: GlFilteredSearchToken,
// TODO Get more than one value when GraphQL API supports OR for "status"
unique: true,
options: [
{ value: STATUS_ACTIVE, title: s__('Runners|Active') },
{ value: STATUS_PAUSED, title: s__('Runners|Paused') },
{ value: STATUS_ONLINE, title: s__('Runners|Online') },
{ value: STATUS_OFFLINE, title: s__('Runners|Offline') },
// Added extra quotes in this title to avoid splitting this value:
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
{ value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
],
// TODO In principle we could support more complex search rules,
// this can be added to a separate issue.
operators: OPERATOR_IS_ONLY,
},
{
icon: 'file-tree',
title: __('Type'),
type: PARAM_KEY_RUNNER_TYPE,
token: GlFilteredSearchToken,
// TODO Get more than one value when GraphQL API supports OR for "status"
unique: true,
options: [
{ value: INSTANCE_TYPE, title: s__('Runners|shared') },
{ value: GROUP_TYPE, title: s__('Runners|group') },
{ value: PROJECT_TYPE, title: s__('Runners|specific') },
],
// TODO We should support more complex search rules,
// search for multiple states (OR) or have NOT operators
operators: OPERATOR_IS_ONLY,
},
// TODO Support tags
];
const sortOptions = [
{
id: 1,
title: __('Created date'),
sortDirection: {
descending: CREATED_DESC,
ascending: CREATED_ASC,
},
},
{
id: 2,
title: __('Last contact'),
sortDirection: {
descending: CONTACTED_DESC,
ascending: CONTACTED_ASC,
},
},
];
export default {
components: {
FilteredSearch,
},
props: {
value: {
type: Object,
required: true,
validator(val) {
return Array.isArray(val?.filters) && typeof val?.sort === 'string';
},
},
},
data() {
// filtered_search_bar_root.vue may mutate the inital
// filters. Use `cloneDeep` to prevent those mutations
// from affecting this component
const { filters, sort } = cloneDeep(this.value);
return {
initialFilterValue: filters,
initialSortBy: sort,
};
},
methods: {
onFilter(filters) {
const { sort } = this.value;
this.$emit('input', {
filters,
sort,
});
},
onSort(sort) {
const { filters } = this.value;
this.$emit('input', {
filters,
sort,
});
},
},
sortOptions,
searchTokens,
};
</script>
<template>
<filtered-search
v-bind="$attrs"
recent-searches-storage-key="runners-search"
:sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy"
:tokens="$options.searchTokens"
:search-input-placeholder="__('Search or filter results...')"
@onFilter="onFilter"
@onSort="onSort"
/>
</template>

View File

@ -95,8 +95,8 @@ export default {
stacked="md"
fixed
>
<template #table-busy>
<gl-skeleton-loader />
<template v-if="!runners.length" #table-busy>
<gl-skeleton-loader v-for="i in 4" :key="i" />
</template>
<template #cell(type)="{ item }">

View File

@ -4,8 +4,33 @@ export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
export const RUNNER_ENTITY_TYPE = 'Ci::Runner';
// Filtered search parameter names
// - Used for URL params names
// - GlFilteredSearch tokens type
export const PARAM_KEY_STATUS = 'status';
export const PARAM_KEY_RUNNER_TYPE = 'runner_type';
export const PARAM_KEY_SORT = 'sort';
// CiRunnerType
export const INSTANCE_TYPE = 'INSTANCE_TYPE';
export const GROUP_TYPE = 'GROUP_TYPE';
export const PROJECT_TYPE = 'PROJECT_TYPE';
// CiRunnerStatus
export const STATUS_ACTIVE = 'ACTIVE';
export const STATUS_PAUSED = 'PAUSED';
export const STATUS_ONLINE = 'ONLINE';
export const STATUS_OFFLINE = 'OFFLINE';
export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED';
// CiRunnerSort
export const CREATED_DESC = 'CREATED_DESC';
export const CREATED_ASC = 'CREATED_ASC'; // TODO Add this to the API
export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API
export const CONTACTED_ASC = 'CONTACTED_ASC';
export const DEFAULT_SORT = CREATED_DESC;

View File

@ -1,5 +1,5 @@
query getRunners {
runners {
query getRunners($status: CiRunnerStatus, $type: CiRunnerType, $sort: CiRunnerSort) {
runners(status: $status, type: $type, sort: $sort) {
nodes {
id
description

View File

@ -0,0 +1,72 @@
import { queryToObject, setUrlParams } from '~/lib/utils/url_utility';
import {
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_SORT,
DEFAULT_SORT,
} from '../constants';
const getValuesFromFilters = (paramKey, filters) => {
return filters
.filter(({ type, value }) => type === paramKey && value.operator === '=')
.map(({ value }) => value.data);
};
const getFilterFromParams = (paramKey, params) => {
const value = params[paramKey];
if (!value) {
return [];
}
const values = Array.isArray(value) ? value : [value];
return values.map((data) => {
return {
type: paramKey,
value: {
data,
operator: '=',
},
};
});
};
export const fromUrlQueryToSearch = (query = window.location.search) => {
const params = queryToObject(query, { gatherArrays: true });
return {
filters: [
...getFilterFromParams(PARAM_KEY_STATUS, params),
...getFilterFromParams(PARAM_KEY_RUNNER_TYPE, params),
],
sort: params[PARAM_KEY_SORT] || DEFAULT_SORT,
};
};
export const fromSearchToUrl = ({ filters = [], sort = null }, url = window.location.href) => {
const urlParams = {
[PARAM_KEY_STATUS]: getValuesFromFilters(PARAM_KEY_STATUS, filters),
[PARAM_KEY_RUNNER_TYPE]: getValuesFromFilters(PARAM_KEY_RUNNER_TYPE, filters),
};
if (sort && sort !== DEFAULT_SORT) {
urlParams[PARAM_KEY_SORT] = sort;
}
return setUrlParams(urlParams, url, false, true, true);
};
export const fromSearchToVariables = ({ filters = [], sort = null } = {}) => {
const variables = {};
// TODO Get more than one value when GraphQL API supports OR for "status"
[variables.status] = getValuesFromFilters(PARAM_KEY_STATUS, filters);
// TODO Get more than one value when GraphQL API supports OR for "runner type"
[variables.type] = getValuesFromFilters(PARAM_KEY_RUNNER_TYPE, filters);
if (sort) {
variables.sort = sort;
}
return variables;
};

View File

@ -1,12 +1,20 @@
<script>
import * as Sentry from '@sentry/browser';
import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
} from './filtered_search_utils';
export default {
components: {
RunnerFilteredSearchBar,
RunnerList,
RunnerManualSetupHelp,
RunnerTypeHelp,
@ -23,12 +31,16 @@ export default {
},
data() {
return {
search: fromUrlQueryToSearch(),
runners: [],
};
},
apollo: {
runners: {
query: getRunnersQuery,
variables() {
return this.variables;
},
update({ runners }) {
return runners?.nodes || [];
},
@ -38,6 +50,9 @@ export default {
},
},
computed: {
variables() {
return fromSearchToVariables(this.search);
},
runnersLoading() {
return this.$apollo.queries.runners.loading;
},
@ -45,6 +60,16 @@ export default {
return !this.runnersLoading && !this.runners.length;
},
},
watch: {
search() {
// TODO Implement back button reponse using onpopstate
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
});
},
},
errorCaptured(err) {
this.captureException(err);
},
@ -69,6 +94,8 @@ export default {
</div>
</div>
<runner-filtered-search-bar v-model="search" namespace="admin_runners" />
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>

View File

@ -0,0 +1,120 @@
<script>
import { GlBadge, GlButton } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapState, mapActions } from 'vuex';
import EmptyState from '~/feature_flags/components/empty_state.vue';
import {
buildUrlWithCurrentLocation,
getParameterByName,
historyPushState,
} from '~/lib/utils/common_utils';
import { objectToQuery } from '~/lib/utils/url_utility';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import UserListsTable from './user_lists_table.vue';
export default {
components: {
EmptyState,
UserListsTable,
GlBadge,
GlButton,
TablePagination,
},
inject: {
newUserListPath: { default: '' },
},
data() {
return {
page: getParameterByName('page') || '1',
};
},
computed: {
...mapState(['userLists', 'alerts', 'count', 'pageInfo', 'isLoading', 'hasError', 'options']),
canUserRotateToken() {
return this.rotateInstanceIdPath !== '';
},
shouldRenderPagination() {
return (
!this.isLoading &&
!this.hasError &&
this.userLists.length > 0 &&
this.pageInfo.total > this.pageInfo.perPage
);
},
shouldShowEmptyState() {
return !this.isLoading && !this.hasError && this.userLists.length === 0;
},
shouldRenderErrorState() {
return this.hasError && !this.isLoading;
},
shouldRenderUserLists() {
return !this.isLoading && this.userLists.length > 0 && !this.hasError;
},
hasNewPath() {
return !isEmpty(this.newUserListPath);
},
},
created() {
this.setUserListsOptions({ page: this.page });
this.fetchUserLists();
},
methods: {
...mapActions(['setUserListsOptions', 'fetchUserLists', 'clearAlert', 'deleteUserList']),
onChangePage(page) {
this.updateUserListsOptions({
/* URLS parameters are strings, we need to parse to match types */
page: Number(page).toString(),
});
},
updateUserListsOptions(parameters) {
const queryString = objectToQuery(parameters);
historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
this.setUserListsOptions(parameters);
this.fetchUserLists();
},
},
};
</script>
<template>
<div>
<div class="gl-display-flex gl-flex-direction-column">
<div class="gl-display-flex gl-flex-direction-column gl-md-display-none!">
<gl-button v-if="hasNewPath" :href="newUserListPath" variant="confirm">
{{ s__('UserLists|New user list') }}
</gl-button>
</div>
<div
class="gl-display-flex gl-align-items-baseline gl-flex-direction-column gl-md-flex-direction-row gl-justify-content-space-between gl-mt-6"
>
<div class="gl-display-flex gl-align-items-center">
<h2 class="gl-font-size-h2 gl-my-0">
{{ s__('UserLists|User Lists') }}
</h2>
<gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge>
</div>
<div class="gl-display-flex gl-align-items-center gl-justify-content-end">
<gl-button v-if="hasNewPath" :href="newUserListPath" variant="confirm">
{{ s__('UserLists|New user list') }}
</gl-button>
</div>
</div>
<empty-state
:alerts="alerts"
:is-loading="isLoading"
:loading-label="s__('UserLists|Loading user lists')"
:error-state="shouldRenderErrorState"
:error-title="s__('UserLists|There was an error fetching the user lists.')"
:empty-state="shouldShowEmptyState"
:empty-title="s__('UserLists|Get started with user lists')"
:empty-description="
s__('UserLists|User lists allow you to define a set of users to use with Feature Flags.')
"
@dismissAlert="clearAlert"
>
<user-lists-table :user-lists="userLists" @delete="deleteUserList" />
</empty-state>
</div>
<table-pagination v-if="shouldRenderPagination" :change="onChangePage" :page-info="pageInfo" />
</div>
</template>

View File

@ -0,0 +1,38 @@
import Api from '~/api';
import * as types from './mutation_types';
export const setUserListsOptions = ({ commit }, options) =>
commit(types.SET_USER_LISTS_OPTIONS, options);
export const fetchUserLists = ({ state, dispatch }) => {
dispatch('requestUserLists');
return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page)
.then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers }))
.catch(() => dispatch('receiveUserListsError'));
};
export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS);
export const receiveUserListsSuccess = ({ commit }, response) =>
commit(types.RECEIVE_USER_LISTS_SUCCESS, response);
export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR);
export const deleteUserList = ({ state, dispatch }, list) => {
dispatch('requestDeleteUserList', list);
return Api.deleteFeatureFlagUserList(state.projectId, list.iid)
.then(() => dispatch('fetchUserLists'))
.catch((error) =>
dispatch('receiveDeleteUserListError', {
list,
error: error?.response?.data ?? error,
}),
);
};
export const requestDeleteUserList = ({ commit }, list) =>
commit(types.REQUEST_DELETE_USER_LIST, list);
export const receiveDeleteUserListError = ({ commit }, { error, list }) =>
commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list });
export const clearAlert = ({ commit }, index) => commit(types.RECEIVE_CLEAR_ALERT, index);

View File

@ -0,0 +1,11 @@
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import createState from './state';
export default (initialState) =>
new Vuex.Store({
actions,
mutations,
state: createState(initialState),
});

View File

@ -0,0 +1,10 @@
export const SET_USER_LISTS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS';
export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS';
export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS';
export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR';
export const REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST';
export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR';
export const RECEIVE_CLEAR_ALERT = 'RECEIVE_CLEAR_ALERT';

View File

@ -0,0 +1,37 @@
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
export default {
[types.SET_USER_LISTS_OPTIONS](state, options = {}) {
state.options = options;
},
[types.REQUEST_USER_LISTS](state) {
state.isLoading = true;
},
[types.RECEIVE_USER_LISTS_SUCCESS](state, { data, headers }) {
state.isLoading = false;
state.hasError = false;
state.userLists = data || [];
const normalizedHeaders = normalizeHeaders(headers);
const paginationInfo = parseIntPagination(normalizedHeaders);
state.count = paginationInfo?.total ?? state.userLists.length;
state.pageInfo = paginationInfo;
},
[types.RECEIVE_USER_LISTS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
},
[types.REQUEST_DELETE_USER_LIST](state, list) {
state.userLists = state.userLists.filter((l) => l !== list);
},
[types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) {
state.isLoading = false;
state.hasError = false;
state.alerts = [].concat(error.message);
state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid);
},
[types.RECEIVE_CLEAR_ALERT](state, index) {
state.alerts.splice(index, 1);
},
};

View File

@ -0,0 +1,10 @@
export default ({ projectId }) => ({
userLists: [],
alerts: [],
count: 0,
pageInfo: {},
isLoading: true,
hasError: false,
options: {},
projectId,
});

View File

@ -6,6 +6,9 @@ class Projects::FeatureFlagsUserListsController < Projects::ApplicationControlle
feature_category :feature_flags
def index
end
def new
end

View File

@ -83,13 +83,17 @@ module PreferencesHelper
def integration_views
[].tap do |views|
views << { name: 'gitpod', message: gitpod_enable_description, message_url: 'https://gitpod.io/', help_link: help_page_path('integration/gitpod.md') } if Gitlab::CurrentSettings.gitpod_enabled
views << { name: 'gitpod', message: gitpod_enable_description, message_url: gitpod_url_placeholder, help_link: help_page_path('integration/gitpod.md') } if Gitlab::CurrentSettings.gitpod_enabled
views << { name: 'sourcegraph', message: sourcegraph_url_message, message_url: Gitlab::CurrentSettings.sourcegraph_url, help_link: help_page_path('user/profile/preferences.md', anchor: 'sourcegraph') } if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled
end
end
private
def gitpod_url_placeholder
Gitlab::CurrentSettings.gitpod_url.presence || 'https://gitpod.io/'
end
# Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too
def validate_dashboard_choices!(user_dashboards)
if user_dashboards.size != localized_dashboard_choices.size

View File

@ -53,18 +53,10 @@ module Emails
return unless member_exists?
subject_line =
if member.created_by
subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name })
else
subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular })
end
member_email_with_layout(
to: member.invite_email,
subject: subject_line,
layout: 'unknown_user_mailer'
)
mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format|
format.html { render layout: 'unknown_user_mailer' }
format.text { render layout: 'unknown_user_mailer' }
end
end
def member_invited_reminder_email(member_source_type, member_id, token, reminder_index)
@ -149,6 +141,25 @@ module Emails
private
def invite_email_subject
if member.created_by
subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name })
else
subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular })
end
end
def invite_email_headers
if Gitlab.dev_env_or_com?
{
'X-Mailgun-Tag' => 'invite_email',
'X-Mailgun-Variables' => { 'invite_token' => @token }.to_json
}
else
{}
end
end
def member_exists?
Gitlab::AppLogger.info("Tried to send an email invitation for a deleted group. Member id: #{@member_id}") if member.blank?
member.present?

View File

@ -146,7 +146,7 @@ class NotifyPreview < ActionMailer::Preview
end
def member_invited_email
Notify.member_invited_email('project', user.id, '1234').message
Notify.member_invited_email('project', member.id, '1234').message
end
def pages_domain_enabled_email

View File

@ -271,7 +271,8 @@ class Namespace < ApplicationRecord
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
namespace = user? ? self : self_and_descendants
namespace = user? ? self : self_and_descendant_ids
Project.where(namespace: namespace)
end

View File

@ -46,6 +46,12 @@ module Namespaces
after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? }
scope :traversal_ids_contains, ->(ids) { where("traversal_ids @> (?)", ids) }
# When filtering namespaces by the traversal_ids column to compile a
# list of namespace IDs, it's much faster to reference the ID in
# traversal_ids than the primary key ID column.
# WARNING This scope must be used behind a linear query feature flag
# such as `use_traversal_ids`.
scope :as_ids, -> { select('traversal_ids[array_length(traversal_ids, 1)] AS id') }
end
def sync_traversal_ids?
@ -64,6 +70,12 @@ module Namespaces
lineage(top: self)
end
def self_and_descendant_ids
return super unless use_traversal_ids?
self_and_descendants.as_ids
end
def descendants
return super unless use_traversal_ids?

View File

@ -61,6 +61,11 @@ module Namespaces
end
alias_method :recursive_self_and_descendants, :self_and_descendants
def self_and_descendant_ids
self_and_descendants.select(:id)
end
alias_method :recursive_self_and_descendant_ids, :self_and_descendant_ids
def object_hierarchy(ancestors_base)
Gitlab::ObjectHierarchy.new(ancestors_base, options: { use_distinct: Feature.enabled?(:use_distinct_in_object_hierarchy, self) })
end

View File

@ -94,18 +94,14 @@ class ProjectStatistics < ApplicationRecord
end
def update_storage_size
storage_size = repository_size + wiki_size + lfs_objects_size + build_artifacts_size + packages_size
# The `snippets_size` column was added on 20200622095419 but db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb
# might try to update project statistics before the `snippets_size` column has been created.
storage_size += snippets_size if self.class.column_names.include?('snippets_size')
# The `pipeline_artifacts_size` column was added on 20200817142800 but db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb
# might try to update project statistics before the `pipeline_artifacts_size` column has been created.
storage_size += pipeline_artifacts_size if self.class.column_names.include?('pipeline_artifacts_size')
# The `uploads_size` column was added on 20201105021637 but db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb
# might try to update project statistics before the `uploads_size` column has been created.
storage_size += uploads_size if self.class.column_names.include?('uploads_size')
storage_size = repository_size +
wiki_size +
lfs_objects_size +
build_artifacts_size +
packages_size +
snippets_size +
pipeline_artifacts_size +
uploads_size
self.storage_size = storage_size
end

View File

@ -23,9 +23,9 @@ module Groups
end
def group_members
return [] unless noteable
return [] unless group
@group_members ||= sorted(noteable.group.direct_and_indirect_users)
@group_members ||= sorted(group.direct_and_indirect_users)
end
end
end

View File

@ -14,4 +14,4 @@
"can-user-admin-feature-flag" => can?(current_user, :admin_feature_flag, @project),
"new-feature-flag-path" => can?(current_user, :create_feature_flag, @project) ? new_project_feature_flag_path(@project): nil,
"rotate-instance-id-path" => can?(current_user, :admin_feature_flags_client, @project) ? reset_token_project_feature_flags_client_path(@project, format: :json) : nil,
"new-user-list-path" => can?(current_user, :admin_feature_flags_user_lists, @project) ? new_project_feature_flags_user_list_path(@project) : nil } }
"user-list-path" => can?(current_user, :admin_feature_flags_user_lists, @project) ? project_feature_flags_user_lists_path(@project) : nil } }

View File

@ -1,4 +1,5 @@
- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
- add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project)
- breadcrumb_title s_('FeatureFlags|Edit User List')
- page_title s_('FeatureFlags|Edit User List')

View File

@ -0,0 +1,8 @@
- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
- breadcrumb_title s_('FeatureFlags|User Lists')
- page_title s_('FeatureFlags|Feature Flag User Lists')
#js-user-lists{ data: { project_id: @project.id,
feature_flags_help_page_path: help_page_path("operations/feature_flags"),
new_user_list_path: can?(current_user, :create_feature_flag, @project) ? new_project_feature_flags_user_list_path(@project): nil,
error_state_svg_path: image_path('illustrations/feature_flag.svg') } }

View File

@ -1,5 +1,6 @@
- @breadcrumb_link = new_project_feature_flags_user_list_path(@project)
- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
- add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project)
- breadcrumb_title s_('FeatureFlags|New User List')
- page_title s_('FeatureFlags|New User List')

View File

@ -1,4 +1,5 @@
- add_to_breadcrumbs s_('FeatureFlags|Feature Flags'), project_feature_flags_path(@project)
- add_to_breadcrumbs s_('FeatureFlags|User Lists'), project_feature_flags_user_lists_path(@project)
- breadcrumb_title s_('FeatureFlags|List details')
- page_title s_('FeatureFlags|Feature Flag User List Details')

View File

@ -1,6 +1,6 @@
.context-header
= link_to scope_menu.link, **scope_menu.container_html_options do
%span.avatar-container.rect-avatar.s40.project-avatar
= source_icon(scope_menu.container, alt: scope_menu.title, class: 'avatar s40 avatar-tile', width: 40, height: 40)
%span.sidebar-context-title
= scope_menu.title
- if sidebar_refactor_enabled?
= nav_link(**scope_menu.active_routes, html_options: scope_menu.nav_link_html_options) do
= render 'shared/nav/scope_menu_body', scope_menu: scope_menu
- else
.context-header
= render 'shared/nav/scope_menu_body', scope_menu: scope_menu

View File

@ -0,0 +1,5 @@
= link_to scope_menu.link, **scope_menu.container_html_options do
%span.avatar-container.rect-avatar.s40.project-avatar
= source_icon(scope_menu.container, alt: scope_menu.title, class: 'avatar s40 avatar-tile', width: 40, height: 40)
%span.sidebar-context-title
= scope_menu.title

View File

@ -1,11 +1,13 @@
%aside.nav-sidebar{ class: ('sidebar-collapsed-desktop' if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(sidebar.container), 'aria-label': sidebar.aria_label }
.nav-sidebar-inner-scroll
- if sidebar.scope_menu
- if sidebar.scope_menu && sidebar_refactor_disabled?
= render partial: 'shared/nav/scope_menu', object: sidebar.scope_menu
- elsif sidebar.render_raw_scope_menu_partial
= render sidebar.render_raw_scope_menu_partial
%ul.sidebar-top-level-items.qa-project-sidebar
- if sidebar.scope_menu && sidebar_refactor_enabled?
= render partial: 'shared/nav/scope_menu', object: sidebar.scope_menu
- if sidebar.renderable_menus.any?
= render partial: 'shared/nav/sidebar_menu', collection: sidebar.renderable_menus
- if sidebar.render_raw_menus_partial

View File

@ -14,4 +14,3 @@ distribution:
- ee
tier:
- free
skip_validation: true

View File

@ -403,7 +403,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resource :feature_flags_client, only: [] do
post :reset_token
end
resources :feature_flags_user_lists, param: :iid, only: [:new, :edit, :show]
resources :feature_flags_user_lists, param: :iid, only: [:index, :new, :edit, :show]
get '/schema/:branch/*filename',
to: 'web_ide_schemas#show',

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class AddShaToStatusCheckResponse < ActiveRecord::Migration[6.0]
def up
execute('DELETE FROM status_check_responses')
add_column :status_check_responses, :sha, :binary, null: false # rubocop:disable Rails/NotNullColumn
end
def down
remove_column :status_check_responses, :sha
end
end

View File

@ -18,12 +18,12 @@ class ScheduleCalculateWikiSizes < ActiveRecord::Migration[5.0]
disable_ddl_transaction!
# Disabling this old migration because it should already run
# in 14.0. This will allow us to remove some `technical debt`
# in ProjectStatistics model, because of some columns
# not present by the time the migration is run.
def up
queue_background_migration_jobs_by_range_at_intervals(
::ScheduleCalculateWikiSizes::ProjectStatistics.without_wiki_size,
MIGRATION,
BATCH_TIME,
batch_size: BATCH_SIZE)
# no-op
end
def down

View File

@ -0,0 +1 @@
307e45d581c48b6f571fc8fa2a00dfd4360296560ee2b320540314b8f9f9e02c

View File

@ -18108,7 +18108,8 @@ ALTER SEQUENCE sprints_id_seq OWNED BY sprints.id;
CREATE TABLE status_check_responses (
id bigint NOT NULL,
merge_request_id bigint NOT NULL,
external_approval_rule_id bigint NOT NULL
external_approval_rule_id bigint NOT NULL,
sha bytea NOT NULL
);
CREATE SEQUENCE status_check_responses_id_seq

View File

@ -74,7 +74,9 @@ required number of seconds.
"id": { "type": "integer" },
"username": { "type": "string" },
"email": { "type": "string" },
"created_at": { "type": ["string", "null"], "format": "date-time" }
"created_at": { "type": ["string", "null"], "format": "date-time" },
"current_sign_in_ip": { "type": ["string", "null"] },
"last_sign_in_ip": { "type": ["string", "null"] }
}
},
"pipeline": {

View File

@ -0,0 +1,9 @@
---
redirect_to: 'puma.md'
remove_date: '2021-08-26'
---
This file was moved to [another location](puma.md).
<!-- This redirect file can be deleted after <2021-08-26>. -->
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->

View File

@ -22,6 +22,7 @@ If you use a cloud-managed service, or provide your own PostgreSQL instance:
roles to your `gitlab` user:
- Amazon RDS requires the [`rds_superuser`](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Appendix.PostgreSQL.CommonDBATasks.html#Appendix.PostgreSQL.CommonDBATasks.Roles) role.
- Azure Database for PostgreSQL requires the [`azure_pg_admin`](https://docs.microsoft.com/en-us/azure/postgresql/howto-create-users#how-to-create-additional-admin-users-in-azure-database-for-postgresql) role.
- Google Cloud SQL requires the [`cloudsqlsuperuser`](https://cloud.google.com/sql/docs/postgres/users#default-users) role.
This is for the installation of extensions during installation and upgrades. As an alternative,
[ensure the extensions are installed manually, and read about the problems that may arise during future GitLab upgrades](../../install/postgresql_extensions.md).

93
doc/api/status_checks.md Normal file
View File

@ -0,0 +1,93 @@
---
stage: Manage
group: Compliance
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments"
type: reference, api
---
# External Status Checks API **(ULTIMATE)**
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3869) in GitLab 14.0.
> - It's [deployed behind a feature flag](../user/feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-status-checks). **(ULTIMATE SELF)**
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
## List status checks for a merge request
For a single merge request, list the external status checks that apply to it and their status.
```plaintext
GET /projects/:id/merge_requests/:merge_request_iid/status_checks
```
**Parameters:**
| Attribute | Type | Required | Description |
| ------------------------ | ------- | -------- | -------------------------- |
| `id` | integer | yes | ID of a project |
| `merge_request_iid` | integer | yes | IID of a merge request |
```json
[
{
"id": 2,
"name": "Rule 1",
"external_url": "https://gitlab.com/test-endpoint",
"status": "approved"
},
{
"id": 1,
"name": "Rule 2",
"external_url": "https://gitlab.com/test-endpoint-2",
"status": "pending"
}
]
```
## Set approval status of an external status check
For a single merge request, use the API to inform GitLab that a merge request has been approved by an external service.
```plaintext
POST /projects/:id/merge_requests/:merge_request_iid/status_check_responses
```
**Parameters:**
| Attribute | Type | Required | Description |
| ------------------------ | ------- | -------- | -------------------------------------- |
| `id` | integer | yes | ID of a project |
| `merge_request_iid` | integer | yes | IID of a merge request |
| `sha` | string | yes | SHA at `HEAD` of the source branch |
NOTE:
`sha` must be the SHA at the `HEAD` of the merge request's source branch.
## Enable or disable status checks **(ULTIMATE SELF)**
Status checks are under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
can enable it.
To enable it:
```ruby
# For the instance
Feature.enable(:ff_compliance_approval_gates)
# For a single project
Feature.enable(:ff_compliance_approval_gates, Project.find(<project id>))
```
To disable it:
```ruby
# For the instance
Feature.disable(:ff_compliance_approval_gates)
# For a single project
Feature.disable(:ff_compliance_approval_gates, Project.find(<project id>)
```

View File

@ -184,14 +184,16 @@ For example:
#### Create a user list
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13308) in GitLab 13.3.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13308) in GitLab 13.3.
> - [Updated](https://gitlab.com/gitlab-org/gitlab/-/issues/322425) in GitLab 14.0.
To create a user list:
1. In your project, navigate to **Operations > Feature Flags**.
1. Click on **New list**.
1. Select **View user lists**
1. Select **New user list**.
1. Enter a name for the list.
1. Click **Create**.
1. Select **Create**.
You can view a list's User IDs by clicking the **{pencil}** (edit) button next to it.
When viewing a list, you can rename it by clicking the **Edit** button.

View File

@ -106,9 +106,10 @@ instance or the project's parent groups.
### Set instance-level description templates **(PREMIUM SELF)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52360) in GitLab 13.9.
> - It's [deployed behind a feature flag](../feature_flags.md), disabled by default.
> - [Became enabled by default](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56737) in GitLab 13.11.
> - It's enabled by default on GitLab.com.
> - [Deployed behind a feature flag](../feature_flags.md), disabled by default.
> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56737) in GitLab 13.11.
> - Enabled by default on GitLab.com.
> - Recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-issue-and-merge-request-description-templates-at-group-and-instance-level). **(PREMIUM SELF)**
You can set a description template at the **instance level** for issues
@ -131,9 +132,10 @@ Learn more about [instance template repository](../admin_area/settings/instance_
### Set group-level description templates **(PREMIUM)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52360) in GitLab 13.9.
> - It's [deployed behind a feature flag](../feature_flags.md), disabled by default.
> - [Became enabled by default](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56737) in GitLab 13.11.
> - It's enabled by default on GitLab.com.
> - [Deployed behind a feature flag](../feature_flags.md), disabled by default.
> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56737) in GitLab 13.11.
> - Enabled by default on GitLab.com.
> - Recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-issue-and-merge-request-description-templates-at-group-and-instance-level). **(PREMIUM SELF)**
With **group-level** description templates, you can store your templates in a single repository and
@ -230,26 +232,26 @@ it's very hard to read otherwise.)
/assign @qa-tester
```
## Enable or disable issue and merge request description templates at group and instance level
## Enable or disable issue and merge request description templates at group and instance level **(PREMIUM SELF)**
Setting issue and merge request description templates at group and instance levels
is under development and not ready for production use. It is deployed behind a
is under development but ready for production use. It is deployed behind a
feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can disable it.
To enable it:
```ruby
Feature.enable(:inherited_issuable_templates)
```
To disable it:
```ruby
Feature.disable(:inherited_issuable_templates)
```
To enable it:
```ruby
Feature.enable(:inherited_issuable_templates)
```
The feature flag affects these features:
- Setting a templates project as issue and merge request description templates source at group level.

View File

@ -66,14 +66,7 @@ module Banzai
# These associations are primarily used for checking permissions.
# Eager loading these ensures we don't end up running dozens of
# queries in this process.
project: [
{ namespace: :owner },
{ group: [:owners, :group_members] },
:invited_groups,
:project_members,
:project_feature,
:route
]
project: [:namespace, :project_feature, :route]
}
),
self.class.data_attribute

View File

@ -17,14 +17,7 @@ module Banzai
# These associations are primarily used for checking permissions.
# Eager loading these ensures we don't end up running dozens of
# queries in this process.
target_project: [
{ namespace: [:owner, :route] },
{ group: [:owners, :group_members] },
:invited_groups,
:project_members,
:project_feature,
:route
]
target_project: [{ namespace: :route }, :project_feature, :route]
}),
self.class.data_attribute
)

View File

@ -89,7 +89,9 @@ module Gitlab
id: current_user.id,
username: current_user.username,
email: current_user.email,
created_at: current_user.created_at&.iso8601
created_at: current_user.created_at&.iso8601,
current_sign_in_ip: current_user.current_sign_in_ip,
last_sign_in_ip: current_user.last_sign_in_ip
},
pipeline: {
sha: pipeline.sha,

View File

@ -17,14 +17,16 @@ module Sidebars
override :link
def link
project_path(context.project)
renderable_items.first.link
end
override :extra_container_html_options
def extra_container_html_options
{
class: 'shortcuts-project rspec-project-link'
}
if Feature.enabled?(:sidebar_refactor, context.current_user, default_enabled: :yaml)
{ class: 'shortcuts-project-information' }
else
{ class: 'shortcuts-project rspec-project-link' }
end
end
override :nav_link_html_options
@ -50,13 +52,6 @@ module Sidebars
end
end
override :active_routes
def active_routes
return {} if Feature.disabled?(:sidebar_refactor, context.current_user, default_enabled: :yaml)
{ path: 'projects#show' }
end
private
def details_menu_item

View File

@ -13,6 +13,27 @@ module Sidebars
def title
context.project.name
end
override :active_routes
def active_routes
{ path: 'projects#show' }
end
override :extra_container_html_options
def extra_container_html_options
return {} if Feature.disabled?(:sidebar_refactor, context.current_user, default_enabled: :yaml)
{
class: 'shortcuts-project rspec-project-link'
}
end
override :nav_link_html_options
def nav_link_html_options
return {} if Feature.disabled?(:sidebar_refactor, context.current_user, default_enabled: :yaml)
{ class: 'context-header' }
end
end
end
end

View File

@ -13782,6 +13782,9 @@ msgstr ""
msgid "FeatureFlags|Feature Flag User List Details"
msgstr ""
msgid "FeatureFlags|Feature Flag User Lists"
msgstr ""
msgid "FeatureFlags|Feature Flag behavior is built up by creating a set of rules to define the status of target environments. A default wildcard rule %{codeStart}*%{codeEnd} for %{boldStart}All Environments%{boldEnd} is set, and you are able to add as many rules as you need by choosing environment specs below. You can toggle the behavior for each of your rules to set them %{boldStart}Active%{boldEnd} or %{boldStart}Inactive%{boldEnd}."
msgstr ""
@ -13806,9 +13809,6 @@ msgstr ""
msgid "FeatureFlags|Get started with feature flags"
msgstr ""
msgid "FeatureFlags|Get started with user lists"
msgstr ""
msgid "FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag."
msgstr ""
@ -13836,9 +13836,6 @@ msgstr ""
msgid "FeatureFlags|Loading feature flags"
msgstr ""
msgid "FeatureFlags|Loading user lists"
msgstr ""
msgid "FeatureFlags|More information"
msgstr ""
@ -13857,9 +13854,6 @@ msgstr ""
msgid "FeatureFlags|New feature flag"
msgstr ""
msgid "FeatureFlags|New user list"
msgstr ""
msgid "FeatureFlags|No user list selected"
msgstr ""
@ -13902,9 +13896,6 @@ msgstr ""
msgid "FeatureFlags|There was an error fetching the feature flags."
msgstr ""
msgid "FeatureFlags|There was an error fetching the user lists."
msgstr ""
msgid "FeatureFlags|To prevent accidental actions we ask you to confirm your intention. Please type %{projectName} to proceed or close this modal to cancel."
msgstr ""
@ -13920,7 +13911,7 @@ msgstr ""
msgid "FeatureFlags|User Lists"
msgstr ""
msgid "FeatureFlags|User lists allow you to define a set of users to use with Feature Flags."
msgid "FeatureFlags|View user lists"
msgstr ""
msgid "FeatureFlag|Percentage"
@ -28310,6 +28301,18 @@ msgstr ""
msgid "Runners|New runner, has not connected yet"
msgstr ""
msgid "Runners|Not connected"
msgstr ""
msgid "Runners|Offline"
msgstr ""
msgid "Runners|Online"
msgstr ""
msgid "Runners|Paused"
msgstr ""
msgid "Runners|Platform"
msgstr ""
@ -35542,27 +35545,45 @@ msgstr ""
msgid "UserLists|Feature flag user list"
msgstr ""
msgid "UserLists|Get started with user lists"
msgstr ""
msgid "UserLists|Lists allow you to define a set of users to be used with feature flags. %{linkStart}Read more about feature flag lists.%{linkEnd}"
msgstr ""
msgid "UserLists|Loading user lists"
msgstr ""
msgid "UserLists|Name"
msgstr ""
msgid "UserLists|New list"
msgstr ""
msgid "UserLists|New user list"
msgstr ""
msgid "UserLists|Save"
msgstr ""
msgid "UserLists|There are no users"
msgstr ""
msgid "UserLists|There was an error fetching the user lists."
msgstr ""
msgid "UserLists|User ID"
msgstr ""
msgid "UserLists|User IDs"
msgstr ""
msgid "UserLists|User Lists"
msgstr ""
msgid "UserLists|User lists allow you to define a set of users to use with Feature Flags."
msgstr ""
msgid "UserList|Delete %{name}?"
msgstr ""

View File

@ -16,6 +16,39 @@ RSpec.describe Projects::FeatureFlagsUserListsController do
{ namespace_id: project.namespace, project_id: project }.merge(extra_params)
end
describe 'GET #index' do
it 'redirects when the user is unauthenticated' do
get(:index, params: request_params)
expect(response).to redirect_to(new_user_session_path)
end
it 'returns not found if the user does not belong to the project' do
user = create(:user)
sign_in(user)
get(:index, params: request_params)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns not found for a reporter' do
sign_in(reporter)
get(:index, params: request_params)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'renders the new page for a developer' do
sign_in(developer)
get(:index, params: request_params)
expect(response).to have_gitlab_http_status(:ok)
end
end
describe 'GET #new' do
it 'redirects when the user is unauthenticated' do
get(:new, params: request_params)

View File

@ -3,17 +3,17 @@
require 'spec_helper'
RSpec.describe 'Contextual sidebar', :js do
let(:user) { create(:user) }
let(:project) { create(:project) }
let_it_be(:project) { create(:project) }
let(:user) { project.owner }
before do
project.add_maintainer(user)
sign_in(user)
visit project_path(project)
end
it 'shows flyout navs when collapsed or expanded apart from on the active item when expanded' do
it 'shows flyout navs when collapsed or expanded apart from on the active item when expanded', :aggregate_failures do
expect(page).not_to have_selector('.js-sidebar-collapsed')
find('.rspec-link-pipelines').hover

View File

@ -16,6 +16,7 @@ RSpec.describe 'GFM autocomplete', :js do
fill_in 'Description', with: User.reference_prefix
wait_for_requests
expect(find_autocomplete_menu).to be_visible
expect_autocomplete_entry(user.name)
expect_autocomplete_entry(group.name)
fill_in 'Description', with: Label.reference_prefix

View File

@ -18,12 +18,11 @@ RSpec.describe 'Project active tab' do
end
context 'on project Home' do
context 'when feature flag :sidebar_refactor is enabled' do
before do
visit project_path(project)
end
it 'activates Project scope menu' do
visit project_path(project)
it_behaves_like 'page has active tab', 'Project'
expect(page).to have_selector('.sidebar-top-level-items > li.active', count: 1)
expect(find('.sidebar-top-level-items > li.active')).to have_content(project.name)
end
context 'when feature flag :sidebar_refactor is disabled' do
@ -36,11 +35,23 @@ RSpec.describe 'Project active tab' do
it_behaves_like 'page has active tab', 'Project'
it_behaves_like 'page has active sub tab', 'Details'
end
end
context 'on project Home/Activity' do
context 'on Project information' do
context 'default link' do
before do
visit project_path(project)
click_tab('Activity')
click_link('Project information', match: :first)
end
it_behaves_like 'page has active tab', 'Project'
it_behaves_like 'page has active sub tab', 'Activity'
end
context 'on Project information/Activity' do
before do
visit activity_project_path(project)
end
it_behaves_like 'page has active tab', 'Project'

View File

@ -17,12 +17,13 @@ RSpec.describe 'User deletes feature flag user list', :js do
end
it 'deletes the list' do
visit(project_feature_flags_path(project, scope: 'userLists'))
visit(project_feature_flags_user_lists_path(project, scope: 'userLists'))
delete_user_list_button.click
delete_user_list_modal_confirmation_button.click
expect(page).to have_text('Lists 0')
expect(page).to have_text('Lists')
expect(page).not_to have_selector('[data-testid="ffUserListName"]')
end
end
@ -34,7 +35,7 @@ RSpec.describe 'User deletes feature flag user list', :js do
end
it 'does not delete the list' do
visit(project_feature_flags_path(project, scope: 'userLists'))
visit(project_feature_flags_user_lists_path(project, scope: 'userLists'))
delete_user_list_button.click
delete_user_list_modal_confirmation_button.click

View File

@ -3,8 +3,9 @@
require 'spec_helper'
RSpec.describe 'Projects > Members > User requests access', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public, :repository) }
let(:maintainer) { project.owner }
before do
@ -47,6 +48,8 @@ RSpec.describe 'Projects > Members > User requests access', :js do
expect(project.requesters.exists?(user_id: user)).to be_truthy
click_link 'Project information'
page.within('.nav-sidebar') do
click_link('Members')
end

View File

@ -17,6 +17,10 @@ RSpec.describe 'Project navbar' do
end
context 'when sidebar refactor feature flag is disabled' do
let(:project_context_nav_item) do
nil
end
before do
stub_feature_flags(sidebar_refactor: false)
insert_package_nav(_('Operations'))

View File

@ -3,11 +3,11 @@
require 'spec_helper'
RSpec.describe 'User uses shortcuts', :js do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let(:user) { project.owner }
before do
project.add_maintainer(user)
sign_in(user)
visit(project_path(project))
@ -74,7 +74,7 @@ RSpec.describe 'User uses shortcuts', :js do
find('body').native.send_key('g')
find('body').native.send_key('p')
expect(page).to have_active_navigation('Project')
expect(page).to have_active_navigation(project.name)
end
context 'when feature flag :sidebar_refactor is disabled' do

View File

@ -32,7 +32,9 @@
"id": { "type": "integer" },
"username": { "type": "string" },
"email": { "type": "string" },
"created_at": { "type": ["string", "null"], "format": "date-time" }
"created_at": { "type": ["string", "null"], "format": "date-time" },
"current_sign_in_ip": { "type": ["string", "null"] },
"last_sign_in_ip": { "type": ["string", "null"] }
}
},
"pipeline": {

View File

@ -1,16 +1,14 @@
import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue';
import EmptyState from '~/feature_flags/components/empty_state.vue';
const DEFAULT_PROPS = {
title: 'test',
count: 5,
alerts: ['an alert', 'another alert'],
isLoading: false,
loadingLabel: 'test loading',
errorState: false,
errorTitle: 'test title',
emptyState: true,
emptyState: false,
emptyTitle: 'test empty',
emptyDescription: 'empty description',
};
@ -27,13 +25,10 @@ describe('feature_flags/components/feature_flags_tab.vue', () => {
mount(
{
components: {
GlTabs,
FeatureFlagsTab,
EmptyState,
},
render(h) {
return h(GlTabs, [
h(FeatureFlagsTab, { props: this.$attrs, on: this.$listeners }, this.$slots.default),
]);
return h(EmptyState, { props: this.$attrs, on: this.$listeners }, this.$slots.default);
},
},
{
@ -72,7 +67,7 @@ describe('feature_flags/components/feature_flags_tab.vue', () => {
it('should emit a dismiss event for a dismissed alert', () => {
alerts.at(0).vm.$emit('dismiss');
expect(wrapper.find(FeatureFlagsTab).emitted('dismissAlert')).toEqual([[0]]);
expect(wrapper.find(EmptyState).emitted('dismissAlert')).toEqual([[0]]);
});
});
@ -138,30 +133,4 @@ describe('feature_flags/components/feature_flags_tab.vue', () => {
expect(slot.text()).toBe('testing');
});
});
describe('count', () => {
it('should display a count if there is one', async () => {
wrapper = factory();
await wrapper.vm.$nextTick();
expect(wrapper.find(GlBadge).text()).toBe(DEFAULT_PROPS.count.toString());
});
it('should display 0 if there is no count', async () => {
wrapper = factory({ count: undefined });
await wrapper.vm.$nextTick();
expect(wrapper.find(GlBadge).text()).toBe('0');
});
});
describe('title', () => {
it('should show the title', async () => {
wrapper = factory();
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="feature-flags-tab-title"]').text()).toBe(
DEFAULT_PROPS.title,
);
});
});
});

View File

@ -1,19 +1,17 @@
import { GlAlert, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlAlert, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import Api from '~/api';
import ConfigureFeatureFlagsModal from '~/feature_flags/components/configure_feature_flags_modal.vue';
import EmptyState from '~/feature_flags/components/empty_state.vue';
import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue';
import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue';
import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue';
import UserListsTable from '~/feature_flags/components/user_lists_table.vue';
import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '~/feature_flags/constants';
import createStore from '~/feature_flags/store/index';
import axios from '~/lib/utils/axios_utils';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import { getRequestData, userList } from '../mock_data';
import { getRequestData } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
@ -28,7 +26,7 @@ describe('Feature flags', () => {
featureFlagsLimit: '200',
featureFlagsLimitExceeded: false,
newFeatureFlagPath: 'feature-flags/new',
newUserListPath: '/user-list/new',
userListPath: '/user-list',
unleashApiUrl: `${TEST_HOST}/api/unleash`,
projectName: 'fakeProjectName',
errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
@ -44,36 +42,25 @@ describe('Feature flags', () => {
let mock;
let store;
const factory = (provide = mockData, fn = shallowMount) => {
const factory = (provide = mockData, fn = mount) => {
store = createStore(mockState);
wrapper = fn(FeatureFlagsComponent, {
localVue,
store,
provide,
stubs: {
FeatureFlagsTab,
EmptyState,
},
});
};
const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]');
const newButton = () => wrapper.find('[data-testid="ff-new-button"]');
const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]');
const limitAlert = () => wrapper.find(GlAlert);
const userListButton = () => wrapper.find('[data-testid="ff-user-list-button"]');
const limitAlert = () => wrapper.findComponent(GlAlert);
beforeEach(() => {
mock = new MockAdapter(axios);
jest.spyOn(Api, 'fetchFeatureFlagUserLists').mockResolvedValue({
data: [userList],
headers: {
'x-next-page': '2',
'x-page': '1',
'X-Per-Page': '8',
'X-Prev-Page': '',
'X-TOTAL': '40',
'X-Total-Pages': '5',
},
});
});
afterEach(() => {
@ -87,7 +74,7 @@ describe('Feature flags', () => {
beforeEach((done) => {
mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.onGet(`${TEST_HOST}/endpoint.json`, { params: { page: '1' } })
.reply(200, getRequestData, {});
factory(provideData);
setImmediate(done);
@ -101,9 +88,7 @@ describe('Feature flags', () => {
it('shows a feature flags limit reached alert', () => {
expect(limitAlert().exists()).toBe(true);
expect(limitAlert().find(GlSprintf).attributes('message')).toContain(
'Feature flags limit reached',
);
expect(limitAlert().text()).toContain('Feature flags limit reached');
});
describe('when the alert is dismissed', () => {
@ -129,12 +114,12 @@ describe('Feature flags', () => {
canUserConfigure: false,
canUserRotateToken: false,
newFeatureFlagPath: null,
newUserListPath: null,
userListPath: null,
};
beforeEach((done) => {
mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.onGet(`${TEST_HOST}/endpoint.json`, { params: { page: '1' } })
.reply(200, getRequestData, {});
factory(provideData);
setImmediate(done);
@ -148,20 +133,20 @@ describe('Feature flags', () => {
expect(newButton().exists()).toBe(false);
});
it('does not render new user list button', () => {
expect(newUserListButton().exists()).toBe(false);
it('does not render view user list button', () => {
expect(userListButton().exists()).toBe(false);
});
});
describe('loading state', () => {
it('renders a loading icon', () => {
mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.onGet(`${TEST_HOST}/endpoint.json`, { params: { page: '1' } })
.replyOnce(200, getRequestData, {});
factory();
const loadingElement = wrapper.find(GlLoadingIcon);
const loadingElement = wrapper.findComponent(GlLoadingIcon);
expect(loadingElement.exists()).toBe(true);
expect(loadingElement.props('label')).toEqual('Loading feature flags');
@ -173,7 +158,7 @@ describe('Feature flags', () => {
let emptyState;
beforeEach(async () => {
mock.onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }).reply(
mock.onGet(mockState.endpoint, { params: { page: '1' } }).reply(
200,
{
feature_flags: [],
@ -187,9 +172,10 @@ describe('Feature flags', () => {
);
factory();
await waitForPromises();
await wrapper.vm.$nextTick();
emptyState = wrapper.find(GlEmptyState);
emptyState = wrapper.findComponent(GlEmptyState);
});
it('should render the empty state', async () => {
@ -204,9 +190,9 @@ describe('Feature flags', () => {
expect(newButton().exists()).toBe(true);
});
it('renders new user list button', () => {
expect(newUserListButton().exists()).toBe(true);
expect(newUserListButton().attributes('href')).toBe('/user-list/new');
it('renders view user list button', () => {
expect(userListButton().exists()).toBe(true);
expect(userListButton().attributes('href')).toBe(mockData.userListPath);
});
describe('in feature flags tab', () => {
@ -218,16 +204,14 @@ describe('Feature flags', () => {
describe('with paginated feature flags', () => {
beforeEach((done) => {
mock
.onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.replyOnce(200, getRequestData, {
'x-next-page': '2',
'x-page': '1',
'X-Per-Page': '2',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '5',
});
mock.onGet(mockState.endpoint, { params: { page: '1' } }).replyOnce(200, getRequestData, {
'x-next-page': '2',
'x-page': '1',
'X-Per-Page': '2',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '5',
});
factory();
jest.spyOn(store, 'dispatch');
@ -235,9 +219,9 @@ describe('Feature flags', () => {
});
it('should render a table with feature flags', () => {
const table = wrapper.find(FeatureFlagsTable);
const table = wrapper.findComponent(FeatureFlagsTable);
expect(table.exists()).toBe(true);
expect(table.props(FEATURE_FLAG_SCOPE)).toEqual(
expect(table.props('featureFlags')).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: getRequestData.feature_flags[0].name,
@ -248,9 +232,9 @@ describe('Feature flags', () => {
});
it('should toggle a flag when receiving the toggle-flag event', () => {
const table = wrapper.find(FeatureFlagsTable);
const table = wrapper.findComponent(FeatureFlagsTable);
const [flag] = table.props(FEATURE_FLAG_SCOPE);
const [flag] = table.props('featureFlags');
table.vm.$emit('toggle-flag', flag);
expect(store.dispatch).toHaveBeenCalledWith('toggleFeatureFlag', flag);
@ -264,71 +248,38 @@ describe('Feature flags', () => {
expect(newButton().exists()).toBe(true);
});
it('renders new user list button', () => {
expect(newUserListButton().exists()).toBe(true);
expect(newUserListButton().attributes('href')).toBe('/user-list/new');
it('renders view user list button', () => {
expect(userListButton().exists()).toBe(true);
expect(userListButton().attributes('href')).toBe(mockData.userListPath);
});
describe('pagination', () => {
it('should render pagination', () => {
expect(wrapper.find(TablePagination).exists()).toBe(true);
expect(wrapper.findComponent(TablePagination).exists()).toBe(true);
});
it('should make an API request when page is clicked', () => {
jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions');
wrapper.find(TablePagination).vm.change(4);
wrapper.findComponent(TablePagination).vm.change(4);
expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({
scope: FEATURE_FLAG_SCOPE,
page: '4',
});
});
it('should make an API request when using tabs', () => {
jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions');
wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab');
expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({
scope: USER_LIST_SCOPE,
page: '1',
});
});
});
});
describe('in user lists tab', () => {
beforeEach((done) => {
factory();
setImmediate(done);
});
beforeEach(() => {
wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab');
return wrapper.vm.$nextTick();
});
it('should display the user list table', () => {
expect(wrapper.find(UserListsTable).exists()).toBe(true);
});
it('should set the user lists to display', () => {
expect(wrapper.find(UserListsTable).props('userLists')).toEqual([userList]);
});
});
});
describe('unsuccessful request', () => {
beforeEach((done) => {
mock
.onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.replyOnce(500, {});
Api.fetchFeatureFlagUserLists.mockRejectedValueOnce();
mock.onGet(mockState.endpoint, { params: { page: '1' } }).replyOnce(500, {});
factory();
setImmediate(done);
});
it('should render error state', () => {
const emptyState = wrapper.find(GlEmptyState);
const emptyState = wrapper.findComponent(GlEmptyState);
expect(emptyState.props('title')).toEqual('There was an error fetching the feature flags.');
expect(emptyState.props('description')).toEqual(
'Try again in a few moments or contact your support team.',
@ -343,16 +294,16 @@ describe('Feature flags', () => {
expect(newButton().exists()).toBe(true);
});
it('renders new user list button', () => {
expect(newUserListButton().exists()).toBe(true);
expect(newUserListButton().attributes('href')).toBe('/user-list/new');
it('renders view user list button', () => {
expect(userListButton().exists()).toBe(true);
expect(userListButton().attributes('href')).toBe(mockData.userListPath);
});
});
describe('rotate instance id', () => {
beforeEach((done) => {
mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.onGet(`${TEST_HOST}/endpoint.json`, { params: { page: '1' } })
.reply(200, getRequestData, {});
factory();
setImmediate(done);
@ -360,7 +311,7 @@ describe('Feature flags', () => {
it('should fire the rotate action when a `token` event is received', () => {
const actionSpy = jest.spyOn(wrapper.vm, 'rotateInstanceId');
const modal = wrapper.find(ConfigureFeatureFlagsModal);
const modal = wrapper.findComponent(ConfigureFeatureFlagsModal);
modal.vm.$emit('token');
expect(actionSpy).toHaveBeenCalled();

View File

@ -1,7 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
import Api from '~/api';
import { mapToScopesViewModel } from '~/feature_flags/store/helpers';
import {
requestFeatureFlags,
@ -17,18 +16,12 @@ import {
updateFeatureFlag,
receiveUpdateFeatureFlagSuccess,
receiveUpdateFeatureFlagError,
requestUserLists,
receiveUserListsSuccess,
receiveUserListsError,
fetchUserLists,
deleteUserList,
receiveDeleteUserListError,
clearAlert,
} from '~/feature_flags/store/index/actions';
import * as types from '~/feature_flags/store/index/mutation_types';
import state from '~/feature_flags/store/index/state';
import axios from '~/lib/utils/axios_utils';
import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data';
import { getRequestData, rotateData, featureFlag } from '../../mock_data';
jest.mock('~/api.js');
@ -154,99 +147,6 @@ describe('Feature flags actions', () => {
});
});
describe('fetchUserLists', () => {
beforeEach(() => {
Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} });
});
describe('success', () => {
it('dispatches requestUserLists and receiveUserListsSuccess ', (done) => {
testAction(
fetchUserLists,
null,
mockedState,
[],
[
{
type: 'requestUserLists',
},
{
payload: { data: [userList], headers: {} },
type: 'receiveUserListsSuccess',
},
],
done,
);
});
});
describe('error', () => {
it('dispatches requestUserLists and receiveUserListsError ', (done) => {
Api.fetchFeatureFlagUserLists.mockRejectedValue();
testAction(
fetchUserLists,
null,
mockedState,
[],
[
{
type: 'requestUserLists',
},
{
type: 'receiveUserListsError',
},
],
done,
);
});
});
});
describe('requestUserLists', () => {
it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => {
testAction(
requestUserLists,
null,
mockedState,
[{ type: types.REQUEST_USER_LISTS }],
[],
done,
);
});
});
describe('receiveUserListsSuccess', () => {
it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => {
testAction(
receiveUserListsSuccess,
{ data: [userList], headers: {} },
mockedState,
[
{
type: types.RECEIVE_USER_LISTS_SUCCESS,
payload: { data: [userList], headers: {} },
},
],
[],
done,
);
});
});
describe('receiveUserListsError', () => {
it('should commit RECEIVE_USER_LISTS_ERROR mutation', (done) => {
testAction(
receiveUserListsError,
null,
mockedState,
[{ type: types.RECEIVE_USER_LISTS_ERROR }],
[],
done,
);
});
});
describe('rotateInstanceId', () => {
let mock;
@ -482,69 +382,6 @@ describe('Feature flags actions', () => {
);
});
});
describe('deleteUserList', () => {
beforeEach(() => {
mockedState.userLists = [userList];
});
describe('success', () => {
beforeEach(() => {
Api.deleteFeatureFlagUserList.mockResolvedValue();
});
it('should refresh the user lists', (done) => {
testAction(
deleteUserList,
userList,
mockedState,
[],
[{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }],
done,
);
});
});
describe('error', () => {
beforeEach(() => {
Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } });
});
it('should dispatch receiveDeleteUserListError', (done) => {
testAction(
deleteUserList,
userList,
mockedState,
[],
[
{ type: 'requestDeleteUserList', payload: userList },
{
type: 'receiveDeleteUserListError',
payload: { list: userList, error: 'some error' },
},
],
done,
);
});
});
});
describe('receiveDeleteUserListError', () => {
it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', (done) => {
testAction(
receiveDeleteUserListError,
{ list: userList, error: 'mock error' },
mockedState,
[
{
type: 'RECEIVE_DELETE_USER_LIST_ERROR',
payload: { list: userList, error: 'mock error' },
},
],
[],
done,
);
});
});
describe('clearAlert', () => {
it('should commit RECEIVE_CLEAR_ALERT', (done) => {

View File

@ -3,7 +3,7 @@ import * as types from '~/feature_flags/store/index/mutation_types';
import mutations from '~/feature_flags/store/index/mutations';
import state from '~/feature_flags/store/index/state';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data';
import { getRequestData, rotateData, featureFlag } from '../../mock_data';
describe('Feature flags store Mutations', () => {
let stateCopy;
@ -59,13 +59,11 @@ describe('Feature flags store Mutations', () => {
});
it('should set count with the given data', () => {
expect(stateCopy.count.featureFlags).toEqual(37);
expect(stateCopy.count).toEqual(37);
});
it('should set pagination', () => {
expect(stateCopy.pageInfo.featureFlags).toEqual(
parseIntPagination(normalizeHeaders(headers)),
);
expect(stateCopy.pageInfo).toEqual(parseIntPagination(normalizeHeaders(headers)));
});
});
@ -83,58 +81,6 @@ describe('Feature flags store Mutations', () => {
});
});
describe('REQUEST_USER_LISTS', () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_USER_LISTS](stateCopy);
expect(stateCopy.isLoading).toBe(true);
});
});
describe('RECEIVE_USER_LISTS_SUCCESS', () => {
const headers = {
'x-next-page': '2',
'x-page': '1',
'X-Per-Page': '2',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '5',
};
beforeEach(() => {
mutations[types.RECEIVE_USER_LISTS_SUCCESS](stateCopy, { data: [userList], headers });
});
it('sets isLoading to false', () => {
expect(stateCopy.isLoading).toBe(false);
});
it('sets userLists to the received userLists', () => {
expect(stateCopy.userLists).toEqual([userList]);
});
it('sets pagination info for user lits', () => {
expect(stateCopy.pageInfo.userLists).toEqual(parseIntPagination(normalizeHeaders(headers)));
});
it('sets the count for user lists', () => {
expect(stateCopy.count.userLists).toBe(parseInt(headers['X-TOTAL'], 10));
});
});
describe('RECEIVE_USER_LISTS_ERROR', () => {
beforeEach(() => {
mutations[types.RECEIVE_USER_LISTS_ERROR](stateCopy);
});
it('should set isLoading to false', () => {
expect(stateCopy.isLoading).toEqual(false);
});
it('should set hasError to true', () => {
expect(stateCopy.hasError).toEqual(true);
});
});
describe('REQUEST_ROTATE_INSTANCE_ID', () => {
beforeEach(() => {
mutations[types.REQUEST_ROTATE_INSTANCE_ID](stateCopy);
@ -214,7 +160,7 @@ describe('Feature flags store Mutations', () => {
...flagState,
scopes: mapToScopesViewModel(flag.scopes || []),
}));
stateCopy.count.featureFlags = stateCount;
stateCopy.count = stateCount;
mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy, {
...featureFlag,
@ -241,8 +187,6 @@ describe('Feature flags store Mutations', () => {
...flag,
scopes: mapToScopesViewModel(flag.scopes || []),
}));
stateCopy.count = { enabled: 1, disabled: 0 };
mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, featureFlag.id);
});
@ -257,36 +201,6 @@ describe('Feature flags store Mutations', () => {
});
});
describe('REQUEST_DELETE_USER_LIST', () => {
beforeEach(() => {
stateCopy.userLists = [userList];
mutations[types.REQUEST_DELETE_USER_LIST](stateCopy, userList);
});
it('should remove the deleted list', () => {
expect(stateCopy.userLists).not.toContain(userList);
});
});
describe('RECEIVE_DELETE_USER_LIST_ERROR', () => {
beforeEach(() => {
stateCopy.userLists = [];
mutations[types.RECEIVE_DELETE_USER_LIST_ERROR](stateCopy, {
list: userList,
error: 'some error',
});
});
it('should set isLoading to false and hasError to false', () => {
expect(stateCopy.isLoading).toBe(false);
expect(stateCopy.hasError).toBe(false);
});
it('should add the user list back to the list of user lists', () => {
expect(stateCopy.userLists).toContain(userList);
});
});
describe('RECEIVE_CLEAR_ALERT', () => {
it('clears the alert', () => {
stateCopy.alerts = ['a server error'];

View File

@ -0,0 +1,135 @@
import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE } from '~/runner/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
describe('RunnerList', () => {
let wrapper;
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem);
const mockDefaultSort = 'CREATED_DESC';
const mockOtherSort = 'CONTACTED_DESC';
const mockFilters = [
{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
];
const createComponent = ({ props = {}, options = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(RunnerFilteredSearchBar, {
propsData: {
value: {
filters: [],
sort: mockDefaultSort,
},
...props,
},
attrs: { namespace: 'runners' },
stubs: {
FilteredSearch,
GlFilteredSearch,
GlDropdown,
GlDropdownItem,
},
...options,
}),
);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('binds a namespace to the filtered search', () => {
expect(findFilteredSearch().props('namespace')).toBe('runners');
});
it('sets sorting options', () => {
const SORT_OPTIONS_COUNT = 2;
expect(findSortOptions()).toHaveLength(SORT_OPTIONS_COUNT);
expect(findSortOptions().at(0).text()).toBe('Created date');
expect(findSortOptions().at(1).text()).toBe('Last contact');
});
it('sets tokens', () => {
expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({
type: PARAM_KEY_STATUS,
options: expect.any(Array),
}),
expect.objectContaining({
type: PARAM_KEY_RUNNER_TYPE,
options: expect.any(Array),
}),
]);
});
it('fails validation for v-model with the wrong shape', () => {
expect(() => {
createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } });
}).toThrow('Invalid prop: custom validator check failed');
expect(() => {
createComponent({ props: { value: { sort: 'sort' } } });
}).toThrow('Invalid prop: custom validator check failed');
});
describe('when a search is preselected', () => {
beforeEach(() => {
createComponent({
props: {
value: {
sort: mockOtherSort,
filters: mockFilters,
},
},
});
});
it('filter values are shown', () => {
expect(findGlFilteredSearch().props('value')).toEqual(mockFilters);
});
it('sort option is selected', () => {
expect(
findSortOptions()
.filter((w) => w.props('isChecked'))
.at(0)
.text(),
).toEqual('Last contact');
});
});
it('when the user sets a filter, the "search" is emitted with filters', () => {
findGlFilteredSearch().vm.$emit('input', mockFilters);
findGlFilteredSearch().vm.$emit('submit');
expect(wrapper.emitted('input')[0]).toEqual([
{
filters: mockFilters,
sort: mockDefaultSort,
},
]);
});
it('when the user sets a sorting method, the "search" is emitted with the sort', () => {
findSortOptions().at(1).vm.$emit('click');
expect(wrapper.emitted('input')[0]).toEqual([
{
filters: [],
sort: mockOtherSort,
},
]);
});
});

View File

@ -1,5 +1,5 @@
import { GlLink, GlSkeletonLoader } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
@ -13,14 +13,15 @@ describe('RunnerList', () => {
const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message');
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findTable = () => wrapper.findComponent(GlTable);
const findHeaders = () => wrapper.findAll('th');
const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]');
const findCell = ({ row = 0, fieldKey }) =>
findRows().at(row).find(`[data-testid="td-${fieldKey}"]`);
const createComponent = ({ props = {} } = {}) => {
const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
wrapper = extendedWrapper(
mount(RunnerList, {
mountFn(RunnerList, {
propsData: {
runners: mockRunners,
activeRunnersCount: mockActiveRunnersCount,
@ -31,7 +32,7 @@ describe('RunnerList', () => {
};
beforeEach(() => {
createComponent();
createComponent({}, mount);
});
afterEach(() => {
@ -104,12 +105,21 @@ describe('RunnerList', () => {
});
describe('When data is loading', () => {
beforeEach(() => {
createComponent({ props: { loading: true } });
it('shows a busy state', () => {
createComponent({ props: { runners: [], loading: true } });
expect(findTable().attributes('busy')).toBeTruthy();
});
it('shows an skeleton loader', () => {
it('when there are no runners, shows an skeleton loader', () => {
createComponent({ props: { runners: [], loading: true } }, mount);
expect(findSkeletonLoader().exists()).toBe(true);
});
it('when there are runners, shows a busy indicator skeleton loader', () => {
createComponent({ props: { loading: true } }, mount);
expect(findSkeletonLoader().exists()).toBe(false);
});
});
});

View File

@ -0,0 +1,98 @@
import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
} from '~/runner/runner_list/filtered_search_utils';
describe('search_params.js', () => {
const examples = [
{
name: 'a default query',
urlQuery: '',
search: { filters: [], sort: 'CREATED_DESC' },
graphqlVariables: { sort: 'CREATED_DESC' },
},
{
name: 'a single status',
urlQuery: '?status[]=ACTIVE',
search: {
filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
sort: 'CREATED_DESC',
},
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC' },
},
{
name: 'single instance type',
urlQuery: '?runner_type[]=INSTANCE_TYPE',
search: {
filters: [{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }],
sort: 'CREATED_DESC',
},
graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC' },
},
{
name: 'multiple runner status',
urlQuery: '?status[]=ACTIVE&status[]=PAUSED',
search: {
filters: [
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'status', value: { data: 'PAUSED', operator: '=' } },
],
sort: 'CREATED_DESC',
},
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC' },
},
{
name: 'multiple status, a single instance type and a non default sort',
urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC',
search: {
filters: [
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } },
],
sort: 'CREATED_ASC',
},
graphqlVariables: { status: 'ACTIVE', type: 'INSTANCE_TYPE', sort: 'CREATED_ASC' },
},
];
describe('fromUrlQueryToSearch', () => {
examples.forEach(({ name, urlQuery, search }) => {
it(`Converts ${name} to a search object`, () => {
expect(fromUrlQueryToSearch(urlQuery)).toEqual(search);
});
});
});
describe('fromSearchToUrl', () => {
examples.forEach(({ name, urlQuery, search }) => {
it(`Converts ${name} to a url`, () => {
expect(fromSearchToUrl(search)).toEqual(`http://test.host/${urlQuery}`);
});
});
it('When a filtered search parameter is already present, it gets removed', () => {
const initialUrl = `http://test.host/?status[]=ACTIVE`;
const search = { filters: [], sort: 'CREATED_DESC' };
const expectedUrl = `http://test.host/`;
expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl);
});
it('When unrelated search parameter is present, it does not get removed', () => {
const initialUrl = `http://test.host/?unrelated=UNRELATED&status[]=ACTIVE`;
const search = { filters: [], sort: 'CREATED_DESC' };
const expectedUrl = `http://test.host/?unrelated=UNRELATED`;
expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl);
});
});
describe('fromSearchToVariables', () => {
examples.forEach(({ name, graphqlVariables, search }) => {
it(`Converts ${name} to a GraphQL query variables object`, () => {
expect(fromSearchToVariables(search)).toEqual(graphqlVariables);
});
});
});
});

View File

@ -2,12 +2,22 @@ import * as Sentry from '@sentry/browser';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
import {
CREATED_ASC,
DEFAULT_SORT,
INSTANCE_TYPE,
PARAM_KEY_STATUS,
STATUS_ACTIVE,
} from '~/runner/constants';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import RunnerListApp from '~/runner/runner_list/runner_list_app.vue';
@ -18,6 +28,10 @@ const mockActiveRunnersCount = 2;
const mocKRunners = runnersData.data.runners.nodes;
jest.mock('@sentry/browser');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
updateHistory: jest.fn(),
}));
const localVue = createLocalVue();
localVue.use(VueApollo);
@ -25,10 +39,12 @@ localVue.use(VueApollo);
describe('RunnerListApp', () => {
let wrapper;
let mockRunnersQuery;
let originalLocation;
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnersQuery, mockRunnersQuery]];
@ -44,7 +60,23 @@ describe('RunnerListApp', () => {
});
};
const setQuery = (query) => {
window.location.href = `${TEST_HOST}/admin/runners/${query}`;
window.location.search = query;
};
beforeAll(() => {
originalLocation = window.location;
Object.defineProperty(window, 'location', { writable: true, value: { href: '', search: '' } });
});
afterAll(() => {
window.location = originalLocation;
});
beforeEach(async () => {
setQuery('');
Sentry.withScope.mockImplementation((fn) => {
const scope = { setTag: jest.fn() };
fn(scope);
@ -64,6 +96,14 @@ describe('RunnerListApp', () => {
expect(mocKRunners).toMatchObject(findRunnerList().props('runners'));
});
it('requests the runners with no filters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: undefined,
type: undefined,
sort: DEFAULT_SORT,
});
});
it('shows the runner type help', () => {
expect(findRunnerTypeHelp().exists()).toBe(true);
});
@ -73,6 +113,56 @@ describe('RunnerListApp', () => {
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
});
describe('when a filter is preselected', () => {
beforeEach(async () => {
window.location.search = `?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`;
createComponentWithApollo();
await waitForPromises();
});
it('sets the filters in the search bar', () => {
expect(findRunnerFilteredSearchBar().props('value')).toEqual({
filters: [
{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } },
{ type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } },
],
sort: 'CREATED_DESC',
});
});
it('requests the runners with filter parameters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: STATUS_ACTIVE,
type: INSTANCE_TYPE,
sort: DEFAULT_SORT,
});
});
});
describe('when a filter is selected by the user', () => {
beforeEach(() => {
findRunnerFilteredSearchBar().vm.$emit('input', {
filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }],
sort: CREATED_ASC,
});
});
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
url: 'http://test.host/admin/runners/?status[]=ACTIVE&sort=CREATED_ASC',
});
});
it('requests the runners with filters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: STATUS_ACTIVE,
sort: CREATED_ASC,
});
});
});
describe('when no runners are found', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } });

View File

@ -0,0 +1,195 @@
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { within } from '@testing-library/dom';
import { mount, createWrapper } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import UserListsComponent from '~/user_lists/components/user_lists.vue';
import UserListsTable from '~/user_lists/components/user_lists_table.vue';
import createStore from '~/user_lists/store/index';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import { userList } from '../../feature_flags/mock_data';
jest.mock('~/api');
Vue.use(Vuex);
describe('~/user_lists/components/user_lists.vue', () => {
const mockProvide = {
newUserListPath: '/user-lists/new',
featureFlagsHelpPagePath: '/help/feature-flags',
errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
};
const mockState = {
projectId: '1',
};
let wrapper;
let store;
const factory = (provide = mockProvide, fn = mount) => {
store = createStore(mockState);
wrapper = fn(UserListsComponent, {
store,
provide,
});
};
const newButton = () => within(wrapper.element).queryAllByText('New user list');
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('without permissions', () => {
const provideData = {
...mockProvide,
newUserListPath: null,
};
beforeEach(() => {
Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [], headers: {} });
factory(provideData);
});
it('does not render new user list button', () => {
expect(newButton()).toHaveLength(0);
});
});
describe('loading state', () => {
it('renders a loading icon', () => {
Api.fetchFeatureFlagUserLists.mockReturnValue(new Promise(() => {}));
factory();
const loadingElement = wrapper.findComponent(GlLoadingIcon);
expect(loadingElement.exists()).toBe(true);
expect(loadingElement.props('label')).toEqual('Loading user lists');
});
});
describe('successful request', () => {
describe('without user lists', () => {
let emptyState;
beforeEach(async () => {
Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [], headers: {} });
factory();
await waitForPromises();
await Vue.nextTick();
emptyState = wrapper.findComponent(GlEmptyState);
});
it('should render the empty state', async () => {
expect(emptyState.exists()).toBe(true);
});
it('renders new feature flag button', () => {
expect(newButton()).not.toHaveLength(0);
});
it('renders generic title', () => {
const title = createWrapper(
within(emptyState.element).getByText('Get started with user lists'),
);
expect(title.exists()).toBe(true);
});
it('renders generic description', () => {
const description = createWrapper(
within(emptyState.element).getByText(
'User lists allow you to define a set of users to use with Feature Flags.',
),
);
expect(description.exists()).toBe(true);
});
});
describe('with paginated user lists', () => {
let table;
beforeEach(async () => {
Api.fetchFeatureFlagUserLists.mockResolvedValue({
data: [userList],
headers: {
'x-next-page': '2',
'x-page': '1',
'X-Per-Page': '2',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '5',
},
});
factory();
jest.spyOn(store, 'dispatch');
await Vue.nextTick();
table = wrapper.findComponent(UserListsTable);
});
it('should render a table with feature flags', () => {
expect(table.exists()).toBe(true);
expect(table.props('userLists')).toEqual([userList]);
});
it('renders new feature flag button', () => {
expect(newButton()).not.toHaveLength(0);
});
describe('pagination', () => {
let pagination;
beforeEach(() => {
pagination = wrapper.findComponent(TablePagination);
});
it('should render pagination', () => {
expect(pagination.exists()).toBe(true);
});
it('should make an API request when page is clicked', () => {
jest.spyOn(store, 'dispatch');
pagination.vm.change('4');
expect(store.dispatch).toHaveBeenCalledWith('setUserListsOptions', {
page: '4',
});
});
});
});
});
describe('unsuccessful request', () => {
beforeEach(async () => {
Api.fetchFeatureFlagUserLists.mockRejectedValue();
factory();
await Vue.nextTick();
});
it('should render error state', () => {
const emptyState = wrapper.findComponent(GlEmptyState);
const title = createWrapper(
within(emptyState.element).getByText('There was an error fetching the user lists.'),
);
expect(title.exists()).toBe(true);
const description = createWrapper(
within(emptyState.element).getByText(
'Try again in a few moments or contact your support team.',
),
);
expect(description.exists()).toBe(true);
});
it('renders new feature flag button', () => {
expect(newButton()).not.toHaveLength(0);
});
});
});

View File

@ -1,8 +1,8 @@
import { GlModal } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import * as timeago from 'timeago.js';
import UserListsTable from '~/feature_flags/components/user_lists_table.vue';
import { userList } from '../mock_data';
import UserListsTable from '~/user_lists/components/user_lists_table.vue';
import { userList } from '../../feature_flags/mock_data';
jest.mock('timeago.js', () => ({
format: jest.fn().mockReturnValue('2 weeks ago'),

View File

@ -0,0 +1,203 @@
import testAction from 'helpers/vuex_action_helper';
import Api from '~/api';
import {
setUserListsOptions,
requestUserLists,
receiveUserListsSuccess,
receiveUserListsError,
fetchUserLists,
deleteUserList,
receiveDeleteUserListError,
clearAlert,
} from '~/user_lists/store/index/actions';
import * as types from '~/user_lists/store/index/mutation_types';
import createState from '~/user_lists/store/index/state';
import { userList } from '../../../feature_flags/mock_data';
jest.mock('~/api.js');
describe('~/user_lists/store/index/actions', () => {
let state;
beforeEach(() => {
state = createState({ projectId: '1' });
});
describe('setUserListsOptions', () => {
it('should commit SET_USER_LISTS_OPTIONS mutation', (done) => {
testAction(
setUserListsOptions,
{ page: '1', scope: 'all' },
state,
[{ type: types.SET_USER_LISTS_OPTIONS, payload: { page: '1', scope: 'all' } }],
[],
done,
);
});
});
describe('fetchUserLists', () => {
beforeEach(() => {
Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} });
});
describe('success', () => {
it('dispatches requestUserLists and receiveUserListsSuccess ', (done) => {
testAction(
fetchUserLists,
null,
state,
[],
[
{
type: 'requestUserLists',
},
{
payload: { data: [userList], headers: {} },
type: 'receiveUserListsSuccess',
},
],
done,
);
});
});
describe('error', () => {
it('dispatches requestUserLists and receiveUserListsError ', (done) => {
Api.fetchFeatureFlagUserLists.mockRejectedValue();
testAction(
fetchUserLists,
null,
state,
[],
[
{
type: 'requestUserLists',
},
{
type: 'receiveUserListsError',
},
],
done,
);
});
});
});
describe('requestUserLists', () => {
it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => {
testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], [], done);
});
});
describe('receiveUserListsSuccess', () => {
it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => {
testAction(
receiveUserListsSuccess,
{ data: [userList], headers: {} },
state,
[
{
type: types.RECEIVE_USER_LISTS_SUCCESS,
payload: { data: [userList], headers: {} },
},
],
[],
done,
);
});
});
describe('receiveUserListsError', () => {
it('should commit RECEIVE_USER_LISTS_ERROR mutation', (done) => {
testAction(
receiveUserListsError,
null,
state,
[{ type: types.RECEIVE_USER_LISTS_ERROR }],
[],
done,
);
});
});
describe('deleteUserList', () => {
beforeEach(() => {
state.userLists = [userList];
});
describe('success', () => {
beforeEach(() => {
Api.deleteFeatureFlagUserList.mockResolvedValue();
});
it('should refresh the user lists', (done) => {
testAction(
deleteUserList,
userList,
state,
[],
[{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }],
done,
);
});
});
describe('error', () => {
beforeEach(() => {
Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } });
});
it('should dispatch receiveDeleteUserListError', (done) => {
testAction(
deleteUserList,
userList,
state,
[],
[
{ type: 'requestDeleteUserList', payload: userList },
{
type: 'receiveDeleteUserListError',
payload: { list: userList, error: 'some error' },
},
],
done,
);
});
});
});
describe('receiveDeleteUserListError', () => {
it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', (done) => {
testAction(
receiveDeleteUserListError,
{ list: userList, error: 'mock error' },
state,
[
{
type: 'RECEIVE_DELETE_USER_LIST_ERROR',
payload: { list: userList, error: 'mock error' },
},
],
[],
done,
);
});
});
describe('clearAlert', () => {
it('should commit RECEIVE_CLEAR_ALERT', (done) => {
const alertIndex = 3;
testAction(
clearAlert,
alertIndex,
state,
[{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }],
[],
done,
);
});
});
});

View File

@ -0,0 +1,121 @@
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import * as types from '~/user_lists/store/index/mutation_types';
import mutations from '~/user_lists/store/index/mutations';
import createState from '~/user_lists/store/index/state';
import { userList } from '../../../feature_flags/mock_data';
describe('~/user_lists/store/index/mutations', () => {
let state;
beforeEach(() => {
state = createState({ projectId: '1' });
});
describe('SET_USER_LISTS_OPTIONS', () => {
it('should set provided options', () => {
mutations[types.SET_USER_LISTS_OPTIONS](state, { page: '1', scope: 'all' });
expect(state.options).toEqual({ page: '1', scope: 'all' });
});
});
describe('REQUEST_USER_LISTS', () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_USER_LISTS](state);
expect(state.isLoading).toBe(true);
});
});
describe('RECEIVE_USER_LISTS_SUCCESS', () => {
const headers = {
'x-next-page': '2',
'x-page': '1',
'X-Per-Page': '2',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '5',
};
beforeEach(() => {
mutations[types.RECEIVE_USER_LISTS_SUCCESS](state, { data: [userList], headers });
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBe(false);
});
it('sets userLists to the received userLists', () => {
expect(state.userLists).toEqual([userList]);
});
it('sets pagination info for user lits', () => {
expect(state.pageInfo).toEqual(parseIntPagination(normalizeHeaders(headers)));
});
it('sets the count for user lists', () => {
expect(state.count).toBe(parseInt(headers['X-TOTAL'], 10));
});
});
describe('RECEIVE_USER_LISTS_ERROR', () => {
beforeEach(() => {
mutations[types.RECEIVE_USER_LISTS_ERROR](state);
});
it('should set isLoading to false', () => {
expect(state.isLoading).toEqual(false);
});
it('should set hasError to true', () => {
expect(state.hasError).toEqual(true);
});
});
describe('REQUEST_DELETE_USER_LIST', () => {
beforeEach(() => {
state.userLists = [userList];
mutations[types.REQUEST_DELETE_USER_LIST](state, userList);
});
it('should remove the deleted list', () => {
expect(state.userLists).not.toContain(userList);
});
});
describe('RECEIVE_DELETE_USER_LIST_ERROR', () => {
beforeEach(() => {
state.userLists = [];
mutations[types.RECEIVE_DELETE_USER_LIST_ERROR](state, {
list: userList,
error: 'some error',
});
});
it('should set isLoading to false and hasError to false', () => {
expect(state.isLoading).toBe(false);
expect(state.hasError).toBe(false);
});
it('should add the user list back to the list of user lists', () => {
expect(state.userLists).toContain(userList);
});
});
describe('RECEIVE_CLEAR_ALERT', () => {
it('clears the alert', () => {
state.alerts = ['a server error'];
mutations[types.RECEIVE_CLEAR_ALERT](state, 0);
expect(state.alerts).toEqual([]);
});
it('clears the alert at the specified index', () => {
state.alerts = ['a server error', 'another error', 'final error'];
mutations[types.RECEIVE_CLEAR_ALERT](state, 1);
expect(state.alerts).toEqual(['a server error', 'final error']);
});
});
});

View File

@ -143,4 +143,41 @@ RSpec.describe PreferencesHelper do
.and_return(double('user', messages))
end
end
describe '#integration_views' do
let(:gitpod_url) { 'http://gitpod.test' }
before do
allow(Gitlab::CurrentSettings).to receive(:gitpod_enabled).and_return(gitpod_enabled)
allow(Gitlab::CurrentSettings).to receive(:gitpod_url).and_return(gitpod_url)
end
context 'when Gitpod is not enabled' do
let(:gitpod_enabled) { false }
it 'does not include Gitpod integration' do
expect(helper.integration_views).to be_empty
end
end
context 'when Gitpod is enabled' do
let(:gitpod_enabled) { true }
it 'includes Gitpod integration' do
expect(helper.integration_views[0][:name]).to eq 'gitpod'
end
it 'returns the Gitpod url configured in settings' do
expect(helper.integration_views[0][:message_url]).to eq gitpod_url
end
context 'when Gitpod url is not set' do
let(:gitpod_url) { '' }
it 'returns the Gitpod default url' do
expect(helper.integration_views[0][:message_url]).to eq 'https://gitpod.io/'
end
end
end
end
end

View File

@ -5,9 +5,11 @@ require 'spec_helper'
RSpec.describe Banzai::ReferenceParser::IssueParser do
include ReferenceParserHelpers
let(:project) { create(:project, :public) }
let(:user) { create(:user) }
let(:issue) { create(:issue, project: project) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue, project: project) }
let(:link) { empty_html_link }
subject { described_class.new(Banzai::RenderContext.new(project, user)) }
@ -121,7 +123,7 @@ RSpec.describe Banzai::ReferenceParser::IssueParser do
end
end
context 'when checking multiple merge requests on another project' do
context 'when checking multiple issues on another project' do
let(:other_project) { create(:project, :public) }
let(:other_issue) { create(:issue, project: other_project) }

View File

@ -5,9 +5,11 @@ require 'spec_helper'
RSpec.describe Banzai::ReferenceParser::MergeRequestParser do
include ReferenceParserHelpers
let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, group: group) }
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:merge_request) { create(:merge_request, source_project: project) }
subject(:parser) { described_class.new(Banzai::RenderContext.new(merge_request.target_project, user)) }
let(:link) { empty_html_link }

View File

@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Pipeline::Chain::Validate::External do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:user) { create(:user, :with_sign_ins) }
let(:pipeline) { build(:ci_empty_pipeline, user: user, project: project) }
let!(:step) { described_class.new(pipeline, command) }

View File

@ -8,6 +8,20 @@ RSpec.describe Sidebars::Projects::Menus::ProjectInformationMenu do
let(:user) { project.owner }
let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
describe '#container_html_options' do
subject { described_class.new(context).container_html_options }
specify { is_expected.to match(hash_including(class: 'shortcuts-project-information')) }
context 'when feature flag :sidebar_refactor is disabled' do
before do
stub_feature_flags(sidebar_refactor: false)
end
specify { is_expected.to match(hash_including(class: 'shortcuts-project rspec-project-link')) }
end
end
describe 'Menu Items' do
subject { described_class.new(context).renderable_items.index { |e| e.item_id == item_id } }

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Sidebars::Projects::Menus::ScopeMenu do
let(:project) { build(:project) }
let(:user) { project.owner }
let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) }
describe '#container_html_options' do
subject { described_class.new(context).container_html_options }
specify { is_expected.to match(hash_including(class: 'shortcuts-project rspec-project-link')) }
context 'when feature flag :sidebar_refactor is disabled' do
before do
stub_feature_flags(sidebar_refactor: false)
end
specify { is_expected.to eq(aria: { label: project.name }) }
end
end
end

View File

@ -831,6 +831,19 @@ RSpec.describe Notify do
is_expected.to have_body_text project_member.invite_token
end
end
context 'when on gitlab.com' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
end
it 'has custom headers' do
aggregate_failures do
expect(subject).to have_header('X-Mailgun-Tag', 'invite_email')
expect(subject).to have_header('X-Mailgun-Variables', { 'invite_token' => project_member.invite_token }.to_json)
end
end
end
end
describe 'project invitation accepted' do

View File

@ -1,60 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20190527194900_schedule_calculate_wiki_sizes.rb')
RSpec.describe ScheduleCalculateWikiSizes do
let(:migration_class) { Gitlab::BackgroundMigration::CalculateWikiSizes }
let(:migration_name) { migration_class.to_s.demodulize }
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:project_statistics) { table(:project_statistics) }
let(:namespace) { namespaces.create!(name: 'wiki-migration', path: 'wiki-migration') }
let(:project1) { projects.create!(name: 'wiki-project-1', path: 'wiki-project-1', namespace_id: namespace.id) }
let(:project2) { projects.create!(name: 'wiki-project-2', path: 'wiki-project-2', namespace_id: namespace.id) }
let(:project3) { projects.create!(name: 'wiki-project-3', path: 'wiki-project-3', namespace_id: namespace.id) }
context 'when missing wiki sizes exist' do
let!(:project_statistic1) { project_statistics.create!(project_id: project1.id, namespace_id: namespace.id, wiki_size: 1000) }
let!(:project_statistic2) { project_statistics.create!(project_id: project2.id, namespace_id: namespace.id, wiki_size: nil) }
let!(:project_statistic3) { project_statistics.create!(project_id: project3.id, namespace_id: namespace.id, wiki_size: nil) }
it 'schedules a background migration' do
freeze_time do
migrate!
expect(migration_name).to be_scheduled_delayed_migration(5.minutes, project_statistic2.id, project_statistic3.id)
expect(BackgroundMigrationWorker.jobs.size).to eq 1
end
end
it 'calculates missing wiki sizes', :sidekiq_inline do
expect(project_statistic2.wiki_size).to be_nil
expect(project_statistic3.wiki_size).to be_nil
migrate!
expect(project_statistic2.reload.wiki_size).not_to be_nil
expect(project_statistic3.reload.wiki_size).not_to be_nil
end
end
context 'when missing wiki sizes do not exist' do
before do
namespace = namespaces.create!(name: 'wiki-migration', path: 'wiki-migration')
project = projects.create!(name: 'wiki-project-1', path: 'wiki-project-1', namespace_id: namespace.id)
project_statistics.create!(project_id: project.id, namespace_id: namespace.id, wiki_size: 1000)
end
it 'does not schedule a background migration' do
Sidekiq::Testing.fake! do
freeze_time do
migrate!
expect(BackgroundMigrationWorker.jobs.size).to eq 0
end
end
end
end
end

View File

@ -517,6 +517,10 @@ RSpec.describe Group do
it { expect(group.self_and_descendants.to_sql).not_to include 'traversal_ids @>' }
end
describe '#self_and_descendant_ids' do
it { expect(group.self_and_descendant_ids.to_sql).not_to include 'traversal_ids @>' }
end
describe '#descendants' do
it { expect(group.descendants.to_sql).not_to include 'traversal_ids @>' }
end
@ -533,6 +537,10 @@ RSpec.describe Group do
it { expect(group.self_and_descendants.to_sql).to include 'traversal_ids @>' }
end
describe '#self_and_descendant_ids' do
it { expect(group.self_and_descendant_ids.to_sql).to include 'traversal_ids @>' }
end
describe '#descendants' do
it { expect(group.descendants.to_sql).to include 'traversal_ids @>' }
end

View File

@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe Namespace do
include ProjectForksHelper
include GitHelpers
include ReloadHelpers
let!(:namespace) { create(:namespace, :with_namespace_settings) }
let(:gitlab_shell) { Gitlab::Shell.new }
@ -199,6 +200,8 @@ RSpec.describe Namespace do
it { is_expected.to include_module(Namespaces::Traversal::Linear) }
end
it_behaves_like 'linear namespace traversal'
context 'traversal_ids on create' do
context 'default traversal_ids' do
let(:namespace) { build(:namespace) }
@ -1010,35 +1013,51 @@ RSpec.describe Namespace do
end
end
describe '#all_projects' do
shared_examples '#all_projects' do
context 'when namespace is a group' do
let(:namespace) { create(:group) }
let(:child) { create(:group, parent: namespace) }
let!(:project1) { create(:project_empty_repo, namespace: namespace) }
let!(:project2) { create(:project_empty_repo, namespace: child) }
let_it_be(:namespace) { create(:group) }
let_it_be(:child) { create(:group, parent: namespace) }
let_it_be(:project1) { create(:project_empty_repo, namespace: namespace) }
let_it_be(:project2) { create(:project_empty_repo, namespace: child) }
let_it_be(:other_project) { create(:project_empty_repo) }
before do
reload_models(namespace, child)
end
it { expect(namespace.all_projects.to_a).to match_array([project2, project1]) }
it { expect(child.all_projects.to_a).to match_array([project2]) }
it 'queries for the namespace and its descendants' do
expect(Project).to receive(:where).with(namespace: [namespace, child])
namespace.all_projects
end
end
context 'when namespace is a user namespace' do
let_it_be(:user) { create(:user) }
let_it_be(:user_namespace) { create(:namespace, owner: user) }
let_it_be(:project) { create(:project, namespace: user_namespace) }
let_it_be(:other_project) { create(:project_empty_repo) }
before do
reload_models(user_namespace)
end
it { expect(user_namespace.all_projects.to_a).to match_array([project]) }
end
end
it 'only queries for the namespace itself' do
expect(Project).to receive(:where).with(namespace: user_namespace)
user_namespace.all_projects
describe '#all_projects' do
context 'with use_traversal_ids feature flag enabled' do
before do
stub_feature_flags(use_traversal_ids: true)
end
include_examples '#all_projects'
end
context 'with use_traversal_ids feature flag disabled' do
before do
stub_feature_flags(use_traversal_ids: false)
end
include_examples '#all_projects'
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::ParticipantsService do
describe '#group_members' do
let(:user) { create(:user) }
let(:parent_group) { create(:group) }
let(:group) { create(:group, parent: parent_group) }
let(:subgroup) { create(:group, parent: group) }
let(:subproject) { create(:project, group: subgroup) }
it 'returns all members in parent groups, sub-groups, and sub-projects' do
parent_group.add_developer(create(:user))
subgroup.add_developer(create(:user))
subproject.add_developer(create(:user))
result = described_class.new(group, user).execute(nil)
expected_users = (group.self_and_hierarchy.flat_map(&:users) + subproject.users)
.map { |user| user_to_autocompletable(user) }
expect(expected_users.count).to eq(3)
expect(result).to include(*expected_users)
end
end
def user_to_autocompletable(user)
{
type: user.class.name,
username: user.username,
name: user.name,
avatar_url: user.avatar_url,
availability: user&.status&.availability
}
end
end

View File

@ -90,6 +90,5 @@ module FeatureFlagHelpers
def expect_user_to_see_feature_flags_index_page
expect(page).to have_text('Feature Flags')
expect(page).to have_text('Lists')
end
end

View File

@ -21,6 +21,11 @@ module ReferenceParserHelpers
end
control = record_queries.call(control_links)
create(:group_member, group: project.group) if project.group
create(:project_member, project: project)
create(:project_group_link, project: project)
actual = record_queries.call(actual_links)
expect(actual.count).to be <= control.count

View File

@ -71,8 +71,16 @@ RSpec.shared_context 'project navbar structure' do
]
end
let(:project_context_nav_item) do
{
nav_item: "#{project.name[0, 1].upcase} #{project.name}",
nav_sub_items: []
}
end
let(:structure) do
[
project_context_nav_item,
project_information_nav_item,
{
nav_item: _('Repository'),

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
# Traversal examples common to linear and recursive methods are in
# spec/support/shared_examples/namespaces/traversal_examples.rb
RSpec.shared_examples 'linear namespace traversal' do
context 'when use_traversal_ids feature flag is enabled' do
before do
stub_feature_flags(use_traversal_ids: true)
end
context 'scopes' do
describe '.as_ids' do
let_it_be(:namespace1) { create(:group) }
let_it_be(:namespace2) { create(:group) }
subject { Namespace.where(id: [namespace1, namespace2]).as_ids.pluck(:id) }
it { is_expected.to contain_exactly(namespace1.id, namespace2.id) }
end
end
end
end

View File

@ -122,4 +122,20 @@ RSpec.shared_examples 'namespace traversal' do
it_behaves_like 'recursive version', :self_and_descendants
end
end
describe '#self_and_descendant_ids' do
let!(:group) { create(:group, path: 'git_lab') }
let!(:nested_group) { create(:group, parent: group) }
let!(:deep_nested_group) { create(:group, parent: nested_group) }
subject { group.self_and_descendant_ids.pluck(:id) }
it { is_expected.to contain_exactly(group.id, nested_group.id, deep_nested_group.id) }
describe '#recursive_self_and_descendant_ids' do
let(:groups) { [group, nested_group, deep_nested_group] }
it_behaves_like 'recursive version', :self_and_descendant_ids
end
end
end

View File

@ -105,7 +105,7 @@ RSpec.describe Tooling::Danger::Changelog do
context "and there are DB changes" do
let(:foss_change) { change_class.new('db/migrate/foo.rb', :added, :migration) }
it { is_expected.to have_attributes(warnings: ["This MR has a Changelog commit with the `EE: true` trailer, but there are database changes which [requires](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry) the Changelog commiot to not have the `EE: true` trailer. Consider removing the `EE: true` trailer."]) }
it { is_expected.to have_attributes(warnings: ["This MR has a Changelog commit with the `EE: true` trailer, but there are database changes which [requires](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry) the Changelog commit to not have the `EE: true` trailer. Consider removing the `EE: true` trailer."]) }
end
end
end

View File

@ -19,21 +19,32 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it_behaves_like 'has nav sidebar'
describe 'Project information' do
describe 'Project context' do
it 'has a link to the project path' do
render
expect(rendered).to have_link('Project information', href: project_path(project), class: %w(shortcuts-project rspec-project-link))
expect(rendered).to have_link(project.name, href: project_path(project), class: %w(shortcuts-project rspec-project-link))
expect(rendered).to have_selector("[aria-label=\"#{project.name}\"]")
end
end
describe 'Project information' do
it 'has a link to the project activity path' do
render
expect(rendered).to have_link('Project information', href: activity_project_path(project), class: %w(shortcuts-project-information))
expect(rendered).to have_selector('[aria-label="Project information"]')
end
context 'when feature flag :sidebar_refactor is disabled' do
it 'has a link to the project path' do
before do
stub_feature_flags(sidebar_refactor: false)
end
it 'has a link to the project path' do
render
expect(rendered).to have_link('Project overview', href: project_path(project), class: %w(shortcuts-project rspec-project-link))
expect(rendered).to have_link('Project overview', href: project_path(project), class: %w(shortcuts-project))
expect(rendered).to have_selector('[aria-label="Project overview"]')
end
end
@ -89,7 +100,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it 'has a link to the labels path' do
render
expect(page.at_css('.shortcuts-project').parent.css('[aria-label="Labels"]')).not_to be_empty
expect(page.at_css('.shortcuts-project-information').parent.css('[aria-label="Labels"]')).not_to be_empty
expect(rendered).to have_link('Labels', href: project_labels_path(project))
end
@ -110,7 +121,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it 'has a link to the members page' do
render
expect(page.at_css('.shortcuts-project').parent.css('[aria-label="Members"]')).not_to be_empty
expect(page.at_css('.shortcuts-project-information').parent.css('[aria-label="Members"]')).not_to be_empty
expect(rendered).to have_link('Members', href: project_project_members_path(project))
end

View File

@ -146,7 +146,7 @@ module Tooling
end
if ee_changes.any? && ee_changelog? && required_reasons.include?(:db_changes)
check_result.warning("This MR has a Changelog commit with the `EE: true` trailer, but there are database changes which [requires](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry) the Changelog commiot to not have the `EE: true` trailer. Consider removing the `EE: true` trailer.")
check_result.warning("This MR has a Changelog commit with the `EE: true` trailer, but there are database changes which [requires](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry) the Changelog commit to not have the `EE: true` trailer. Consider removing the `EE: true` trailer.")
end
check_result