diff options
100 files changed, 2176 insertions, 894 deletions
diff --git a/.gitlab/CODEOWNERS b/.gitlab/CODEOWNERS index 4a94f672f0a..eee9b638d78 100644 --- a/.gitlab/CODEOWNERS +++ b/.gitlab/CODEOWNERS @@ -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 diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/empty_state.vue index d0df00e446b..a6de4972bb1 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue +++ b/app/assets/javascripts/feature_flags/components/empty_state.vue @@ -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> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 9aa1accb0f2..d08e8d2b3a1 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -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; @@ -200,6 +152,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'" variant="info" @@ -212,17 +174,6 @@ export default { </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" variant="confirm" @@ -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" - > - <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" + <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" > - <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> - - <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> + {{ 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="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> diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js index 658984456a5..f697f203cf5 100644 --- a/app/assets/javascripts/feature_flags/constants.js +++ b/app/assets/javascripts/feature_flags/constants.js @@ -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 = [ diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js index d2371a2aa8b..5c0d9cb8624 100644 --- a/app/assets/javascripts/feature_flags/index.js +++ b/app/assets/javascripts/feature_flags/index.js @@ -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); diff --git a/app/assets/javascripts/feature_flags/store/index/actions.js b/app/assets/javascripts/feature_flags/store/index/actions.js index 4372c280f39..751f627ca48 100644 --- a/app/assets/javascripts/feature_flags/store/index/actions.js +++ b/app/assets/javascripts/feature_flags/store/index/actions.js @@ -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'); diff --git a/app/assets/javascripts/feature_flags/store/index/mutation_types.js b/app/assets/javascripts/feature_flags/store/index/mutation_types.js index 189c763782e..ed05294a6f3 100644 --- a/app/assets/javascripts/feature_flags/store/index/mutation_types.js +++ b/app/assets/javascripts/feature_flags/store/index/mutation_types.js @@ -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'; diff --git a/app/assets/javascripts/feature_flags/store/index/mutations.js b/app/assets/javascripts/feature_flags/store/index/mutations.js index 25eb7da1c72..54e48a4b80c 100644 --- a/app/assets/javascripts/feature_flags/store/index/mutations.js +++ b/app/assets/javascripts/feature_flags/store/index/mutations.js @@ -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); }, diff --git a/app/assets/javascripts/feature_flags/store/index/state.js b/app/assets/javascripts/feature_flags/store/index/state.js index f8439b02639..488da265b28 100644 --- a/app/assets/javascripts/feature_flags/store/index/state.js +++ b/app/assets/javascripts/feature_flags/store/index/state.js @@ -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, diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js new file mode 100644 index 00000000000..519e04e14fb --- /dev/null +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js @@ -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); + }, +}); diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index 1acf3a03e73..abc981493c7 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -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" diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index a945fc542a5..ebe73bdcec3 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -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> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue new file mode 100644 index 00000000000..8bcaa5df7b6 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -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> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index 3d4c1cb43d5..f58f271c9ee 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -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 }"> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index de3a3fda47e..51dc0afdd0b 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -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; diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql index 67cb0b0201d..1f094b72e79 100644 --- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql @@ -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 diff --git a/app/assets/javascripts/runner/runner_list/filtered_search_utils.js b/app/assets/javascripts/runner/runner_list/filtered_search_utils.js new file mode 100644 index 00000000000..4ae068c3eb6 --- /dev/null +++ b/app/assets/javascripts/runner/runner_list/filtered_search_utils.js @@ -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; +}; diff --git a/app/assets/javascripts/runner/runner_list/runner_list_app.vue b/app/assets/javascripts/runner/runner_list/runner_list_app.vue index c637f815d9a..e0f3330fef5 100644 --- a/app/assets/javascripts/runner/runner_list/runner_list_app.vue +++ b/app/assets/javascripts/runner/runner_list/runner_list_app.vue @@ -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> diff --git a/app/assets/javascripts/user_lists/components/user_lists.vue b/app/assets/javascripts/user_lists/components/user_lists.vue new file mode 100644 index 00000000000..80be894c689 --- /dev/null +++ b/app/assets/javascripts/user_lists/components/user_lists.vue @@ -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> diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/user_lists/components/user_lists_table.vue index 765f59228a6..765f59228a6 100644 --- a/app/assets/javascripts/feature_flags/components/user_lists_table.vue +++ b/app/assets/javascripts/user_lists/components/user_lists_table.vue diff --git a/app/assets/javascripts/user_lists/store/index/actions.js b/app/assets/javascripts/user_lists/store/index/actions.js new file mode 100644 index 00000000000..432c576694a --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/actions.js @@ -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); diff --git a/app/assets/javascripts/user_lists/store/index/index.js b/app/assets/javascripts/user_lists/store/index/index.js new file mode 100644 index 00000000000..9b9df59ed32 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/index.js @@ -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), + }); diff --git a/app/assets/javascripts/user_lists/store/index/mutation_types.js b/app/assets/javascripts/user_lists/store/index/mutation_types.js new file mode 100644 index 00000000000..5637ed60b7b --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/mutation_types.js @@ -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'; diff --git a/app/assets/javascripts/user_lists/store/index/mutations.js b/app/assets/javascripts/user_lists/store/index/mutations.js new file mode 100644 index 00000000000..8e2865dc165 --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/mutations.js @@ -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); + }, +}; diff --git a/app/assets/javascripts/user_lists/store/index/state.js b/app/assets/javascripts/user_lists/store/index/state.js new file mode 100644 index 00000000000..0658d23cffc --- /dev/null +++ b/app/assets/javascripts/user_lists/store/index/state.js @@ -0,0 +1,10 @@ +export default ({ projectId }) => ({ + userLists: [], + alerts: [], + count: 0, + pageInfo: {}, + isLoading: true, + hasError: false, + options: {}, + projectId, +}); diff --git a/app/controllers/projects/feature_flags_user_lists_controller.rb b/app/controllers/projects/feature_flags_user_lists_controller.rb index 7be3254e966..fd81321924a 100644 --- a/app/controllers/projects/feature_flags_user_lists_controller.rb +++ b/app/controllers/projects/feature_flags_user_lists_controller.rb @@ -6,6 +6,9 @@ class Projects::FeatureFlagsUserListsController < Projects::ApplicationControlle feature_category :feature_flags + def index + end + def new end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index d851ed3db8f..af9ff33cd12 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -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 diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 674a9bfc4eb..d1870065845 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -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? diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index df0d1774d6b..ceeb178e9c2 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -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 diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 0fd0298cc6d..409d37fc097 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -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 diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index a1711bc5ee0..480289b0df0 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -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? diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb index 409438f53d2..c33260a6c91 100644 --- a/app/models/namespaces/traversal/recursive.rb +++ b/app/models/namespaces/traversal/recursive.rb @@ -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 diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 37ddd2d030d..387732cf151 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -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 diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb index 0844c98dd6a..1de2b3c5a2e 100644 --- a/app/services/groups/participants_service.rb +++ b/app/services/groups/participants_service.rb @@ -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 diff --git a/app/views/projects/feature_flags/index.html.haml b/app/views/projects/feature_flags/index.html.haml index 7d48cba74d0..53fe30422ca 100644 --- a/app/views/projects/feature_flags/index.html.haml +++ b/app/views/projects/feature_flags/index.html.haml @@ -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 } } diff --git a/app/views/projects/feature_flags_user_lists/edit.html.haml b/app/views/projects/feature_flags_user_lists/edit.html.haml index ea47cc06c0e..1ff488ff0f0 100644 --- a/app/views/projects/feature_flags_user_lists/edit.html.haml +++ b/app/views/projects/feature_flags_user_lists/edit.html.haml @@ -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') diff --git a/app/views/projects/feature_flags_user_lists/index.html.haml b/app/views/projects/feature_flags_user_lists/index.html.haml new file mode 100644 index 00000000000..f0e3c36992a --- /dev/null +++ b/app/views/projects/feature_flags_user_lists/index.html.haml @@ -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') } } diff --git a/app/views/projects/feature_flags_user_lists/new.html.haml b/app/views/projects/feature_flags_user_lists/new.html.haml index 3d25453cb66..f2e1ea38d9c 100644 --- a/app/views/projects/feature_flags_user_lists/new.html.haml +++ b/app/views/projects/feature_flags_user_lists/new.html.haml @@ -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') diff --git a/app/views/projects/feature_flags_user_lists/show.html.haml b/app/views/projects/feature_flags_user_lists/show.html.haml index add256f0190..2c88f3da66b 100644 --- a/app/views/projects/feature_flags_user_lists/show.html.haml +++ b/app/views/projects/feature_flags_user_lists/show.html.haml @@ -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') diff --git a/app/views/shared/nav/_scope_menu.html.haml b/app/views/shared/nav/_scope_menu.html.haml index 2f10914ef3d..cbee0e0429c 100644 --- a/app/views/shared/nav/_scope_menu.html.haml +++ b/app/views/shared/nav/_scope_menu.html.haml @@ -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 diff --git a/app/views/shared/nav/_scope_menu_body.html.haml b/app/views/shared/nav/_scope_menu_body.html.haml new file mode 100644 index 00000000000..4e08bee4ee4 --- /dev/null +++ b/app/views/shared/nav/_scope_menu_body.html.haml @@ -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 diff --git a/app/views/shared/nav/_sidebar.html.haml b/app/views/shared/nav/_sidebar.html.haml index 552dcbfd6fd..54c3b8a281d 100644 --- a/app/views/shared/nav/_sidebar.html.haml +++ b/app/views/shared/nav/_sidebar.html.haml @@ -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 diff --git a/config/metrics/counts_all/20210216175024_service_desk_enabled_projects.yml b/config/metrics/counts_all/20210216175024_service_desk_enabled_projects.yml index 8e1129883ae..e9a534911bb 100644 --- a/config/metrics/counts_all/20210216175024_service_desk_enabled_projects.yml +++ b/config/metrics/counts_all/20210216175024_service_desk_enabled_projects.yml @@ -14,4 +14,3 @@ distribution: - ee tier: - free -skip_validation: true diff --git a/config/routes/project.rb b/config/routes/project.rb index 935e816f73c..4213febc1fc 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -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', diff --git a/db/migrate/20210510083845_add_sha_to_status_check_response.rb b/db/migrate/20210510083845_add_sha_to_status_check_response.rb new file mode 100644 index 00000000000..202f5ca00c1 --- /dev/null +++ b/db/migrate/20210510083845_add_sha_to_status_check_response.rb @@ -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 diff --git a/db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb b/db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb index 04cf5906b61..f337390f10c 100644 --- a/db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb +++ b/db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb @@ -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 diff --git a/db/schema_migrations/20210510083845 b/db/schema_migrations/20210510083845 new file mode 100644 index 00000000000..c3c67b9520e --- /dev/null +++ b/db/schema_migrations/20210510083845 @@ -0,0 +1 @@ +307e45d581c48b6f571fc8fa2a00dfd4360296560ee2b320540314b8f9f9e02c
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 6dfa80f16fd..4f73435d562 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -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 diff --git a/doc/administration/external_pipeline_validation.md b/doc/administration/external_pipeline_validation.md index 23207de4999..4e96d1484c3 100644 --- a/doc/administration/external_pipeline_validation.md +++ b/doc/administration/external_pipeline_validation.md @@ -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": { diff --git a/doc/administration/operations/unicorn.md b/doc/administration/operations/unicorn.md new file mode 100644 index 00000000000..6cee19186f9 --- /dev/null +++ b/doc/administration/operations/unicorn.md @@ -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 --> diff --git a/doc/administration/postgresql/external.md b/doc/administration/postgresql/external.md index 1e346a3b8aa..8f0fe0ace87 100644 --- a/doc/administration/postgresql/external.md +++ b/doc/administration/postgresql/external.md @@ -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). diff --git a/doc/api/status_checks.md b/doc/api/status_checks.md new file mode 100644 index 00000000000..116044adb41 --- /dev/null +++ b/doc/api/status_checks.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>) +``` diff --git a/doc/operations/feature_flags.md b/doc/operations/feature_flags.md index dc0d5d77d27..3d87f03832c 100644 --- a/doc/operations/feature_flags.md +++ b/doc/operations/feature_flags.md @@ -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. diff --git a/doc/user/project/description_templates.md b/doc/user/project/description_templates.md index 3acef242cef..4a2bd56b7ba 100644 --- a/doc/user/project/description_templates.md +++ b/doc/user/project/description_templates.md @@ -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,24 +232,24 @@ 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: +To disable it: ```ruby -Feature.enable(:inherited_issuable_templates) +Feature.disable(:inherited_issuable_templates) ``` -To disable it: +To enable it: ```ruby -Feature.disable(:inherited_issuable_templates) +Feature.enable(:inherited_issuable_templates) ``` The feature flag affects these features: diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb index 97c7173ac0f..6b1491cc56b 100644 --- a/lib/banzai/reference_parser/issue_parser.rb +++ b/lib/banzai/reference_parser/issue_parser.rb @@ -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 diff --git a/lib/banzai/reference_parser/merge_request_parser.rb b/lib/banzai/reference_parser/merge_request_parser.rb index 24bc1a24e09..78cbf4a807d 100644 --- a/lib/banzai/reference_parser/merge_request_parser.rb +++ b/lib/banzai/reference_parser/merge_request_parser.rb @@ -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 ) diff --git a/lib/gitlab/ci/pipeline/chain/validate/external.rb b/lib/gitlab/ci/pipeline/chain/validate/external.rb index 18675f80279..27bb7fdc05a 100644 --- a/lib/gitlab/ci/pipeline/chain/validate/external.rb +++ b/lib/gitlab/ci/pipeline/chain/validate/external.rb @@ -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, diff --git a/lib/sidebars/projects/menus/project_information_menu.rb b/lib/sidebars/projects/menus/project_information_menu.rb index 3d3ac5dac3e..898fd2fb67e 100644 --- a/lib/sidebars/projects/menus/project_information_menu.rb +++ b/lib/sidebars/projects/menus/project_information_menu.rb @@ -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 diff --git a/lib/sidebars/projects/menus/scope_menu.rb b/lib/sidebars/projects/menus/scope_menu.rb index 1d1cf11b271..42399ef68a4 100644 --- a/lib/sidebars/projects/menus/scope_menu.rb +++ b/lib/sidebars/projects/menus/scope_menu.rb @@ -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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index aadde55bfe4..636251f49da 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb b/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb index e0d1d3765b2..32817f048e6 100644 --- a/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb +++ b/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb @@ -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) diff --git a/spec/features/contextual_sidebar_spec.rb b/spec/features/contextual_sidebar_spec.rb index 8ea1ebac6b7..39881a28b11 100644 --- a/spec/features/contextual_sidebar_spec.rb +++ b/spec/features/contextual_sidebar_spec.rb @@ -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 diff --git a/spec/features/groups/milestones/gfm_autocomplete_spec.rb b/spec/features/groups/milestones/gfm_autocomplete_spec.rb index 85a14123294..1fec6091f1e 100644 --- a/spec/features/groups/milestones/gfm_autocomplete_spec.rb +++ b/spec/features/groups/milestones/gfm_autocomplete_spec.rb @@ -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 diff --git a/spec/features/projects/active_tabs_spec.rb b/spec/features/projects/active_tabs_spec.rb index 96a321037a9..b333f64aa87 100644 --- a/spec/features/projects/active_tabs_spec.rb +++ b/spec/features/projects/active_tabs_spec.rb @@ -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' diff --git a/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb b/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb index 2a81c706525..37d6f299883 100644 --- a/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb +++ b/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb @@ -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 diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 7073741a92d..94543290050 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -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 diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb index ee5bf99fd75..bce11e6bc8a 100644 --- a/spec/features/projects/navbar_spec.rb +++ b/spec/features/projects/navbar_spec.rb @@ -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')) diff --git a/spec/features/projects/user_uses_shortcuts_spec.rb b/spec/features/projects/user_uses_shortcuts_spec.rb index 1350ecf6e75..2f7844ff615 100644 --- a/spec/features/projects/user_uses_shortcuts_spec.rb +++ b/spec/features/projects/user_uses_shortcuts_spec.rb @@ -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 diff --git a/spec/fixtures/api/schemas/external_validation.json b/spec/fixtures/api/schemas/external_validation.json index 3ff71626cc0..280b77b221a 100644 --- a/spec/fixtures/api/schemas/external_validation.json +++ b/spec/fixtures/api/schemas/external_validation.json @@ -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": { diff --git a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js b/spec/frontend/feature_flags/components/empty_state_spec.js index c2170e8a768..86d0c1a05fd 100644 --- a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js +++ b/spec/frontend/feature_flags/components/empty_state_spec.js @@ -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, - ); - }); - }); }); diff --git a/spec/frontend/feature_flags/components/feature_flags_spec.js b/spec/frontend/feature_flags/components/feature_flags_spec.js index b519aab0dc4..db4bdc736de 100644 --- a/spec/frontend/feature_flags/components/feature_flags_spec.js +++ b/spec/frontend/feature_flags/components/feature_flags_spec.js @@ -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(); diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js index a7ab2e92cb2..ec311ef92a3 100644 --- a/spec/frontend/feature_flags/store/index/actions_spec.js +++ b/spec/frontend/feature_flags/store/index/actions_spec.js @@ -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) => { diff --git a/spec/frontend/feature_flags/store/index/mutations_spec.js b/spec/frontend/feature_flags/store/index/mutations_spec.js index 08b5868d1b4..b9354196c68 100644 --- a/spec/frontend/feature_flags/store/index/mutations_spec.js +++ b/spec/frontend/feature_flags/store/index/mutations_spec.js @@ -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']; diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js new file mode 100644 index 00000000000..aa1752d187f --- /dev/null +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -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, + }, + ]); + }); +}); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 4e48c3cd31c..4fb24b7aab0 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -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); + }); }); }); diff --git a/spec/frontend/runner/runner_list/filtered_search_utils_spec.js b/spec/frontend/runner/runner_list/filtered_search_utils_spec.js new file mode 100644 index 00000000000..e46821d6504 --- /dev/null +++ b/spec/frontend/runner/runner_list/filtered_search_utils_spec.js @@ -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); + }); + }); + }); +}); diff --git a/spec/frontend/runner/runner_list/runner_list_app_spec.js b/spec/frontend/runner/runner_list/runner_list_app_spec.js index 12df8e8adcd..19a5a60d2c1 100644 --- a/spec/frontend/runner/runner_list/runner_list_app_spec.js +++ b/spec/frontend/runner/runner_list/runner_list_app_spec.js @@ -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: [] } } }); diff --git a/spec/frontend/user_lists/components/user_lists_spec.js b/spec/frontend/user_lists/components/user_lists_spec.js new file mode 100644 index 00000000000..7a33c6faac9 --- /dev/null +++ b/spec/frontend/user_lists/components/user_lists_spec.js @@ -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); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js index 1b04ecee146..925a52ee562 100644 --- a/spec/frontend/feature_flags/components/user_lists_table_spec.js +++ b/spec/frontend/user_lists/components/user_lists_table_spec.js @@ -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'), diff --git a/spec/frontend/user_lists/store/index/actions_spec.js b/spec/frontend/user_lists/store/index/actions_spec.js new file mode 100644 index 00000000000..c5d7d557de9 --- /dev/null +++ b/spec/frontend/user_lists/store/index/actions_spec.js @@ -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, + ); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/index/mutations_spec.js b/spec/frontend/user_lists/store/index/mutations_spec.js new file mode 100644 index 00000000000..370838ae5fb --- /dev/null +++ b/spec/frontend/user_lists/store/index/mutations_spec.js @@ -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']); + }); + }); +}); diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index 6be6d3670d4..ad2f142e3ff 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -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 diff --git a/spec/lib/banzai/reference_parser/issue_parser_spec.rb b/spec/lib/banzai/reference_parser/issue_parser_spec.rb index 76f13e7b3aa..7de78710d34 100644 --- a/spec/lib/banzai/reference_parser/issue_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/issue_parser_spec.rb @@ -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) } diff --git a/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb b/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb index 1820141c898..a57e4e52501 100644 --- a/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb @@ -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 } diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb index 84377981cbc..16517b39a45 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb @@ -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) } diff --git a/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb b/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb index b50bf0f4bf1..ef983672f7f 100644 --- a/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/project_information_menu_spec.rb @@ -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 } } diff --git a/spec/lib/sidebars/projects/menus/scope_menu_spec.rb b/spec/lib/sidebars/projects/menus/scope_menu_spec.rb new file mode 100644 index 00000000000..f84d458a2e1 --- /dev/null +++ b/spec/lib/sidebars/projects/menus/scope_menu_spec.rb @@ -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 diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index b073b647532..afe709f02b4 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -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 diff --git a/spec/migrations/schedule_calculate_wiki_sizes_spec.rb b/spec/migrations/schedule_calculate_wiki_sizes_spec.rb deleted file mode 100644 index 0af491d863b..00000000000 --- a/spec/migrations/schedule_calculate_wiki_sizes_spec.rb +++ /dev/null @@ -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 diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index e22ae41be7f..5ca794a2076 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -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 diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index fd7125e3edc..f6753b5f11e 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -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) + describe '#all_projects' do + context 'with use_traversal_ids feature flag enabled' do + before do + stub_feature_flags(use_traversal_ids: true) + end - user_namespace.all_projects + 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 diff --git a/spec/services/groups/participants_service_spec.rb b/spec/services/groups/participants_service_spec.rb new file mode 100644 index 00000000000..750aead277f --- /dev/null +++ b/spec/services/groups/participants_service_spec.rb @@ -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 diff --git a/spec/support/helpers/feature_flag_helpers.rb b/spec/support/helpers/feature_flag_helpers.rb index 93cd915879b..aef15d81712 100644 --- a/spec/support/helpers/feature_flag_helpers.rb +++ b/spec/support/helpers/feature_flag_helpers.rb @@ -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 diff --git a/spec/support/helpers/reference_parser_helpers.rb b/spec/support/helpers/reference_parser_helpers.rb index e65cb8c96db..f42410d8102 100644 --- a/spec/support/helpers/reference_parser_helpers.rb +++ b/spec/support/helpers/reference_parser_helpers.rb @@ -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 diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 32c79905236..89c9d742033 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -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'), diff --git a/spec/support/shared_examples/namespaces/linear_traversal_examples.rb b/spec/support/shared_examples/namespaces/linear_traversal_examples.rb new file mode 100644 index 00000000000..2fd90c36953 --- /dev/null +++ b/spec/support/shared_examples/namespaces/linear_traversal_examples.rb @@ -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 diff --git a/spec/support/shared_examples/namespaces/traversal_examples.rb b/spec/support/shared_examples/namespaces/traversal_examples.rb index 77a1705627e..339efd31534 100644 --- a/spec/support/shared_examples/namespaces/traversal_examples.rb +++ b/spec/support/shared_examples/namespaces/traversal_examples.rb @@ -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 diff --git a/spec/tooling/danger/changelog_spec.rb b/spec/tooling/danger/changelog_spec.rb index 41e17cc8f09..03f2e7102bd 100644 --- a/spec/tooling/danger/changelog_spec.rb +++ b/spec/tooling/danger/changelog_spec.rb @@ -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 diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index 7cb49f635af..9b6d98a12a8 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -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 diff --git a/tooling/danger/changelog.rb b/tooling/danger/changelog.rb index 6e9c2e5ffdb..5216e98d4f5 100644 --- a/tooling/danger/changelog.rb +++ b/tooling/danger/changelog.rb @@ -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 |