Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
47d07def16
commit
04f9cef437
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 }">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export default ({ projectId }) => ({
|
||||
userLists: [],
|
||||
alerts: [],
|
||||
count: 0,
|
||||
pageInfo: {},
|
||||
isLoading: true,
|
||||
hasError: false,
|
||||
options: {},
|
||||
projectId,
|
||||
});
|
||||
|
|
@ -6,6 +6,9 @@ class Projects::FeatureFlagsUserListsController < Projects::ApplicationControlle
|
|||
|
||||
feature_category :feature_flags
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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') } }
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,4 +14,3 @@ distribution:
|
|||
- ee
|
||||
tier:
|
||||
- free
|
||||
skip_validation: true
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
307e45d581c48b6f571fc8fa2a00dfd4360296560ee2b320540314b8f9f9e02c
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [] } } });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'),
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue