Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-05-26 15:10:41 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-26 15:10:41 +0300
commit04f9cef437b65b4a62624936a37a99cfbfb4d61c (patch)
tree9edb887220b45ecd69f2aefa22a0fea09ed03ee1
parent47d07def1648ffc0787fe92ea5e351ccc5e9c4a4 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/CODEOWNERS8
-rw-r--r--app/assets/javascripts/feature_flags/components/empty_state.vue (renamed from app/assets/javascripts/feature_flags/components/feature_flags_tab.vue)23
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue246
-rw-r--r--app/assets/javascripts/feature_flags/constants.js3
-rw-r--r--app/assets/javascripts/feature_flags/index.js4
-rw-r--r--app/assets/javascripts/feature_flags/store/index/actions.js34
-rw-r--r--app/assets/javascripts/feature_flags/store/index/mutation_types.js7
-rw-r--r--app/assets/javascripts/feature_flags/store/index/mutations.js54
-rw-r--r--app/assets/javascripts/feature_flags/store/index/state.js9
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/index/index.js25
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue2
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue143
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue4
-rw-r--r--app/assets/javascripts/runner/constants.js25
-rw-r--r--app/assets/javascripts/runner/graphql/get_runners.query.graphql4
-rw-r--r--app/assets/javascripts/runner/runner_list/filtered_search_utils.js72
-rw-r--r--app/assets/javascripts/runner/runner_list/runner_list_app.vue27
-rw-r--r--app/assets/javascripts/user_lists/components/user_lists.vue120
-rw-r--r--app/assets/javascripts/user_lists/components/user_lists_table.vue (renamed from app/assets/javascripts/feature_flags/components/user_lists_table.vue)0
-rw-r--r--app/assets/javascripts/user_lists/store/index/actions.js38
-rw-r--r--app/assets/javascripts/user_lists/store/index/index.js11
-rw-r--r--app/assets/javascripts/user_lists/store/index/mutation_types.js10
-rw-r--r--app/assets/javascripts/user_lists/store/index/mutations.js37
-rw-r--r--app/assets/javascripts/user_lists/store/index/state.js10
-rw-r--r--app/controllers/projects/feature_flags_user_lists_controller.rb3
-rw-r--r--app/helpers/preferences_helper.rb6
-rw-r--r--app/mailers/emails/members.rb35
-rw-r--r--app/mailers/previews/notify_preview.rb2
-rw-r--r--app/models/namespace.rb3
-rw-r--r--app/models/namespaces/traversal/linear.rb12
-rw-r--r--app/models/namespaces/traversal/recursive.rb5
-rw-r--r--app/models/project_statistics.rb20
-rw-r--r--app/services/groups/participants_service.rb4
-rw-r--r--app/views/projects/feature_flags/index.html.haml2
-rw-r--r--app/views/projects/feature_flags_user_lists/edit.html.haml1
-rw-r--r--app/views/projects/feature_flags_user_lists/index.html.haml8
-rw-r--r--app/views/projects/feature_flags_user_lists/new.html.haml1
-rw-r--r--app/views/projects/feature_flags_user_lists/show.html.haml1
-rw-r--r--app/views/shared/nav/_scope_menu.html.haml12
-rw-r--r--app/views/shared/nav/_scope_menu_body.html.haml5
-rw-r--r--app/views/shared/nav/_sidebar.html.haml4
-rw-r--r--config/metrics/counts_all/20210216175024_service_desk_enabled_projects.yml1
-rw-r--r--config/routes/project.rb2
-rw-r--r--db/migrate/20210510083845_add_sha_to_status_check_response.rb13
-rw-r--r--db/post_migrate/20190527194900_schedule_calculate_wiki_sizes.rb10
-rw-r--r--db/schema_migrations/202105100838451
-rw-r--r--db/structure.sql3
-rw-r--r--doc/administration/external_pipeline_validation.md4
-rw-r--r--doc/administration/operations/unicorn.md9
-rw-r--r--doc/administration/postgresql/external.md1
-rw-r--r--doc/api/status_checks.md93
-rw-r--r--doc/operations/feature_flags.md8
-rw-r--r--doc/user/project/description_templates.md26
-rw-r--r--lib/banzai/reference_parser/issue_parser.rb9
-rw-r--r--lib/banzai/reference_parser/merge_request_parser.rb9
-rw-r--r--lib/gitlab/ci/pipeline/chain/validate/external.rb4
-rw-r--r--lib/sidebars/projects/menus/project_information_menu.rb17
-rw-r--r--lib/sidebars/projects/menus/scope_menu.rb21
-rw-r--r--locale/gitlab.pot47
-rw-r--r--spec/controllers/projects/feature_flags_user_lists_controller_spec.rb33
-rw-r--r--spec/features/contextual_sidebar_spec.rb8
-rw-r--r--spec/features/groups/milestones/gfm_autocomplete_spec.rb1
-rw-r--r--spec/features/projects/active_tabs_spec.rb25
-rw-r--r--spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb7
-rw-r--r--spec/features/projects/members/user_requests_access_spec.rb7
-rw-r--r--spec/features/projects/navbar_spec.rb4
-rw-r--r--spec/features/projects/user_uses_shortcuts_spec.rb8
-rw-r--r--spec/fixtures/api/schemas/external_validation.json4
-rw-r--r--spec/frontend/feature_flags/components/empty_state_spec.js (renamed from spec/frontend/feature_flags/components/feature_flags_tab_spec.js)43
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_spec.js145
-rw-r--r--spec/frontend/feature_flags/store/index/actions_spec.js165
-rw-r--r--spec/frontend/feature_flags/store/index/mutations_spec.js94
-rw-r--r--spec/frontend/runner/components/runner_filtered_search_bar_spec.js135
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js26
-rw-r--r--spec/frontend/runner/runner_list/filtered_search_utils_spec.js98
-rw-r--r--spec/frontend/runner/runner_list/runner_list_app_spec.js90
-rw-r--r--spec/frontend/user_lists/components/user_lists_spec.js195
-rw-r--r--spec/frontend/user_lists/components/user_lists_table_spec.js (renamed from spec/frontend/feature_flags/components/user_lists_table_spec.js)4
-rw-r--r--spec/frontend/user_lists/store/index/actions_spec.js203
-rw-r--r--spec/frontend/user_lists/store/index/mutations_spec.js121
-rw-r--r--spec/helpers/preferences_helper_spec.rb37
-rw-r--r--spec/lib/banzai/reference_parser/issue_parser_spec.rb10
-rw-r--r--spec/lib/banzai/reference_parser/merge_request_parser_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/validate/external_spec.rb2
-rw-r--r--spec/lib/sidebars/projects/menus/project_information_menu_spec.rb14
-rw-r--r--spec/lib/sidebars/projects/menus/scope_menu_spec.rb23
-rw-r--r--spec/mailers/notify_spec.rb13
-rw-r--r--spec/migrations/schedule_calculate_wiki_sizes_spec.rb60
-rw-r--r--spec/models/group_spec.rb8
-rw-r--r--spec/models/namespace_spec.rb47
-rw-r--r--spec/services/groups/participants_service_spec.rb37
-rw-r--r--spec/support/helpers/feature_flag_helpers.rb1
-rw-r--r--spec/support/helpers/reference_parser_helpers.rb5
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb8
-rw-r--r--spec/support/shared_examples/namespaces/linear_traversal_examples.rb23
-rw-r--r--spec/support/shared_examples/namespaces/traversal_examples.rb16
-rw-r--r--spec/tooling/danger/changelog_spec.rb2
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb23
-rw-r--r--tooling/danger/changelog.rb2
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