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:
-rw-r--r--.eslintrc.yml82
-rw-r--r--app/assets/javascripts/behaviors/markdown/editor_extensions.js21
-rw-r--r--app/assets/javascripts/boards/index.js24
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue4
-rw-r--r--app/assets/javascripts/main.js1
-rw-r--r--app/assets/javascripts/notes.js6
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js3
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue2
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js4
-rw-r--r--app/assets/javascripts/profile/preferences/components/profile_preferences.vue105
-rw-r--r--app/assets/javascripts/profile/preferences/constants.js22
-rw-r--r--app/assets/javascripts/profile/preferences/profile_preferences_bundle.js20
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue2
-rw-r--r--app/assets/javascripts/terraform/index.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue2
-rw-r--r--app/controllers/dashboard_controller.rb19
-rw-r--r--app/controllers/profiles/preferences_controller.rb25
-rw-r--r--app/controllers/users_controller.rb46
-rw-r--r--app/finders/user_recent_events_finder.rb32
-rw-r--r--app/helpers/users_helper.rb2
-rw-r--r--app/models/user.rb30
-rw-r--r--app/models/users/user_follow_user.rb7
-rw-r--r--app/views/dashboard/_activity_head.html.haml7
-rw-r--r--app/views/profiles/preferences/show.html.haml15
-rw-r--r--app/views/profiles/preferences/update.js.erb20
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml1
-rw-r--r--app/views/shared/users/_user.html.haml13
-rw-r--r--app/views/shared/users/index.html.haml20
-rw-r--r--app/views/users/show.html.haml33
-rw-r--r--changelogs/unreleased/feat-follow-eachother.yml5
-rw-r--r--changelogs/unreleased/georgekoltsov-group-migration-epic-award-emoji.yml5
-rw-r--r--config/routes/user.rb4
-rw-r--r--db/migrate/20201027101010_create_user_follow_users.rb24
-rw-r--r--db/schema_migrations/202010271010101
-rw-r--r--db/structure.sql18
-rw-r--r--doc/api/users.md86
-rw-r--r--doc/user/img/activity_followed_users_v13_9.pngbin0 -> 414639 bytes
-rw-r--r--doc/user/index.md8
-rw-r--r--doc/user/profile/img/profile_following_v13_9.pngbin0 -> 652637 bytes
-rw-r--r--doc/user/profile/index.md6
-rw-r--r--doc/user/project/labels.md28
-rw-r--r--doc/user/project/service_desk.md7
-rw-r--r--lib/api/entities/user.rb6
-rw-r--r--lib/api/users.rb62
-rw-r--r--lib/bulk_imports/common/extractors/graphql_extractor.rb2
-rw-r--r--lib/bulk_imports/common/transformers/award_emoji_transformer.rb27
-rw-r--r--lib/bulk_imports/groups/graphql/get_group_query.rb4
-rw-r--r--lib/bulk_imports/groups/graphql/get_labels_query.rb6
-rw-r--r--lib/bulk_imports/groups/graphql/get_members_query.rb6
-rw-r--r--lib/bulk_imports/pipeline/context.rb4
-rw-r--r--lib/gitlab/database/migrations/instrumentation.rb24
-rw-r--r--lib/gitlab/database/migrations/observation.rb14
-rw-r--r--lib/gitlab/database/migrations/observers.rb15
-rw-r--r--lib/gitlab/database/migrations/observers/migration_observer.rb29
-rw-r--r--lib/gitlab/database/migrations/observers/total_database_size_change.rb31
-rw-r--r--locale/gitlab.pot74
-rw-r--r--spec/controllers/profiles/preferences_controller_spec.rb35
-rw-r--r--spec/features/dashboard/activity_spec.rb20
-rw-r--r--spec/features/profiles/user_edit_preferences_spec.rb11
-rw-r--r--spec/features/profiles/user_visits_profile_preferences_page_spec.rb12
-rw-r--r--spec/features/users/overview_spec.rb126
-rw-r--r--spec/features/users/show_spec.rb52
-rw-r--r--spec/finders/user_recent_events_finder_spec.rb112
-rw-r--r--spec/frontend/boards/board_list_deprecated_spec.js7
-rw-r--r--spec/frontend/boards/board_list_helper.js4
-rw-r--r--spec/frontend/boards/components/board_column_spec.js2
-rw-r--r--spec/frontend/boards/issue_card_deprecated_spec.js2
-rw-r--r--spec/frontend/boards/list_spec.js3
-rw-r--r--spec/frontend/commits_spec.js2
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_helper_spec.js2
-rw-r--r--spec/frontend/filtered_search/dropdown_user_spec.js3
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js3
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js6
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js2
-rw-r--r--spec/frontend/pages/dashboard/todos/index/todos_spec.js2
-rw-r--r--spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap67
-rw-r--r--spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap51
-rw-r--r--spec/frontend/profile/preferences/components/integration_view_spec.js6
-rw-r--r--spec/frontend/profile/preferences/components/profile_preferences_spec.js140
-rw-r--r--spec/frontend/profile/preferences/mock_data.js2
-rw-r--r--spec/frontend/search_autocomplete_spec.js3
-rw-r--r--spec/frontend/test_setup.js5
-rw-r--r--spec/frontend_integration/ide/helpers/start.js2
-rw-r--r--spec/frontend_integration/ide/ide_integration_spec.js6
-rw-r--r--spec/frontend_integration/test_helpers/factories/commit.js2
-rw-r--r--spec/javascripts/fly_out_nav_browser_spec.js2
-rw-r--r--spec/javascripts/monitoring/components/dashboard_resize_browser_spec.js6
-rw-r--r--spec/javascripts/test_bundle.js9
-rw-r--r--spec/lib/bulk_imports/common/transformers/award_emoji_transformer_spec.rb48
-rw-r--r--spec/lib/bulk_imports/groups/graphql/get_group_query_spec.rb5
-rw-r--r--spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb5
-rw-r--r--spec/lib/bulk_imports/groups/graphql/get_members_query_spec.rb3
-rw-r--r--spec/lib/bulk_imports/importers/group_importer_spec.rb11
-rw-r--r--spec/lib/gitlab/database/migrations/instrumentation_spec.rb35
-rw-r--r--spec/lib/gitlab/database/migrations/observers/total_database_size_change_spec.rb39
-rw-r--r--spec/models/user_spec.rb73
-rw-r--r--spec/requests/api/users_spec.rb150
-rw-r--r--spec/services/ci/daily_build_group_report_result_service_spec.rb22
101 files changed, 1723 insertions, 456 deletions
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 99c390e5060..75c52ac1319 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -42,48 +42,50 @@ rules:
no-jquery/no-serialize: error
promise/always-return: off
promise/no-callback-in-promise: off
- "@gitlab/no-global-event-off": error
+ '@gitlab/no-global-event-off': error
import/order:
- - error
- - groups:
- - builtin
- - external
- - internal
- - parent
- - sibling
- - index
- pathGroups:
- - pattern: ~/**
- group: internal
- - pattern: emojis/**
- group: internal
- - pattern: "{ee_,}empty_states/**"
- group: internal
- - pattern: "{ee_,}icons/**"
- group: internal
- - pattern: "{ee_,}images/**"
- group: internal
- - pattern: vendor/**
- group: internal
- - pattern: shared_queries/**
- group: internal
- - pattern: "{ee_,}spec/**"
- group: internal
- - pattern: "{ee_,}jest/**"
- group: internal
- - pattern: ee_else_ce/**
- group: internal
- - pattern: ee/**
- group: internal
- - pattern: ee_component/**
- group: internal
- - pattern: "{test_,}helpers/**"
- group: internal
- - pattern: test_fixtures/**
- group: internal
+ - error
+ - groups:
+ - builtin
+ - external
+ - internal
+ - parent
+ - sibling
+ - index
+ pathGroups:
+ - pattern: ~/**
+ group: internal
+ - pattern: emojis/**
+ group: internal
+ - pattern: '{ee_,}empty_states/**'
+ group: internal
+ - pattern: '{ee_,}icons/**'
+ group: internal
+ - pattern: '{ee_,}images/**'
+ group: internal
+ - pattern: vendor/**
+ group: internal
+ - pattern: shared_queries/**
+ group: internal
+ - pattern: '{ee_,}spec/**'
+ group: internal
+ - pattern: '{ee_,}jest/**'
+ group: internal
+ - pattern: ee_else_ce/**
+ group: internal
+ - pattern: ee/**
+ group: internal
+ - pattern: ee_component/**
+ group: internal
+ - pattern: '{test_,}helpers/**'
+ group: internal
+ - pattern: test_fixtures/**
+ group: internal
+ alphabetize:
+ order: asc
overrides:
- files:
- '**/spec/**/*'
rules:
- "@gitlab/require-i18n-strings": off
- "@gitlab/no-runtime-template-compiler": off
+ '@gitlab/require-i18n-strings': off
+ '@gitlab/no-runtime-template-compiler': off
diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
index ee50075862a..b512e4dbc8b 100644
--- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js
+++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
@@ -22,27 +22,24 @@ import Heading from './nodes/heading';
import HorizontalRule from './nodes/horizontal_rule';
import Image from './nodes/image';
+import ListItem from './nodes/list_item';
+import OrderedList from './nodes/ordered_list';
+import OrderedTaskList from './nodes/ordered_task_list';
+import Paragraph from './nodes/paragraph';
import Reference from './nodes/reference';
+import Summary from './nodes/summary';
import Table from './nodes/table';
-import TableHead from './nodes/table_head';
import TableBody from './nodes/table_body';
+import TableCell from './nodes/table_cell';
+import TableHead from './nodes/table_head';
import TableHeaderRow from './nodes/table_header_row';
+import TableOfContents from './nodes/table_of_contents';
import TableRow from './nodes/table_row';
-import TableCell from './nodes/table_cell';
-import TableOfContents from './nodes/table_of_contents';
import TaskList from './nodes/task_list';
import TaskListItem from './nodes/task_list_item';
-import Video from './nodes/video';
-
-import OrderedList from './nodes/ordered_list';
-import ListItem from './nodes/list_item';
-
-import OrderedTaskList from './nodes/ordered_task_list';
-import Paragraph from './nodes/paragraph';
-
-import Summary from './nodes/summary';
import Text from './nodes/text';
+import Video from './nodes/video';
// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform
// GitLab Flavored Markdown (GFM) to HTML.
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index ac7d8e542c4..15f7e2191a2 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,9 +1,9 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { mapActions, mapGetters } from 'vuex';
import 'ee_else_ce/boards/models/issue';
import 'ee_else_ce/boards/models/list';
-import VueApollo from 'vue-apollo';
import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
@@ -15,33 +15,31 @@ import {
} from 'ee_else_ce/boards/ee_functions';
import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes';
import toggleLabels from 'ee_else_ce/boards/toggle_labels';
-
import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
import BoardContent from '~/boards/components/board_content.vue';
import BoardExtraActions from '~/boards/components/board_extra_actions.vue';
-import { deprecatedCreateFlash as Flash } from '~/flash';
-import createDefaultClient from '~/lib/graphql';
-import { __ } from '~/locale';
import './models/label';
import './models/assignee';
-
-import toggleFocusMode from '~/boards/toggle_focus';
-import FilteredSearchBoards from '~/boards/filtered_search_boards';
-import eventHub from '~/boards/eventhub';
-import sidebarEventHub from '~/sidebar/event_hub';
import '~/boards/models/milestone';
import '~/boards/models/project';
+import '~/boards/filters/due_date_filters';
+import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
+import eventHub from '~/boards/eventhub';
+import FilteredSearchBoards from '~/boards/filtered_search_boards';
+import modalMixin from '~/boards/mixins/modal_mixins';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import ModalStore from '~/boards/stores/modal_store';
-import modalMixin from '~/boards/mixins/modal_mixins';
-import '~/boards/filters/due_date_filters';
-import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
+import toggleFocusMode from '~/boards/toggle_focus';
+import { deprecatedCreateFlash as Flash } from '~/flash';
+import createDefaultClient from '~/lib/graphql';
import {
NavigationType,
convertObjectPropsToCamelCase,
parseBoolean,
} from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import sidebarEventHub from '~/sidebar/event_hub';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
Vue.use(VueApollo);
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 4362263b0dd..787152d00ef 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -1,13 +1,13 @@
<script>
import { GlButton, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
import { getParameterByName } from '~/lib/utils/common_utils';
+import SvgBlankState from '~/pipelines/components/pipelines_list/blank_state.vue';
+import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import eventHub from '~/pipelines/event_hub';
import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin';
import PipelinesService from '~/pipelines/services/pipelines_service';
import PipelineStore from '~/pipelines/stores/pipelines_store';
-import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
-import SvgBlankState from '~/pipelines/components/pipelines_list/blank_state.vue';
export default {
components: {
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 376d93a9783..32ecc0036bd 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -1,4 +1,5 @@
/* global $ */
+/* eslint-disable import/order */
import jQuery from 'jquery';
import Cookies from 'js-cookie';
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 072983b4285..8ed40f36103 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -10,13 +10,13 @@ class-methods-use-this */
old_notes_spec.js is the spec for the legacy, jQuery notes application. It has nothing to do with the new, fancy Vue notes app.
*/
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import Autosize from 'autosize';
import $ from 'jquery';
-import '~/lib/utils/jquery_at_who';
import Cookies from 'js-cookie';
import { escape, uniqueId } from 'lodash';
-import Autosize from 'autosize';
import Vue from 'vue';
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import '~/lib/utils/jquery_at_who';
import AjaxCache from '~/lib/utils/ajax_cache';
import syntaxHighlight from '~/syntax_highlight';
import Autosave from './autosave';
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 610c643c805..61ff1c95a38 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
+import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
import BlobViewer from '~/blob/viewer/index';
import GpgBadges from '~/gpg_badges';
import initBlob from '~/pages/projects/init_blob';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import '~/sourcegraph/load';
-import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
document.addEventListener('DOMContentLoaded', () => {
new BlobViewer(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index 6baf8c6595e..90a663802d2 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -1,6 +1,6 @@
/* eslint-disable no-new */
-
import $ from 'jquery';
+import loadAwardsHandler from '~/awards_handler';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import Diff from '~/diff';
import flash from '~/flash';
@@ -14,7 +14,6 @@ import { initCommitBoxInfo } from '~/projects/commit_box/info';
import syntaxHighlight from '~/syntax_highlight';
import ZenMode from '~/zen_mode';
import '~/sourcegraph/load';
-import loadAwardsHandler from '~/awards_handler';
const hasPerfBar = document.querySelector('.with-performance-bar');
const performanceHeight = hasPerfBar ? 35 : 0;
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index e3aac07097b..80e14842f51 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -141,7 +141,15 @@ export default class UserTabs {
this.loadOverviewTab();
}
- const loadableActions = ['groups', 'contributed', 'projects', 'starred', 'snippets'];
+ const loadableActions = [
+ 'groups',
+ 'contributed',
+ 'projects',
+ 'starred',
+ 'snippets',
+ 'followers',
+ 'following',
+ ];
if (loadableActions.indexOf(action) > -1) {
this.loadTab(action, endpoint);
}
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index a45adc766e3..48009a9fcb8 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -10,11 +10,11 @@ import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../
import PipelinesMixin from '../../mixins/pipelines_mixin';
import PipelinesService from '../../services/pipelines_service';
import { validateParams } from '../../utils';
+import SvgBlankState from './blank_state.vue';
import EmptyState from './empty_state.vue';
import NavigationControls from './nav_controls.vue';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
import PipelinesTableComponent from './pipelines_table.vue';
-import SvgBlankState from './blank_state.vue';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index 8444d43513b..2321728e30c 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -1,9 +1,9 @@
import Visibility from 'visibilityjs';
import { deprecatedCreateFlash as createFlash } from '~/flash';
-import Poll from '~/lib/utils/poll';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
-import { validateParams } from '~/pipelines/utils';
+import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
+import { validateParams } from '~/pipelines/utils';
import { CANCEL_REQUEST } from '../constants';
import eventHub from '../event_hub';
diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
index 8b2006b7c5b..184ee3810ac 100644
--- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
+++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue
@@ -1,31 +1,92 @@
<script>
-import { s__ } from '~/locale';
+import { GlButton } from '@gitlab/ui';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants';
import IntegrationView from './integration_view.vue';
-const INTEGRATION_VIEW_CONFIGS = {
- sourcegraph: {
- title: s__('ProfilePreferences|Sourcegraph'),
- label: s__('ProfilePreferences|Enable integrated code intelligence on code views'),
- formName: 'sourcegraph_enabled',
- },
- gitpod: {
- title: s__('ProfilePreferences|Gitpod'),
- label: s__('ProfilePreferences|Enable Gitpod integration'),
- formName: 'gitpod_enabled',
- },
-};
+function updateClasses(bodyClasses = '', applicationTheme, layout) {
+ // Remove body class for any previous theme, re-add current one
+ document.body.classList.remove(...bodyClasses.split(' '));
+ document.body.classList.add(applicationTheme);
+
+ // Toggle container-fluid class
+ if (layout === 'fluid') {
+ document
+ .querySelector('.content-wrapper .container-fluid')
+ .classList.remove('container-limited');
+ } else {
+ document.querySelector('.content-wrapper .container-fluid').classList.add('container-limited');
+ }
+}
export default {
name: 'ProfilePreferences',
components: {
IntegrationView,
+ GlButton,
},
inject: {
integrationViews: {
default: [],
},
+ themes: {
+ default: [],
+ },
+ userFields: {
+ default: {},
+ },
+ formEl: 'formEl',
+ profilePreferencesPath: 'profilePreferencesPath',
+ bodyClasses: 'bodyClasses',
},
integrationViewConfigs: INTEGRATION_VIEW_CONFIGS,
+ i18n,
+ data() {
+ return {
+ isSubmitEnabled: true,
+ };
+ },
+ computed: {
+ applicationThemes() {
+ return this.themes.reduce((themes, theme) => {
+ const { id, ...rest } = theme;
+ return { ...themes, [id]: rest };
+ }, {});
+ },
+ },
+ created() {
+ this.formEl.addEventListener('ajax:beforeSend', this.handleLoading);
+ this.formEl.addEventListener('ajax:success', this.handleSuccess);
+ this.formEl.addEventListener('ajax:error', this.handleError);
+ },
+ beforeDestroy() {
+ this.formEl.removeEventListener('ajax:beforeSend', this.handleLoading);
+ this.formEl.removeEventListener('ajax:success', this.handleSuccess);
+ this.formEl.removeEventListener('ajax:error', this.handleError);
+ },
+ methods: {
+ handleLoading() {
+ this.isSubmitEnabled = false;
+ },
+ handleSuccess(customEvent) {
+ const formData = new FormData(this.formEl);
+ updateClasses(
+ this.bodyClasses,
+ this.applicationThemes[formData.get('user[theme_id]')].css_class,
+ this.selectedLayout,
+ );
+ const { message = this.$options.i18n.defaultSuccess, type = FLASH_TYPES.NOTICE } =
+ customEvent?.detail?.[0] || {};
+ createFlash({ message, type });
+ this.isSubmitEnabled = true;
+ },
+ handleError(customEvent) {
+ const { message = this.$options.i18n.defaultError, type = FLASH_TYPES.ALERT } =
+ customEvent?.detail?.[0] || {};
+ createFlash({ message, type });
+ this.isSubmitEnabled = true;
+ },
+ },
};
</script>
@@ -36,10 +97,10 @@ export default {
</div>
<div v-if="integrationViews.length" class="col-lg-4 profile-settings-sidebar">
<h4 class="gl-mt-0" data-testid="profile-preferences-integrations-heading">
- {{ s__('ProfilePreferences|Integrations') }}
+ {{ $options.i18n.integrations }}
</h4>
<p>
- {{ s__('ProfilePreferences|Customize integrations with third party services.') }}
+ {{ $options.i18n.integrationsDescription }}
</p>
</div>
<div v-if="integrationViews.length" class="col-lg-8">
@@ -52,5 +113,19 @@ export default {
:config="$options.integrationViewConfigs[view.name]"
/>
</div>
+ <div class="col-lg-4 profile-settings-sidebar"></div>
+ <div class="col-lg-8">
+ <div class="form-group">
+ <gl-button
+ variant="success"
+ name="commit"
+ type="submit"
+ :disabled="!isSubmitEnabled"
+ :value="$options.i18n.saveChanges"
+ >
+ {{ $options.i18n.saveChanges }}
+ </gl-button>
+ </div>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/profile/preferences/constants.js b/app/assets/javascripts/profile/preferences/constants.js
new file mode 100644
index 00000000000..ea8464ba065
--- /dev/null
+++ b/app/assets/javascripts/profile/preferences/constants.js
@@ -0,0 +1,22 @@
+import { s__, __ } from '~/locale';
+
+export const INTEGRATION_VIEW_CONFIGS = {
+ sourcegraph: {
+ title: s__('Preferences|Sourcegraph'),
+ label: s__('Preferences|Enable integrated code intelligence on code views'),
+ formName: 'sourcegraph_enabled',
+ },
+ gitpod: {
+ title: s__('Preferences|Gitpod'),
+ label: s__('Preferences|Enable Gitpod integration'),
+ formName: 'gitpod_enabled',
+ },
+};
+
+export const i18n = {
+ saveChanges: __('Save changes'),
+ defaultSuccess: __('Preferences saved.'),
+ defaultError: s__('Preferences|Failed to save preferences.'),
+ integrations: s__('Preferences|Integrations'),
+ integrationsDescription: s__('Preferences|Customize integrations with third party services.'),
+};
diff --git a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
index 744e0174a4e..6520e68d41c 100644
--- a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
+++ b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js
@@ -3,16 +3,20 @@ import ProfilePreferences from './components/profile_preferences.vue';
export default () => {
const el = document.querySelector('#js-profile-preferences-app');
- const shouldParse = ['integrationViews', 'userFields'];
+ const formEl = document.querySelector('#profile-preferences-form');
+ const shouldParse = ['integrationViews', 'themes', 'userFields'];
- const provide = Object.keys(el.dataset).reduce((memo, key) => {
- let value = el.dataset[key];
- if (shouldParse.includes(key)) {
- value = JSON.parse(value);
- }
+ const provide = Object.keys(el.dataset).reduce(
+ (memo, key) => {
+ let value = el.dataset[key];
+ if (shouldParse.includes(key)) {
+ value = JSON.parse(value);
+ }
- return { ...memo, [key]: value };
- }, {});
+ return { ...memo, [key]: value };
+ },
+ { formEl },
+ );
return new Vue({
el,
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index e2c3f3b81ee..b74c2333148 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -1,8 +1,8 @@
<script>
/* eslint-disable vue/no-v-html */
+import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
-import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { handleLocationHash } from '~/lib/utils/common_utils';
import readmeQuery from '../../queries/readme.query.graphql';
diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js
index 2288bab2fa8..3f986423836 100644
--- a/app/assets/javascripts/terraform/index.js
+++ b/app/assets/javascripts/terraform/index.js
@@ -1,5 +1,5 @@
-import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import { GlToast } from '@gitlab/ui';
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index 24386c90954..3790a509f26 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -1,9 +1,8 @@
<script>
/* eslint-disable vue/no-v-html */
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
-
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { forEach, escape } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 1699329bf4d..25d01dc550f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,9 +1,9 @@
<script>
/* eslint-disable vue/no-v-html */
+import { GlIcon } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { unescape } from 'lodash';
-import { GlIcon } from '@gitlab/ui';
import { deprecatedCreateFlash as Flash } from '~/flash';
import GLForm from '~/gl_form';
import axios from '~/lib/utils/axios_utils';
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index a88cf64d842..29cb60ad3cc 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -33,6 +33,21 @@ class DashboardController < Dashboard::ApplicationController
protected
def load_events
+ @events =
+ if params[:filter] == "followed"
+ load_user_events
+ else
+ load_project_events
+ end
+
+ Events::RenderService.new(current_user).execute(@events)
+ end
+
+ def load_user_events
+ UserRecentEventsFinder.new(current_user, current_user.followees, event_filter, params).execute
+ end
+
+ def load_project_events
projects =
if params[:filter] == "starred"
ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute
@@ -40,12 +55,10 @@ class DashboardController < Dashboard::ApplicationController
current_user.authorized_projects
end
- @events = EventCollection
+ EventCollection
.new(projects, offset: params[:offset].to_i, filter: event_filter)
.to_a
.map(&:present)
-
- Events::RenderService.new(current_user).execute(@events)
end
def set_show_full_reference
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 4d88491e9a8..add5046e213 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -9,23 +9,18 @@ class Profiles::PreferencesController < Profiles::ApplicationController
end
def update
- begin
- result = Users::UpdateService.new(current_user, preferences_params.merge(user: user)).execute
-
- if result[:status] == :success
- flash[:notice] = _('Preferences saved.')
- else
- flash[:alert] = _('Failed to save preferences.')
- end
- rescue ArgumentError => e
- # Raised when `dashboard` is given an invalid value.
- flash[:alert] = _("Failed to save preferences (%{error_message}).") % { error_message: e.message }
- end
+ result = Users::UpdateService.new(current_user, preferences_params.merge(user: user)).execute
+ if result[:status] == :success
+ message = _('Preferences saved.')
- respond_to do |format|
- format.html { redirect_to profile_preferences_path }
- format.js
+ render json: { type: :notice, message: message }
+ else
+ render status: :bad_request, json: { type: :alert, message: _('Failed to save preferences.') }
end
+ rescue ArgumentError => e
+ # Raised when `dashboard` is given an invalid value.
+ message = _("Failed to save preferences (%{error_message}).") % { error_message: e.message }
+ render status: :bad_request, json: { type: :alert, message: message }
end
private
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index c9152128638..54d97f588fc 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class UsersController < ApplicationController
+ include InternalRedirect
include RoutableActions
include RendersMemberAccess
include RendersProjectsList
@@ -13,13 +14,15 @@ class UsersController < ApplicationController
contributed: false,
snippets: true,
calendar: false,
+ followers: false,
+ following: false,
calendar_activities: true
skip_before_action :authenticate_user!
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
before_action :user, except: [:exists, :suggests, :ssh_keys]
before_action :authorize_read_user_profile!,
- only: [:calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets]
+ only: [:calendar, :calendar_activities, :groups, :projects, :contributed, :starred, :snippets, :followers, :following]
feature_category :users
@@ -97,6 +100,18 @@ class UsersController < ApplicationController
present_projects(@starred_projects)
end
+ def followers
+ @user_followers = user.followers.page(params[:page])
+
+ present_users(@user_followers)
+ end
+
+ def following
+ @user_following = user.followees.page(params[:page])
+
+ present_users(@user_following)
+ end
+
def present_projects(projects)
skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
@@ -146,6 +161,22 @@ class UsersController < ApplicationController
render json: { exists: exists, suggests: suggestions }
end
+ def follow
+ current_user.follow(user)
+
+ redirect_path = referer_path(request) || @user
+
+ redirect_to redirect_path
+ end
+
+ def unfollow
+ current_user.unfollow(user)
+
+ redirect_path = referer_path(request) || @user
+
+ redirect_to redirect_path
+ end
+
private
def user
@@ -169,7 +200,7 @@ class UsersController < ApplicationController
end
def load_events
- @events = UserRecentEventsFinder.new(current_user, user, params).execute
+ @events = UserRecentEventsFinder.new(current_user, user, nil, params).execute
Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?)
end
@@ -216,6 +247,17 @@ class UsersController < ApplicationController
def authorize_read_user_profile!
access_denied! unless can?(current_user, :read_user_profile, user)
end
+
+ def present_users(users)
+ respond_to do |format|
+ format.html { render 'show' }
+ format.json do
+ render json: {
+ html: view_to_html_string("shared/users/index", users: users)
+ }
+ end
+ end
+ end
end
UsersController.prepend_if_ee('EE::UsersController')
diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb
index c9a1c918365..596a413782e 100644
--- a/app/finders/user_recent_events_finder.rb
+++ b/app/finders/user_recent_events_finder.rb
@@ -15,27 +15,49 @@ class UserRecentEventsFinder
requires_cross_project_access
- attr_reader :current_user, :target_user, :params
+ attr_reader :current_user, :target_user, :params, :event_filter
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
- def initialize(current_user, target_user, params = {})
+ def initialize(current_user, target_user, event_filter, params = {})
@current_user = current_user
@target_user = target_user
@params = params
+ @event_filter = event_filter || EventFilter.new(EventFilter::ALL)
end
def execute
+ if target_user.is_a? User
+ execute_single
+ else
+ execute_multi
+ end
+ end
+
+ private
+
+ def execute_single
return Event.none unless can?(current_user, :read_user_profile, target_user)
- target_events
+ event_filter.apply_filter(target_events
.with_associations
.limit_recent(limit, params[:offset])
- .order_created_desc
+ .order_created_desc)
end
- private
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute_multi
+ users = []
+ @target_user.each do |user|
+ users.append(user.id) if can?(current_user, :read_user_profile, user)
+ end
+
+ return Event.none if users.empty?
+
+ event_filter.apply_filter(Event.where(author: users).limit_recent(limit, params[:offset] || 0))
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def target_events
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 1ea2d4412b1..1979426f844 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -242,7 +242,7 @@ module UsersHelper
tabs = []
if can?(current_user, :read_user_profile, @user)
- tabs += [:overview, :activity, :groups, :contributed, :projects, :starred, :snippets]
+ tabs += [:overview, :activity, :groups, :contributed, :projects, :starred, :snippets, :followers, :following]
end
tabs
diff --git a/app/models/user.rb b/app/models/user.rb
index 4a2ca64fbe9..1f8b680c7e5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -116,6 +116,13 @@ class User < ApplicationRecord
has_one :user_synced_attributes_metadata, autosave: true
has_one :aws_role, class_name: 'Aws::Role'
+ # Followers
+ has_many :followed_users, foreign_key: :follower_id, class_name: 'Users::UserFollowUser'
+ has_many :followees, through: :followed_users
+
+ has_many :following_users, foreign_key: :followee_id, class_name: 'Users::UserFollowUser'
+ has_many :followers, through: :following_users
+
# Groups
has_many :members
has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, source: 'GroupMember'
@@ -1442,6 +1449,29 @@ class User < ApplicationRecord
end
end
+ def following?(user)
+ self.followees.exists?(user.id)
+ end
+
+ def follow(user)
+ return false if self.id == user.id
+
+ begin
+ followee = Users::UserFollowUser.create(follower_id: self.id, followee_id: user.id)
+ self.followees.reset if followee.persisted?
+ rescue ActiveRecord::RecordNotUnique
+ false
+ end
+ end
+
+ def unfollow(user)
+ if Users::UserFollowUser.where(follower_id: self.id, followee_id: user.id).delete_all > 0
+ self.followees.reset
+ else
+ false
+ end
+ end
+
def manageable_namespaces
@manageable_namespaces ||= [namespace] + manageable_groups
end
diff --git a/app/models/users/user_follow_user.rb b/app/models/users/user_follow_user.rb
new file mode 100644
index 00000000000..a94239a746c
--- /dev/null
+++ b/app/models/users/user_follow_user.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+module Users
+ class UserFollowUser < ApplicationRecord
+ belongs_to :follower, class_name: 'User'
+ belongs_to :followee, class_name: 'User'
+ end
+end
diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml
index 3f39555a1d4..0daadd20f54 100644
--- a/app/views/dashboard/_activity_head.html.haml
+++ b/app/views/dashboard/_activity_head.html.haml
@@ -5,7 +5,10 @@
%ul.nav-links.nav.nav-tabs
%li{ class: active_when(params[:filter].nil?) }>
= link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do
- Your projects
+ = _('Your projects')
%li{ class: active_when(params[:filter] == 'starred') }>
= link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do
- Starred projects
+ = _('Starred projects')
+ %li{ class: active_when(params[:filter] == 'followed') }>
+ = link_to activity_dashboard_path(filter: 'followed'), data: {placement: 'right'} do
+ = _('Followed users')
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 169eef1fa9b..cd76a67b692 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,13 +1,14 @@
- page_title _('Preferences')
- @content_class = "limit-container-width" unless fluid_layout
-- user_fields = { gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }
- user_theme_id = Gitlab::Themes.for_user(@user).id
-- data_attributes = { integration_views: integration_views.to_json, user_fields: user_fields.to_json }
+- user_fields = { theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }.to_json
+- @themes = Gitlab::Themes::THEMES.to_json
+- data_attributes = { themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path }
- Gitlab::Themes.each do |theme|
= stylesheet_link_tag "themes/#{theme.css_filename}" if theme.css_filename
-= form_for @user, url: profile_preferences_path, remote: true, method: :put do |f|
+= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f|
.row.gl-mt-3.js-preferences-form.js-search-settings-section
.col-lg-4.application-theme#navigation-theme
%h4.gl-mt-0
@@ -143,10 +144,4 @@
.form-text.text-muted
= s_('Preferences|For example: 30 mins ago.')
- #js-profile-preferences-app{ data: data_attributes, user_fields: user_fields.to_json }
-
- .row.gl-mt-3.js-preferences-form
- .col-lg-4.profile-settings-sidebar
- .col-lg-8
- .form-group
- = f.submit _('Save changes'), class: 'gl-button btn btn-success'
+ #js-profile-preferences-app{ data: data_attributes }
diff --git a/app/views/profiles/preferences/update.js.erb b/app/views/profiles/preferences/update.js.erb
deleted file mode 100644
index 241262880c1..00000000000
--- a/app/views/profiles/preferences/update.js.erb
+++ /dev/null
@@ -1,20 +0,0 @@
-// Remove body class for any previous theme, re-add current one
-$('body').removeClass('<%= Gitlab::Themes.body_classes %>')
-$('body').addClass('<%= user_application_theme %>')
-
-// Toggle container-fluid class
-if ('<%= current_user.layout %>' === 'fluid') {
- $('.content-wrapper .container-fluid').removeClass('container-limited')
-} else {
- $('.content-wrapper .container-fluid').addClass('container-limited')
-}
-
-// Re-enable the "Save" button
-$('input[type=submit]').enable()
-
-// Show flash messages
-<% if flash.notice %>
- new Flash({ message: '<%= flash.discard(:notice) %>', type: 'notice'})
-<% elsif flash.alert %>
- new Flash({ message: '<%= flash.discard(:alert) %>', type: 'alert'})
-<% end %>
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
index 7780a144a26..abcf9740200 100644
--- a/app/views/shared/empty_states/_profile_tabs.html.haml
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -1,5 +1,6 @@
- current_user_empty_message_description = local_assigns.fetch(:current_user_empty_message_description, nil)
- secondary_button_link = local_assigns.fetch(:secondary_button_link, nil)
+- primary_button_link = local_assigns.fetch(:primary_button_link, nil)
.nothing-here-block
.svg-content
diff --git a/app/views/shared/users/_user.html.haml b/app/views/shared/users/_user.html.haml
new file mode 100644
index 00000000000..f92c12102bb
--- /dev/null
+++ b/app/views/shared/users/_user.html.haml
@@ -0,0 +1,13 @@
+- user = local_assigns.fetch(:user)
+
+.col-lg-3.col-md-4.col-sm-12
+ .gl-card.gl-mb-5
+ .gl-card-body
+ = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: ''
+
+ .user-info
+ .block-truncated
+ = link_to user.name, user_path(user), class: 'user js-user-link', data: { user_id: user.id }
+
+ .block-truncated
+ %span.gl-text-gray-900= user.to_reference
diff --git a/app/views/shared/users/index.html.haml b/app/views/shared/users/index.html.haml
new file mode 100644
index 00000000000..dd6b14d6be2
--- /dev/null
+++ b/app/views/shared/users/index.html.haml
@@ -0,0 +1,20 @@
+- followers_illustration_path = 'illustrations/starred_empty.svg'
+- followers_visitor_empty_message = s_('UserProfile|This user doesn\'t have any followers.')
+- followers_current_user_empty_message_header = s_('UserProfile|You do not have any followers.')
+- following_illustration_path = 'illustrations/starred_empty.svg'
+- following_visitor_empty_message = s_('UserProfile|This user isn\'t following other users.')
+- following_current_user_empty_message_header = s_('UserProfile|You are not following other users.')
+
+- if users.size > 0
+ .row.gl-mt-3
+ = render partial: 'shared/users/user', collection: users, as: :user
+ = paginate users, theme: 'gitlab'
+- else
+ - if @user_followers
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: followers_illustration_path,
+ visitor_empty_message: followers_visitor_empty_message,
+ current_user_empty_message_header: followers_current_user_empty_message_header}
+ - elsif @user_following
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: following_illustration_path,
+ visitor_empty_message: following_visitor_empty_message,
+ current_user_empty_message_header: following_current_user_empty_message_header}
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index debfe57dbd7..cdaa739a7b3 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -26,6 +26,13 @@
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn gl-button btn-default btn-icon',
title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= sprite_icon('error')
+ - if current_user && current_user.id != @user.id
+ - if current_user.following?(@user)
+ = link_to user_unfollow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do
+ = _('Unfollow')
+ - else
+ = link_to user_follow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do
+ = _('Follow')
- if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip',
title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
@@ -89,6 +96,16 @@
- unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
= link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link', itemprop: 'email'
+ .cover-desc.gl-text-gray-900.gl-mb-2.mb-sm-2
+ = sprite_icon('users', css_class: 'gl-vertical-align-middle gl-text-gray-500')
+ .profile-link-holder.middle-dot-divider
+ = link_to user_followers_path, class: 'text-link' do
+ - count = @user.followers.count
+ = n_('1 follower', '%{count} followers', count) % { count: count }
+ .profile-link-holder.middle-dot-divider
+ = link_to user_following_path, class: 'text-link' do
+ = @user.followees.count
+ = _('following')
- if @user.bio.present?
.cover-desc.cgray
.profile-user-bio
@@ -129,6 +146,14 @@
%li.js-snippets-tab
= link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do
= s_('UserProfile|Snippets')
+ - if profile_tab?(:followers)
+ %li.js-followers-tab
+ = link_to user_followers_path, data: { target: 'div#followers', action: 'followers', toggle: 'tab', endpoint: user_followers_path(format: :json) } do
+ = s_('UserProfile|Followers')
+ - if profile_tab?(:following)
+ %li.js-following-tab
+ = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json) } do
+ = s_('UserProfile|Following')
%div{ class: container_class }
.tab-content
@@ -165,6 +190,14 @@
#snippets.tab-pane
-# This tab is always loaded via AJAX
+ - if profile_tab?(:followers)
+ #followers.tab-pane
+ -# This tab is always loaded via AJAX
+
+ - if profile_tab?(:following)
+ #following.tab-pane
+ -# This tab is always loaded via AJAX
+
.loading.hide
.spinner.spinner-md
diff --git a/changelogs/unreleased/feat-follow-eachother.yml b/changelogs/unreleased/feat-follow-eachother.yml
new file mode 100644
index 00000000000..ba80e35535e
--- /dev/null
+++ b/changelogs/unreleased/feat-follow-eachother.yml
@@ -0,0 +1,5 @@
+---
+title: Add follow each other model, API and UI(profile, activity view)
+merge_request: 45451
+author: Roger Meier
+type: added
diff --git a/changelogs/unreleased/georgekoltsov-group-migration-epic-award-emoji.yml b/changelogs/unreleased/georgekoltsov-group-migration-epic-award-emoji.yml
new file mode 100644
index 00000000000..e4c4a3026fe
--- /dev/null
+++ b/changelogs/unreleased/georgekoltsov-group-migration-epic-award-emoji.yml
@@ -0,0 +1,5 @@
+---
+title: Import epic award emojis when using Bulk Import
+merge_request: 53747
+author:
+type: added
diff --git a/config/routes/user.rb b/config/routes/user.rb
index 515a9a23360..41319b6d730 100644
--- a/config/routes/user.rb
+++ b/config/routes/user.rb
@@ -46,9 +46,13 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d
get :contributed, as: :contributed_projects
get :starred, as: :starred_projects
get :snippets
+ get :followers
+ get :following
get :exists
get :suggests
get :activity
+ post :follow
+ post :unfollow
get '/', to: redirect('%{username}'), as: nil
end
end
diff --git a/db/migrate/20201027101010_create_user_follow_users.rb b/db/migrate/20201027101010_create_user_follow_users.rb
new file mode 100644
index 00000000000..7c1f831f3b2
--- /dev/null
+++ b/db/migrate/20201027101010_create_user_follow_users.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class CreateUserFollowUsers < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ execute <<~SQL
+ CREATE TABLE user_follow_users (
+ follower_id integer not null references users (id) on delete cascade,
+ followee_id integer not null references users (id) on delete cascade,
+ PRIMARY KEY (follower_id, followee_id)
+ );
+ CREATE INDEX ON user_follow_users (followee_id);
+ SQL
+ end
+ end
+
+ def down
+ drop_table :user_follow_users
+ end
+end
diff --git a/db/schema_migrations/20201027101010 b/db/schema_migrations/20201027101010
new file mode 100644
index 00000000000..68628373757
--- /dev/null
+++ b/db/schema_migrations/20201027101010
@@ -0,0 +1 @@
+d6b324e808265c4ba8b6216c77b7abfa96b4b8b4c9fbd8d0a15240548526c4f3 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 91efc451e20..5d27baaab02 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -17751,6 +17751,11 @@ CREATE SEQUENCE user_details_user_id_seq
ALTER SEQUENCE user_details_user_id_seq OWNED BY user_details.user_id;
+CREATE TABLE user_follow_users (
+ follower_id integer NOT NULL,
+ followee_id integer NOT NULL
+);
+
CREATE TABLE user_highest_roles (
user_id bigint NOT NULL,
updated_at timestamp with time zone NOT NULL,
@@ -20917,6 +20922,9 @@ ALTER TABLE ONLY user_custom_attributes
ALTER TABLE ONLY user_details
ADD CONSTRAINT user_details_pkey PRIMARY KEY (user_id);
+ALTER TABLE ONLY user_follow_users
+ ADD CONSTRAINT user_follow_users_pkey PRIMARY KEY (follower_id, followee_id);
+
ALTER TABLE ONLY user_highest_roles
ADD CONSTRAINT user_highest_roles_pkey PRIMARY KEY (user_id);
@@ -23774,6 +23782,8 @@ CREATE UNIQUE INDEX uniq_pkgs_debian_project_distributions_project_id_and_suite
CREATE UNIQUE INDEX unique_merge_request_metrics_by_merge_request_id ON merge_request_metrics USING btree (merge_request_id);
+CREATE INDEX user_follow_users_followee_id_idx ON user_follow_users USING btree (followee_id);
+
CREATE UNIQUE INDEX vulnerability_feedback_unique_idx ON vulnerability_feedback USING btree (project_id, category, feedback_type, project_fingerprint);
CREATE UNIQUE INDEX vulnerability_occurrence_pipelines_on_unique_keys ON vulnerability_occurrence_pipelines USING btree (occurrence_id, pipeline_id);
@@ -26194,6 +26204,12 @@ ALTER TABLE ONLY u2f_registrations
ADD CONSTRAINT fk_u2f_registrations_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE product_analytics_events_experimental
- ADD CONSTRAINT product_analytics_events_experimental_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;-- schema_migrations.version information is no longer stored in this file,
+ ADD CONSTRAINT product_analytics_events_experimental_project_id_fkey FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY user_follow_users
+ ADD CONSTRAINT user_follow_users_followee_id_fkey FOREIGN KEY (followee_id) REFERENCES users(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY user_follow_users
+ ADD CONSTRAINT user_follow_users_follower_id_fkey FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE;-- schema_migrations.version information is no longer stored in this file,
-- but instead tracked in the db/schema_migrations directory
-- see https://gitlab.com/gitlab-org/gitlab/-/issues/218590 for details
diff --git a/doc/api/users.md b/doc/api/users.md
index d0907008129..60db3be5e88 100644
--- a/doc/api/users.md
+++ b/doc/api/users.md
@@ -274,7 +274,9 @@ Parameters:
"twitter": "",
"website_url": "",
"organization": "",
- "job_title": "Operations Specialist"
+ "job_title": "Operations Specialist",
+ "followers": 1,
+ "following": 1
}
```
@@ -685,6 +687,88 @@ Example responses
}
```
+## User Follow
+
+### Follow and unfollow users
+
+Follow a user.
+
+```plaintext
+POST /users/:id/follow
+```
+
+Unfollow a user.
+
+```plaintext
+POST /users/:id/unfollow
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ------- | -------- | ---------------------------- |
+| `id` | integer | yes | The ID of the user to follow |
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/users/3/follow"
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "username": "john_smith",
+ "name": "John Smith",
+ "state": "active",
+ "avatar_url": "http://localhost:3000/uploads/user/avatar/1/cd8.jpeg",
+ "web_url": "http://localhost:3000/john_smith"
+}
+```
+
+### Followers and following
+
+Get the followers of a user.
+
+```plaintext
+GET /users/:id/followers
+```
+
+Get the list of users being followed.
+
+```plaintext
+GET /users/:id/following
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ------- | -------- | ---------------------------- |
+| `id` | integer | yes | The ID of the user to follow |
+
+```shell
+curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/users/3/followers"
+```
+
+Example response:
+
+```json
+[
+ {
+ "id": 2,
+ "name": "Lennie Donnelly",
+ "username": "evette.kilback",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/7955171a55ac4997ed81e5976287890a?s=80&d=identicon",
+ "web_url": "http://127.0.0.1:3000/evette.kilback"
+ },
+ {
+ "id": 4,
+ "name": "Serena Bradtke",
+ "username": "cammy",
+ "state": "active",
+ "avatar_url": "https://www.gravatar.com/avatar/a2daad869a7b60d3090b7b9bef4baf57?s=80&d=identicon",
+ "web_url": "http://127.0.0.1:3000/cammy"
+ }
+]
+```
+
## User counts
Get the counts (same as in top right menu) of the currently signed in user.
diff --git a/doc/user/img/activity_followed_users_v13_9.png b/doc/user/img/activity_followed_users_v13_9.png
new file mode 100644
index 00000000000..7f54f17821c
--- /dev/null
+++ b/doc/user/img/activity_followed_users_v13_9.png
Binary files differ
diff --git a/doc/user/index.md b/doc/user/index.md
index 598c47963b5..a678038507f 100644
--- a/doc/user/index.md
+++ b/doc/user/index.md
@@ -83,6 +83,14 @@ There are several types of users in GitLab:
self-managed instances' features and settings.
- [Internal users](../development/internal_users.md).
+## User activity
+
+You can follow or unfollow other users from their [user profiles](profile/index.md#user-profile).
+To see their activity in the top-level Activity view, select Follow or Unfollow, and select
+the Followed Users tab:
+
+![Follow users](img/activity_followed_users_v13_9.png)
+
## Projects
In GitLab, you can create [projects](project/index.md) to host
diff --git a/doc/user/profile/img/profile_following_v13_9.png b/doc/user/profile/img/profile_following_v13_9.png
new file mode 100644
index 00000000000..85d54ff3aad
--- /dev/null
+++ b/doc/user/profile/img/profile_following_v13_9.png
Binary files differ
diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md
index 1ca6f968e90..d2cbf9e4acd 100644
--- a/doc/user/profile/index.md
+++ b/doc/user/profile/index.md
@@ -41,6 +41,12 @@ On your profile page, you can see the following information:
- Personal projects: your personal projects (respecting the project's visibility level)
- Starred projects: projects you starred
- Snippets: your personal code [snippets](../snippets.md#personal-snippets)
+- Followers: people following you
+- Following: people you are following
+
+Profile page with active Following view:
+
+![Follow users](img/profile_following_v13_9.png)
## User settings
diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md
index 22dfd3a8719..c0a66343a88 100644
--- a/doc/user/project/labels.md
+++ b/doc/user/project/labels.md
@@ -59,21 +59,25 @@ and edit labels.
> Showing all inherited labels [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/241990) in GitLab 13.5.
-To view the project labels list, navigate to the project and click **Issues > Labels**.
-The list includes all labels that are defined at the project level, as well as all
-labels defined by its ancestor groups.
-For each label, you can see the project or group path from where it was created.
-You can filter the list by entering a search query at the top and clicking search (**{search}**).
+To view a project's available labels, in the project, go to **Issues > Labels**.
+Its list of labels includes both the labels defined at the project level, and
+all labels defined by its ancestor groups. For each label, you can see the
+project or group path from where it was created. You can filter the list by
+entering a search query in the **Filter** field, and then clicking its search
+icon (**{search}**).
To create a new project label:
-1. Navigate to **Issues > Labels** in the project.
-1. Click the **New label** button.
- - Enter the title.
- - (Optional) Enter a description.
- - (Optional) Select a background color by clicking on the available colors, or input
- a hex color value for a specific color.
-1. Click **Create label** to create the label.
+1. In your project, go to **Issues > Labels**.
+1. Select the **New label** button.
+1. In the **Title** field, enter a short, descriptive name for the label. You
+ can also use this field to create [scoped, mutually exclusive labels](#scoped-labels).
+1. (Optional) In the **Description** field, you can enter additional
+ information about how and when to use this label.
+1. (Optional) Select a background color for the label by selecting one of the
+ available colors, or by entering a hex color value in the **Background color**
+ field.
+1. Select **Create label**.
You can also create a new project label from within an issue or merge request. In the
label section of the right sidebar of an issue or a merge request:
diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md
index adcf4657945..debe5c51d51 100644
--- a/doc/user/project/service_desk.md
+++ b/doc/user/project/service_desk.md
@@ -86,9 +86,10 @@ To improve your project's security, we recommend the following:
- [Enable Akismet](../../integration/akismet.md) on your GitLab instance to add spam checking to this service.
Unblocked email spam can result in many spam issues being created.
-The unique internal email address is visible to all project members in your GitLab instance.
-However, when using an email alias externally, an end user (issue creator) cannot see the internal
-email address displayed in the information note.
+The unique internal email address is visible to project members with Maintainer (or higher)
+[permission level](../permissions.md)
+in your GitLab instance. However, when using an email alias externally, an end user
+(issue creator) cannot see the internal email address displayed in the information note.
### Using customized email templates
diff --git a/lib/api/entities/user.rb b/lib/api/entities/user.rb
index b392e7831e5..248a86751d2 100644
--- a/lib/api/entities/user.rb
+++ b/lib/api/entities/user.rb
@@ -10,6 +10,12 @@ module API
expose :work_information do |user|
work_information(user)
end
+ expose :followers, if: ->(user, opts) { Ability.allowed?(opts[:current_user], :read_user_profile, user) } do |user|
+ user.followers.count
+ end
+ expose :following, if: ->(user, opts) { Ability.allowed?(opts[:current_user], :read_user_profile, user) } do |user|
+ user.followees.count
+ end
end
end
end
diff --git a/lib/api/users.rb b/lib/api/users.rb
index 0352ddfb214..28274d5afb3 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -159,6 +159,68 @@ module API
present user.status || {}, with: Entities::UserStatus
end
+ desc 'Follow a user' do
+ success Entities::User
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ end
+ post ':id/follow', feature_category: :users do
+ user = find_user(params[:id])
+ not_found!('User') unless user
+
+ if current_user.follow(user)
+ present user, with: Entities::UserBasic
+ else
+ not_modified!
+ end
+ end
+
+ desc 'Unfollow a user' do
+ success Entities::User
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ end
+ post ':id/unfollow', feature_category: :users do
+ user = find_user(params[:id])
+ not_found!('User') unless user
+
+ if current_user.unfollow(user)
+ present user, with: Entities::UserBasic
+ else
+ not_modified!
+ end
+ end
+
+ desc 'Get the users who follow a user' do
+ success Entities::UserBasic
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ use :pagination
+ end
+ get ':id/following', feature_category: :users do
+ user = find_user(params[:id])
+ not_found!('User') unless user && can?(current_user, :read_user_profile, user)
+
+ present paginate(user.followees), with: Entities::UserBasic
+ end
+
+ desc 'Get the followers of a user' do
+ success Entities::UserBasic
+ end
+ params do
+ requires :id, type: Integer, desc: 'The ID of the user'
+ use :pagination
+ end
+ get ':id/followers', feature_category: :users do
+ user = find_user(params[:id])
+ not_found!('User') unless user && can?(current_user, :read_user_profile, user)
+
+ present paginate(user.followers), with: Entities::UserBasic
+ end
+
desc 'Create a user. Available only for admins.' do
success Entities::UserWithAdmin
end
diff --git a/lib/bulk_imports/common/extractors/graphql_extractor.rb b/lib/bulk_imports/common/extractors/graphql_extractor.rb
index 9e53b525fe3..cde3d1cad5b 100644
--- a/lib/bulk_imports/common/extractors/graphql_extractor.rb
+++ b/lib/bulk_imports/common/extractors/graphql_extractor.rb
@@ -13,7 +13,7 @@ module BulkImports
response = client.execute(
client.parse(query.to_s),
- query.variables(context.entity)
+ query.variables(context)
).original_hash.deep_dup
BulkImports::Pipeline::ExtractedData.new(
diff --git a/lib/bulk_imports/common/transformers/award_emoji_transformer.rb b/lib/bulk_imports/common/transformers/award_emoji_transformer.rb
new file mode 100644
index 00000000000..260b47ab917
--- /dev/null
+++ b/lib/bulk_imports/common/transformers/award_emoji_transformer.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module BulkImports
+ module Common
+ module Transformers
+ class AwardEmojiTransformer
+ def initialize(*args); end
+
+ def transform(context, data)
+ user = find_user(context, data&.dig('user', 'public_email')) || context.current_user
+
+ data
+ .except('user')
+ .merge('user_id' => user.id)
+ end
+
+ private
+
+ def find_user(context, email)
+ return if email.blank?
+
+ context.group.users.find_by_any_email(email, confirmed: true) # rubocop: disable CodeReuse/ActiveRecord
+ end
+ end
+ end
+ end
+end
diff --git a/lib/bulk_imports/groups/graphql/get_group_query.rb b/lib/bulk_imports/groups/graphql/get_group_query.rb
index 169c61247c3..6852e25c87f 100644
--- a/lib/bulk_imports/groups/graphql/get_group_query.rb
+++ b/lib/bulk_imports/groups/graphql/get_group_query.rb
@@ -29,8 +29,8 @@ module BulkImports
GRAPHQL
end
- def variables(entity)
- { full_path: entity.source_full_path }
+ def variables(context)
+ { full_path: context.entity.source_full_path }
end
def base_path
diff --git a/lib/bulk_imports/groups/graphql/get_labels_query.rb b/lib/bulk_imports/groups/graphql/get_labels_query.rb
index cd57b46b9f4..d1fe791c2ce 100644
--- a/lib/bulk_imports/groups/graphql/get_labels_query.rb
+++ b/lib/bulk_imports/groups/graphql/get_labels_query.rb
@@ -26,10 +26,10 @@ module BulkImports
GRAPHQL
end
- def variables(entity)
+ def variables(context)
{
- full_path: entity.source_full_path,
- cursor: entity.next_page_for(:labels)
+ full_path: context.entity.source_full_path,
+ cursor: context.entity.next_page_for(:labels)
}
end
diff --git a/lib/bulk_imports/groups/graphql/get_members_query.rb b/lib/bulk_imports/groups/graphql/get_members_query.rb
index 1287abc85dc..e3a78124a47 100644
--- a/lib/bulk_imports/groups/graphql/get_members_query.rb
+++ b/lib/bulk_imports/groups/graphql/get_members_query.rb
@@ -31,10 +31,10 @@ module BulkImports
GRAPHQL
end
- def variables(entity)
+ def variables(context)
{
- full_path: entity.source_full_path,
- cursor: entity.next_page_for(:group_members)
+ full_path: context.entity.source_full_path,
+ cursor: context.entity.next_page_for(:group_members)
}
end
diff --git a/lib/bulk_imports/pipeline/context.rb b/lib/bulk_imports/pipeline/context.rb
index cda03c04ef2..dd121b2dbed 100644
--- a/lib/bulk_imports/pipeline/context.rb
+++ b/lib/bulk_imports/pipeline/context.rb
@@ -4,10 +4,12 @@ module BulkImports
module Pipeline
class Context
attr_reader :entity, :bulk_import
+ attr_accessor :extra
- def initialize(entity)
+ def initialize(entity, extra = {})
@entity = entity
@bulk_import = entity.bulk_import
+ @extra = extra
end
def group
diff --git a/lib/gitlab/database/migrations/instrumentation.rb b/lib/gitlab/database/migrations/instrumentation.rb
index ea5dd09120b..959028ce00b 100644
--- a/lib/gitlab/database/migrations/instrumentation.rb
+++ b/lib/gitlab/database/migrations/instrumentation.rb
@@ -3,16 +3,11 @@
module Gitlab
module Database
module Migrations
- Observation = Struct.new(
- :migration,
- :walltime,
- :success
- )
-
class Instrumentation
attr_reader :observations
- def initialize
+ def initialize(observers = ::Gitlab::Database::Migrations::Observers.all_observers)
+ @observers = observers
@observations = []
end
@@ -22,6 +17,8 @@ module Gitlab
exception = nil
+ on_each_observer { |observer| observer.before }
+
observation.walltime = Benchmark.realtime do
yield
rescue => e
@@ -29,6 +26,9 @@ module Gitlab
observation.success = false
end
+ on_each_observer { |observer| observer.after }
+ on_each_observer { |observer| observer.record(observation) }
+
record_observation(observation)
raise exception if exception
@@ -38,9 +38,19 @@ module Gitlab
private
+ attr_reader :observers
+
def record_observation(observation)
@observations << observation
end
+
+ def on_each_observer(&block)
+ observers.each do |observer|
+ yield observer
+ rescue => e
+ Gitlab::AppLogger.error("Migration observer #{observer.class} failed with: #{e}")
+ end
+ end
end
end
end
diff --git a/lib/gitlab/database/migrations/observation.rb b/lib/gitlab/database/migrations/observation.rb
new file mode 100644
index 00000000000..518c2c560d2
--- /dev/null
+++ b/lib/gitlab/database/migrations/observation.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Migrations
+ Observation = Struct.new(
+ :migration,
+ :walltime,
+ :success,
+ :total_database_size_change
+ )
+ end
+ end
+end
diff --git a/lib/gitlab/database/migrations/observers.rb b/lib/gitlab/database/migrations/observers.rb
new file mode 100644
index 00000000000..4b931d3c19c
--- /dev/null
+++ b/lib/gitlab/database/migrations/observers.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Migrations
+ module Observers
+ def self.all_observers
+ [
+ TotalDatabaseSizeChange.new
+ ]
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/migrations/observers/migration_observer.rb b/lib/gitlab/database/migrations/observers/migration_observer.rb
new file mode 100644
index 00000000000..9bfbf35887d
--- /dev/null
+++ b/lib/gitlab/database/migrations/observers/migration_observer.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Migrations
+ module Observers
+ class MigrationObserver
+ attr_reader :connection
+
+ def initialize
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def before
+ # implement in subclass
+ end
+
+ def after
+ # implement in subclass
+ end
+
+ def record(observation)
+ raise NotImplementedError, 'implement in subclass'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/migrations/observers/total_database_size_change.rb b/lib/gitlab/database/migrations/observers/total_database_size_change.rb
new file mode 100644
index 00000000000..0b76b0bef5e
--- /dev/null
+++ b/lib/gitlab/database/migrations/observers/total_database_size_change.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module Migrations
+ module Observers
+ class TotalDatabaseSizeChange < MigrationObserver
+ def before
+ @size_before = get_total_database_size
+ end
+
+ def after
+ @size_after = get_total_database_size
+ end
+
+ def record(observation)
+ return unless @size_after && @size_before
+
+ observation.total_database_size_change = @size_after - @size_before
+ end
+
+ private
+
+ def get_total_database_size
+ connection.execute("select pg_database_size(current_database())").first['pg_database_size']
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 329ec718cf0..058018bc8d2 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1123,6 +1123,11 @@ msgid_plural "%d deploy keys"
msgstr[0] ""
msgstr[1] ""
+msgid "1 follower"
+msgid_plural "%{count} followers"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "1 group"
msgid_plural "%d groups"
msgstr[0] ""
@@ -12971,6 +12976,12 @@ msgstr ""
msgid "Folder/%{name}"
msgstr ""
+msgid "Follow"
+msgstr ""
+
+msgid "Followed users"
+msgstr ""
+
msgid "Font Color"
msgstr ""
@@ -22224,21 +22235,39 @@ msgstr ""
msgid "Preferences|Choose what content you want to see on your homepage."
msgstr ""
+msgid "Preferences|Customize integrations with third party services."
+msgstr ""
+
msgid "Preferences|Customize the appearance of the application header and navigation sidebar."
msgstr ""
msgid "Preferences|Display time in 24-hour format"
msgstr ""
+msgid "Preferences|Enable Gitpod integration"
+msgstr ""
+
+msgid "Preferences|Enable integrated code intelligence on code views"
+msgstr ""
+
+msgid "Preferences|Failed to save preferences."
+msgstr ""
+
msgid "Preferences|For example: 30 mins ago."
msgstr ""
+msgid "Preferences|Gitpod"
+msgstr ""
+
msgid "Preferences|Homepage content"
msgstr ""
msgid "Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser."
msgstr ""
+msgid "Preferences|Integrations"
+msgstr ""
+
msgid "Preferences|Layout width"
msgstr ""
@@ -22260,6 +22289,9 @@ msgstr ""
msgid "Preferences|Show whitespace changes in diffs"
msgstr ""
+msgid "Preferences|Sourcegraph"
+msgstr ""
+
msgid "Preferences|Syntax highlighting theme"
msgstr ""
@@ -22452,24 +22484,6 @@ msgstr ""
msgid "Profile Settings"
msgstr ""
-msgid "ProfilePreferences|Customize integrations with third party services."
-msgstr ""
-
-msgid "ProfilePreferences|Enable Gitpod integration"
-msgstr ""
-
-msgid "ProfilePreferences|Enable integrated code intelligence on code views"
-msgstr ""
-
-msgid "ProfilePreferences|Gitpod"
-msgstr ""
-
-msgid "ProfilePreferences|Integrations"
-msgstr ""
-
-msgid "ProfilePreferences|Sourcegraph"
-msgstr ""
-
msgid "ProfileSession|on"
msgstr ""
@@ -31381,6 +31395,9 @@ msgstr ""
msgid "Unexpected error"
msgstr ""
+msgid "Unfollow"
+msgstr ""
+
msgid "Unfortunately, your email message to GitLab could not be processed."
msgstr ""
@@ -32038,6 +32055,12 @@ msgstr ""
msgid "UserProfile|Explore public groups to find projects to contribute to."
msgstr ""
+msgid "UserProfile|Followers"
+msgstr ""
+
+msgid "UserProfile|Following"
+msgstr ""
+
msgid "UserProfile|Groups"
msgstr ""
@@ -32080,6 +32103,9 @@ msgstr ""
msgid "UserProfile|Subscribe"
msgstr ""
+msgid "UserProfile|This user doesn't have any followers."
+msgstr ""
+
msgid "UserProfile|This user doesn't have any personal projects"
msgstr ""
@@ -32095,6 +32121,9 @@ msgstr ""
msgid "UserProfile|This user is blocked"
msgstr ""
+msgid "UserProfile|This user isn't following other users."
+msgstr ""
+
msgid "UserProfile|Unconfirmed user"
msgstr ""
@@ -32104,9 +32133,15 @@ msgstr ""
msgid "UserProfile|View user in admin area"
msgstr ""
+msgid "UserProfile|You are not following other users."
+msgstr ""
+
msgid "UserProfile|You can create a group for several dependent projects."
msgstr ""
+msgid "UserProfile|You do not have any followers."
+msgstr ""
+
msgid "UserProfile|You haven't created any personal projects."
msgstr ""
@@ -34672,6 +34707,9 @@ msgstr[1] ""
msgid "finding is not found or is already attached to a vulnerability"
msgstr ""
+msgid "following"
+msgstr ""
+
msgid "for %{link_to_merge_request} with %{link_to_merge_request_source_branch}"
msgstr ""
diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb
index 4a68475c37f..b7870a63f9d 100644
--- a/spec/controllers/profiles/preferences_controller_spec.rb
+++ b/spec/controllers/profiles/preferences_controller_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe Profiles::PreferencesController do
end
describe 'PATCH update' do
- def go(params: {}, format: :js)
+ def go(params: {}, format: :json)
params.reverse_merge!(
color_scheme_id: '1',
dashboard: 'stars',
@@ -35,9 +35,12 @@ RSpec.describe Profiles::PreferencesController do
end
context 'on successful update' do
- it 'sets the flash' do
+ it 'responds with success' do
go
- expect(flash[:notice]).to eq _('Preferences saved.')
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.parsed_body['message']).to eq _('Preferences saved.')
+ expect(response.parsed_body['type']).to eq('notice')
end
it "changes the user's preferences" do
@@ -59,36 +62,26 @@ RSpec.describe Profiles::PreferencesController do
end
context 'on failed update' do
- it 'sets the flash' do
+ it 'responds with error' do
expect(user).to receive(:save).and_return(false)
go
- expect(flash[:alert]).to eq(_('Failed to save preferences.'))
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response.parsed_body['message']).to eq _('Failed to save preferences.')
+ expect(response.parsed_body['type']).to eq('alert')
end
end
context 'on invalid dashboard setting' do
- it 'sets the flash' do
+ it 'responds with error' do
prefs = { dashboard: 'invalid' }
go params: prefs
- expect(flash[:alert]).to match(/\AFailed to save preferences \(.+\)\.\z/)
- end
- end
-
- context 'as js' do
- it 'renders' do
- go
- expect(response).to render_template :update
- end
- end
-
- context 'as html' do
- it 'redirects' do
- go format: :html
- expect(response).to redirect_to(profile_preferences_path)
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response.parsed_body['message']).to match(/\AFailed to save preferences \(.+\)\.\z/)
+ expect(response.parsed_body['type']).to eq('alert')
end
end
end
diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb
index b419a063858..e75e661b513 100644
--- a/spec/features/dashboard/activity_spec.rb
+++ b/spec/features/dashboard/activity_spec.rb
@@ -9,6 +9,26 @@ RSpec.describe 'Dashboard > Activity' do
sign_in(user)
end
+ context 'tabs' do
+ it 'shows Your Projects' do
+ visit activity_dashboard_path
+
+ expect(find('.top-area .nav-tabs li.active')).to have_content('Your projects')
+ end
+
+ it 'shows Starred Projects' do
+ visit activity_dashboard_path(filter: 'starred')
+
+ expect(find('.top-area .nav-tabs li.active')).to have_content('Starred projects')
+ end
+
+ it 'shows Followed Projects' do
+ visit activity_dashboard_path(filter: 'followed')
+
+ expect(find('.top-area .nav-tabs li.active')).to have_content('Followed users')
+ end
+ end
+
context 'rss' do
before do
visit activity_dashboard_path
diff --git a/spec/features/profiles/user_edit_preferences_spec.rb b/spec/features/profiles/user_edit_preferences_spec.rb
index d489d92c524..3129e4bd952 100644
--- a/spec/features/profiles/user_edit_preferences_spec.rb
+++ b/spec/features/profiles/user_edit_preferences_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe 'User edit preferences profile' do
+RSpec.describe 'User edit preferences profile', :js do
let(:user) { create(:user) }
before do
@@ -53,7 +53,14 @@ RSpec.describe 'User edit preferences profile' do
fill_in 'Tab width', with: -1
click_button 'Save changes'
- expect(page).to have_content('Failed to save preferences')
+ field = page.find_field('user[tab_width]')
+ message = field.native.attribute("validationMessage")
+ expect(message).to eq "Value must be greater than or equal to 1."
+
+ # User trying to hack an invalid value
+ page.execute_script("document.querySelector('#user_tab_width').setAttribute('min', '-1')")
+ click_button 'Save changes'
+ expect(page).to have_content('Failed to save preferences.')
end
end
diff --git a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb
index 56db7efff51..da63f7c0f41 100644
--- a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb
+++ b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'User visits the profile preferences page' do
+RSpec.describe 'User visits the profile preferences page', :js do
include Select2Helper
let(:user) { create(:user) }
@@ -39,7 +39,7 @@ RSpec.describe 'User visits the profile preferences page' do
describe 'User changes their default dashboard', :js do
it 'creates a flash message' do
select2('stars', from: '#user_dashboard')
- click_button 'Save'
+ click_button 'Save changes'
wait_for_requests
@@ -48,7 +48,7 @@ RSpec.describe 'User visits the profile preferences page' do
it 'updates their preference' do
select2('stars', from: '#user_dashboard')
- click_button 'Save'
+ click_button 'Save changes'
wait_for_requests
@@ -67,7 +67,7 @@ RSpec.describe 'User visits the profile preferences page' do
describe 'User changes their language', :js do
it 'creates a flash message', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/31404' do
select2('en', from: '#user_preferred_language')
- click_button 'Save'
+ click_button 'Save changes'
wait_for_requests
@@ -77,7 +77,7 @@ RSpec.describe 'User visits the profile preferences page' do
it 'updates their preference' do
wait_for_requests
select2('pt_BR', from: '#user_preferred_language')
- click_button 'Save'
+ click_button 'Save changes'
wait_for_requests
refresh
@@ -94,6 +94,8 @@ RSpec.describe 'User visits the profile preferences page' do
click_button 'Save changes'
+ wait_for_requests
+
expect(user.reload.render_whitespace_in_code).to be(true)
expect(render_whitespace_field).to be_checked
end
diff --git a/spec/features/users/overview_spec.rb b/spec/features/users/overview_spec.rb
index 67216b04504..902079b7b93 100644
--- a/spec/features/users/overview_spec.rb
+++ b/spec/features/users/overview_spec.rb
@@ -151,6 +151,132 @@ RSpec.describe 'Overview tab on a user profile', :js do
end
end
+ describe 'followers section' do
+ describe 'user has no followers' do
+ before do
+ visit user.username
+ page.find('.js-followers-tab a').click
+ wait_for_requests
+ end
+
+ it 'shows an empty followers list with an info message' do
+ page.within('#followers') do
+ expect(page).to have_content('You do not have any followers')
+ expect(page).not_to have_selector('.gl-card.gl-mb-5')
+ expect(page).not_to have_selector('.gl-pagination')
+ end
+ end
+ end
+
+ describe 'user has less then 20 followers' do
+ let(:follower) { create(:user) }
+
+ before do
+ follower.follow(user)
+ visit user.username
+ page.find('.js-followers-tab a').click
+ wait_for_requests
+ end
+
+ it 'shows followers' do
+ page.within('#followers') do
+ expect(page).to have_content(follower.name)
+ expect(page).to have_selector('.gl-card.gl-mb-5')
+ expect(page).not_to have_selector('.gl-pagination')
+ end
+ end
+ end
+
+ describe 'user has more then 20 followers' do
+ let(:other_users) { create_list(:user, 21) }
+
+ before do
+ other_users.each do |follower|
+ follower.follow(user)
+ end
+
+ visit user.username
+ page.find('.js-followers-tab a').click
+ wait_for_requests
+ end
+ it 'shows paginated followers' do
+ page.within('#followers') do
+ other_users.each_with_index do |follower, i|
+ break if i == 20
+
+ expect(page).to have_content(follower.name)
+ end
+ expect(page).to have_selector('.gl-card.gl-mb-5')
+ expect(page).to have_selector('.gl-pagination')
+ expect(page).to have_selector('.gl-pagination .js-pagination-page', count: 2)
+ end
+ end
+ end
+ end
+
+ describe 'following section' do
+ describe 'user is not following others' do
+ before do
+ visit user.username
+ page.find('.js-following-tab a').click
+ wait_for_requests
+ end
+
+ it 'shows an empty following list with an info message' do
+ page.within('#following') do
+ expect(page).to have_content('You are not following other users')
+ expect(page).not_to have_selector('.gl-card.gl-mb-5')
+ expect(page).not_to have_selector('.gl-pagination')
+ end
+ end
+ end
+
+ describe 'user is following less then 20 people' do
+ let(:followee) { create(:user) }
+
+ before do
+ user.follow(followee)
+ visit user.username
+ page.find('.js-following-tab a').click
+ wait_for_requests
+ end
+
+ it 'shows following user' do
+ page.within('#following') do
+ expect(page).to have_content(followee.name)
+ expect(page).to have_selector('.gl-card.gl-mb-5')
+ expect(page).not_to have_selector('.gl-pagination')
+ end
+ end
+ end
+
+ describe 'user is following more then 20 people' do
+ let(:other_users) { create_list(:user, 21) }
+
+ before do
+ other_users.each do |followee|
+ user.follow(followee)
+ end
+
+ visit user.username
+ page.find('.js-following-tab a').click
+ wait_for_requests
+ end
+ it 'shows paginated following' do
+ page.within('#following') do
+ other_users.each_with_index do |followee, i|
+ break if i == 20
+
+ expect(page).to have_content(followee.name)
+ end
+ expect(page).to have_selector('.gl-card.gl-mb-5')
+ expect(page).to have_selector('.gl-pagination')
+ expect(page).to have_selector('.gl-pagination .js-pagination-page', count: 2)
+ end
+ end
+ end
+ end
+
describe 'bot user' do
let(:bot_user) { create(:user, user_type: :security_bot) }
diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb
index 6aeb3023db8..a8372800700 100644
--- a/spec/features/users/show_spec.rb
+++ b/spec/features/users/show_spec.rb
@@ -20,6 +20,8 @@ RSpec.describe 'User page' do
expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets')
+ expect(page).to have_link('Followers')
+ expect(page).to have_link('Following')
end
end
@@ -54,6 +56,50 @@ RSpec.describe 'User page' do
expect(page).to have_content('GitLab - work info test')
end
end
+
+ context 'follow/unfollow and followers/following' do
+ let_it_be(:followee) { create(:user) }
+ let_it_be(:follower) { create(:user) }
+
+ it 'does not show link to follow' do
+ subject
+
+ expect(page).not_to have_link(text: 'Follow', class: 'gl-button')
+ end
+
+ it 'shows 0 followers and 0 following' do
+ subject
+
+ expect(page).to have_content('0 followers')
+ expect(page).to have_content('0 following')
+ end
+
+ it 'shows 1 followers and 1 following' do
+ follower.follow(user)
+ user.follow(followee)
+
+ subject
+
+ expect(page).to have_content('1 follower')
+ expect(page).to have_content('1 following')
+ end
+
+ it 'does show link to follow' do
+ sign_in(user)
+ visit user_path(followee)
+
+ expect(page).to have_link(text: 'Follow', class: 'gl-button')
+ end
+
+ it 'does show link to unfollow' do
+ sign_in(user)
+ user.follow(followee)
+
+ visit user_path(followee)
+
+ expect(page).to have_link(text: 'Unfollow', class: 'gl-button')
+ end
+ end
end
context 'with private profile' do
@@ -83,6 +129,8 @@ RSpec.describe 'User page' do
expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets')
+ expect(page).to have_link('Followers')
+ expect(page).to have_link('Following')
end
end
end
@@ -242,6 +290,8 @@ RSpec.describe 'User page' do
expect(page).not_to have_link('Contributed projects')
expect(page).not_to have_link('Personal projects')
expect(page).not_to have_link('Snippets')
+ expect(page).not_to have_link('Followers')
+ expect(page).not_to have_link('Following')
end
end
end
@@ -261,6 +311,8 @@ RSpec.describe 'User page' do
expect(page).to have_link('Contributed projects')
expect(page).to have_link('Personal projects')
expect(page).to have_link('Snippets')
+ expect(page).to have_link('Followers')
+ expect(page).to have_link('Following')
end
end
end
diff --git a/spec/finders/user_recent_events_finder_spec.rb b/spec/finders/user_recent_events_finder_spec.rb
index afebff5b5c9..5a9243d150d 100644
--- a/spec/finders/user_recent_events_finder_spec.rb
+++ b/spec/finders/user_recent_events_finder_spec.rb
@@ -5,16 +5,17 @@ require 'spec_helper'
RSpec.describe UserRecentEventsFinder do
let_it_be(:project_owner, reload: true) { create(:user) }
let_it_be(:current_user, reload: true) { create(:user) }
- let(:private_project) { create(:project, :private, creator: project_owner) }
- let(:internal_project) { create(:project, :internal, creator: project_owner) }
- let(:public_project) { create(:project, :public, creator: project_owner) }
+ let_it_be(:private_project) { create(:project, :private, creator: project_owner) }
+ let_it_be(:internal_project) { create(:project, :internal, creator: project_owner) }
+ let_it_be(:public_project) { create(:project, :public, creator: project_owner) }
let!(:private_event) { create(:event, project: private_project, author: project_owner) }
let!(:internal_event) { create(:event, project: internal_project, author: project_owner) }
let!(:public_event) { create(:event, project: public_project, author: project_owner) }
+ let_it_be(:issue) { create(:issue, project: public_project) }
let(:limit) { nil }
let(:params) { { limit: limit } }
- subject(:finder) { described_class.new(current_user, project_owner, params) }
+ subject(:finder) { described_class.new(current_user, project_owner, nil, params) }
describe '#execute' do
context 'when profile is public' do
@@ -39,15 +40,106 @@ RSpec.describe UserRecentEventsFinder do
expect(finder.execute).to be_empty
end
- describe 'design activity events' do
- let_it_be(:event_a) { create(:design_event, author: project_owner) }
- let_it_be(:event_b) { create(:design_event, author: project_owner) }
+ context 'events from multiple users' do
+ let_it_be(:second_user, reload: true) { create(:user) }
+ let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) }
+ let(:internal_project_second_user) { create(:project, :internal, creator: second_user) }
+ let(:public_project_second_user) { create(:project, :public, creator: second_user) }
+ let!(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) }
+ let!(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) }
+ let!(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) }
+
+ it 'includes events from all users', :aggregate_failures do
+ events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
+
+ expect(events).to include(private_event, internal_event, public_event)
+ expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user)
+ expect(events.size).to eq(6)
+ end
+
+ it 'does not include events from users with private profile', :aggregate_failures do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false)
+
+ events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
+
+ expect(events).to include(private_event, internal_event, public_event)
+ expect(events.size).to eq(3)
+ end
+ end
+
+ context 'filter activity events' do
+ let!(:push_event) { create(:push_event, project: public_project, author: project_owner) }
+ let!(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) }
+ let!(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) }
+ let!(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) }
+ let!(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) }
+ let!(:design_event) { create(:design_event, project: public_project, author: project_owner) }
+ let!(:team_event) { create(:event, :joined, project: public_project, author: project_owner) }
+
+ it 'includes all events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::ALL)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
+
+ expect(events).to include(private_event, internal_event, public_event)
+ expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event)
+ expect(events.size).to eq(10)
+ end
+
+ it 'only includes push events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::PUSH)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
+
+ expect(events).to include(push_event)
+ expect(events.size).to eq(1)
+ end
+
+ it 'only includes merge events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::MERGED)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
+
+ expect(events).to include(merge_event)
+ expect(events.size).to eq(1)
+ end
+
+ it 'only includes issue events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::ISSUE)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
+
+ expect(events).to include(issue_event)
+ expect(events.size).to eq(1)
+ end
+
+ it 'only includes comments events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::COMMENTS)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
+
+ expect(events).to include(comment_event)
+ expect(events.size).to eq(1)
+ end
+
+ it 'only includes wiki events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::WIKI)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
+
+ expect(events).to include(wiki_event)
+ expect(events.size).to eq(1)
+ end
it 'only includes design events', :aggregate_failures do
- events = finder.execute
+ event_filter = EventFilter.new(EventFilter::DESIGNS)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
- expect(events).to include(event_a)
- expect(events).to include(event_b)
+ expect(events).to include(design_event)
+ expect(events.size).to eq(1)
+ end
+
+ it 'only includes team events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::TEAM)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
+
+ expect(events).to include(private_event, internal_event, public_event, team_event)
+ expect(events.size).to eq(4)
end
end
diff --git a/spec/frontend/boards/board_list_deprecated_spec.js b/spec/frontend/boards/board_list_deprecated_spec.js
index da3d24de1be..b71564f7858 100644
--- a/spec/frontend/boards/board_list_deprecated_spec.js
+++ b/spec/frontend/boards/board_list_deprecated_spec.js
@@ -1,16 +1,15 @@
/* global List */
/* global ListIssue */
-
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
-import axios from '~/lib/utils/axios_utils';
import BoardList from '~/boards/components/board_list_deprecated.vue';
import eventHub from '~/boards/eventhub';
-import '~/boards/models/issue';
-import '~/boards/models/list';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
+import axios from '~/lib/utils/axios_utils';
+import '~/boards/models/issue';
+import '~/boards/models/list';
import { listObj, boardsMockInterceptor } from './mock_data';
const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => {
diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js
index fc02262ea26..915b470df8d 100644
--- a/spec/frontend/boards/board_list_helper.js
+++ b/spec/frontend/boards/board_list_helper.js
@@ -1,16 +1,14 @@
/* global List */
/* global ListIssue */
-
import MockAdapter from 'axios-mock-adapter';
import Sortable from 'sortablejs';
import Vue from 'vue';
-import axios from '~/lib/utils/axios_utils';
import BoardList from '~/boards/components/board_list_deprecated.vue';
-
import '~/boards/models/issue';
import '~/boards/models/list';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
+import axios from '~/lib/utils/axios_utils';
import { listObj, boardsMockInterceptor } from './mock_data';
window.Sortable = Sortable;
diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js
index 79248d53f53..4e523d636cd 100644
--- a/spec/frontend/boards/components/board_column_spec.js
+++ b/spec/frontend/boards/components/board_column_spec.js
@@ -1,5 +1,5 @@
-import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import { listObj } from 'jest/boards/mock_data';
import BoardColumn from '~/boards/components/board_column.vue';
diff --git a/spec/frontend/boards/issue_card_deprecated_spec.js b/spec/frontend/boards/issue_card_deprecated_spec.js
index 2fefda5158b..909be275030 100644
--- a/spec/frontend/boards/issue_card_deprecated_spec.js
+++ b/spec/frontend/boards/issue_card_deprecated_spec.js
@@ -1,11 +1,11 @@
/* global ListAssignee, ListLabel, ListIssue */
+import { GlLabel } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { range } from 'lodash';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/issue';
import '~/boards/models/list';
-import { GlLabel } from '@gitlab/ui';
import IssueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
import store from '~/boards/stores';
import { listObj } from './mock_data';
diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js
index db01f62c9a6..4d6a82bdff0 100644
--- a/spec/frontend/boards/list_spec.js
+++ b/spec/frontend/boards/list_spec.js
@@ -2,16 +2,15 @@
/* global ListAssignee */
/* global ListIssue */
/* global ListLabel */
-
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
-import axios from '~/lib/utils/axios_utils';
import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/issue';
import '~/boards/models/list';
import { ListType } from '~/boards/constants';
import boardsStore from '~/boards/stores/boards_store';
+import axios from '~/lib/utils/axios_utils';
import { listObj, listObjDuplicate, boardsMockInterceptor } from './mock_data';
describe('List model', () => {
diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js
index 714ff4504a5..954025091cf 100644
--- a/spec/frontend/commits_spec.js
+++ b/spec/frontend/commits_spec.js
@@ -1,6 +1,6 @@
+import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import 'vendor/jquery.endless-scroll';
-import MockAdapter from 'axios-mock-adapter';
import CommitsList from '~/commits';
import axios from '~/lib/utils/axios_utils';
import Pager from '~/pager';
diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
index 543915bbb15..1b5bffc1f9b 100644
--- a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
+++ b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js
@@ -1,7 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
-import axios from '~/lib/utils/axios_utils';
import { dismiss } from '~/feature_highlight/feature_highlight_helper';
import { deprecatedCreateFlash as Flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
jest.mock('~/flash');
diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js
index c1c09ea5d3e..0e2d2ee6c09 100644
--- a/spec/frontend/filtered_search/dropdown_user_spec.js
+++ b/spec/frontend/filtered_search/dropdown_user_spec.js
@@ -1,4 +1,7 @@
import DropdownUtils from '~/filtered_search/dropdown_utils';
+// TODO: Moving this line up throws an error about `FilteredSearchDropdown`
+// being undefined in test. See gitlab-org/gitlab#321476 for more info.
+// eslint-disable-next-line import/order
import DropdownUser from '~/filtered_search/dropdown_user';
import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
import IssuableFilteredTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 810e060e46b..08368e1f2ca 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -1,10 +1,9 @@
/* eslint no-param-reassign: "off" */
-
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
+import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete';
import { initEmojiMock } from 'helpers/emoji';
import '~/lib/utils/jquery_at_who';
-import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete';
import { getJSONFixture } from 'helpers/fixtures';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 52aad9b4b68..1985feb1615 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -1,8 +1,8 @@
-import Vuex from 'vuex';
-import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
-import '~/behaviors/markdown/render_gfm';
import { Range } from 'monaco-editor';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import '~/behaviors/markdown/render_gfm';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import waitForPromises from 'helpers/wait_for_promises';
import waitUsingRealTimer from 'helpers/wait_using_real_timer';
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index f2f9362ae99..32a24227cbd 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
import timezoneMock from 'timezone-mock';
+import * as datetimeUtility from '~/lib/utils/datetime_utility';
import { __, s__ } from '~/locale';
import '~/commons/bootstrap';
-import * as datetimeUtility from '~/lib/utils/datetime_utility';
describe('Date time utils', () => {
describe('timeFor', () => {
diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
index 5ff1b961fc9..fb612f17669 100644
--- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js
+++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
@@ -1,10 +1,10 @@
import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
-import Todos from '~/pages/dashboard/todos/index/todos';
import '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
import { addDelimiter } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility';
+import Todos from '~/pages/dashboard/todos/index/todos';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrl'),
diff --git a/spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap
deleted file mode 100644
index 2fd1fd6a04e..00000000000
--- a/spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap
+++ /dev/null
@@ -1,67 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`IntegrationView component should render IntegrationView properly 1`] = `
-<div
- name="sourcegraph"
->
- <label
- class="label-bold"
- >
-
- Foo
-
- </label>
-
- <gl-link-stub
- class="has-tooltip"
- href="http://foo.com/help"
- title="More information"
- >
- <gl-icon-stub
- class="vertical-align-middle"
- name="question-o"
- size="16"
- />
- </gl-link-stub>
-
- <div
- class="form-group form-check"
- data-testid="profile-preferences-integration-form-group"
- >
- <input
- data-testid="profile-preferences-integration-hidden-field"
- name="user[foo_enabled]"
- type="hidden"
- value="0"
- />
-
- <input
- class="form-check-input"
- data-testid="profile-preferences-integration-checkbox"
- id="user_foo_enabled"
- name="user[foo_enabled]"
- type="checkbox"
- value="1"
- />
-
- <label
- class="form-check-label"
- for="user_foo_enabled"
- >
-
- Enable foo
-
- </label>
-
- <gl-form-text-stub
- tag="div"
- textvariant="muted"
- >
- <integration-help-text-stub
- message="Click %{linkStart}Foo%{linkEnd}!"
- messageurl="http://foo.com"
- />
- </gl-form-text-stub>
- </div>
-</div>
-`;
diff --git a/spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap
deleted file mode 100644
index 4df92cf86a5..00000000000
--- a/spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap
+++ /dev/null
@@ -1,51 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`ProfilePreferences component should render ProfilePreferences properly 1`] = `
-<div
- class="row gl-mt-3 js-preferences-form"
->
- <div
- class="col-sm-12"
- >
- <hr
- data-testid="profile-preferences-integrations-rule"
- />
- </div>
-
- <div
- class="col-lg-4 profile-settings-sidebar"
- >
- <h4
- class="gl-mt-0"
- data-testid="profile-preferences-integrations-heading"
- >
-
- Integrations
-
- </h4>
-
- <p>
-
- Customize integrations with third party services.
-
- </p>
- </div>
-
- <div
- class="col-lg-8"
- >
- <integration-view-stub
- config="[object Object]"
- helplink="http://foo.com/help"
- message="Click %{linkStart}Foo%{linkEnd}!"
- messageurl="http://foo.com"
- />
- <integration-view-stub
- config="[object Object]"
- helplink="http://bar.com/help"
- message="Click %{linkStart}Bar%{linkEnd}!"
- messageurl="http://bar.com"
- />
- </div>
-</div>
-`;
diff --git a/spec/frontend/profile/preferences/components/integration_view_spec.js b/spec/frontend/profile/preferences/components/integration_view_spec.js
index 7a303ae5d3a..6ab0c70298c 100644
--- a/spec/frontend/profile/preferences/components/integration_view_spec.js
+++ b/spec/frontend/profile/preferences/components/integration_view_spec.js
@@ -115,10 +115,4 @@ describe('IntegrationView component', () => {
expect(findFormGroupLabel().text()).toBe('Enable foo');
});
-
- it('should render IntegrationView properly', () => {
- wrapper = createComponent();
-
- expect(wrapper.element).toMatchSnapshot();
- });
});
diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
index 00ed51694ed..82c41178410 100644
--- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js
+++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
@@ -1,27 +1,58 @@
+import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import IntegrationView from '~/profile/preferences/components/integration_view.vue';
import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue';
-import { integrationViews, userFields } from '../mock_data';
+import { i18n } from '~/profile/preferences/constants';
+import { integrationViews, userFields, bodyClasses } from '../mock_data';
+
+const expectedUrl = '/foo';
describe('ProfilePreferences component', () => {
let wrapper;
const defaultProvide = {
integrationViews: [],
userFields,
+ bodyClasses,
+ themes: [{ id: 1, css_class: 'foo' }],
+ profilePreferencesPath: '/update-profile',
+ formEl: document.createElement('form'),
};
function createComponent(options = {}) {
- const { props = {}, provide = {} } = options;
- return shallowMount(ProfilePreferences, {
- provide: {
- ...defaultProvide,
- ...provide,
- },
- propsData: props,
- });
+ const { props = {}, provide = {}, attachTo } = options;
+ return extendedWrapper(
+ shallowMount(ProfilePreferences, {
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ propsData: props,
+ attachTo,
+ }),
+ );
+ }
+
+ function findIntegrationsDivider() {
+ return wrapper.findByTestId('profile-preferences-integrations-rule');
+ }
+
+ function findIntegrationsHeading() {
+ return wrapper.findByTestId('profile-preferences-integrations-heading');
+ }
+
+ function findSubmitButton() {
+ return wrapper.findComponent(GlButton);
+ }
+
+ function findFlashError() {
+ return document.querySelector('.flash-container .flash-text');
}
+ beforeEach(() => {
+ setFixtures('<div class="flash-container"></div>');
+ });
+
afterEach(() => {
wrapper.destroy();
wrapper = null;
@@ -30,8 +61,8 @@ describe('ProfilePreferences component', () => {
it('should not render Integrations section', () => {
wrapper = createComponent();
const views = wrapper.findAll(IntegrationView);
- const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]');
- const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]');
+ const divider = findIntegrationsDivider();
+ const heading = findIntegrationsHeading();
expect(divider.exists()).toBe(false);
expect(heading.exists()).toBe(false);
@@ -40,8 +71,8 @@ describe('ProfilePreferences component', () => {
it('should render Integration section', () => {
wrapper = createComponent({ provide: { integrationViews } });
- const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]');
- const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]');
+ const divider = findIntegrationsDivider();
+ const heading = findIntegrationsHeading();
const views = wrapper.findAll(IntegrationView);
expect(divider.exists()).toBe(true);
@@ -49,9 +80,84 @@ describe('ProfilePreferences component', () => {
expect(views).toHaveLength(integrationViews.length);
});
- it('should render ProfilePreferences properly', () => {
- wrapper = createComponent({ provide: { integrationViews } });
+ describe('form submit', () => {
+ let form;
+
+ beforeEach(() => {
+ const div = document.createElement('div');
+ div.classList.add('container-fluid');
+ document.body.appendChild(div);
+ document.body.classList.add('content-wrapper');
+
+ form = document.createElement('form');
+ form.setAttribute('url', expectedUrl);
+ form.setAttribute('method', 'put');
+
+ const input = document.createElement('input');
+ input.setAttribute('name', 'user[theme_id]');
+ input.setAttribute('type', 'radio');
+ input.setAttribute('value', '1');
+ input.setAttribute('checked', 'checked');
+ form.appendChild(input);
+
+ wrapper = createComponent({ provide: { formEl: form }, attachTo: document.body });
+
+ const beforeSendEvent = new CustomEvent('ajax:beforeSend');
+ form.dispatchEvent(beforeSendEvent);
+ });
- expect(wrapper.element).toMatchSnapshot();
+ it('disables the submit button', async () => {
+ await wrapper.vm.$nextTick();
+ const button = findSubmitButton();
+ expect(button.props('disabled')).toBe(true);
+ });
+
+ it('success re-enables the submit button', async () => {
+ const successEvent = new CustomEvent('ajax:success');
+ form.dispatchEvent(successEvent);
+
+ await wrapper.vm.$nextTick();
+ const button = findSubmitButton();
+ expect(button.props('disabled')).toBe(false);
+ });
+
+ it('error re-enables the submit button', async () => {
+ const errorEvent = new CustomEvent('ajax:error');
+ form.dispatchEvent(errorEvent);
+
+ await wrapper.vm.$nextTick();
+ const button = findSubmitButton();
+ expect(button.props('disabled')).toBe(false);
+ });
+
+ it('displays the default success message', () => {
+ const successEvent = new CustomEvent('ajax:success');
+ form.dispatchEvent(successEvent);
+
+ expect(findFlashError().innerText.trim()).toEqual(i18n.defaultSuccess);
+ });
+
+ it('displays the custom success message', () => {
+ const message = 'foo';
+ const successEvent = new CustomEvent('ajax:success', { detail: [{ message }] });
+ form.dispatchEvent(successEvent);
+
+ expect(findFlashError().innerText.trim()).toEqual(message);
+ });
+
+ it('displays the default error message', () => {
+ const errorEvent = new CustomEvent('ajax:error');
+ form.dispatchEvent(errorEvent);
+
+ expect(findFlashError().innerText.trim()).toEqual(i18n.defaultError);
+ });
+
+ it('displays the custom error message', () => {
+ const message = 'bar';
+ const errorEvent = new CustomEvent('ajax:error', { detail: [{ message }] });
+ form.dispatchEvent(errorEvent);
+
+ expect(findFlashError().innerText.trim()).toEqual(message);
+ });
});
});
diff --git a/spec/frontend/profile/preferences/mock_data.js b/spec/frontend/profile/preferences/mock_data.js
index d07d5f565dc..ce33fc79a39 100644
--- a/spec/frontend/profile/preferences/mock_data.js
+++ b/spec/frontend/profile/preferences/mock_data.js
@@ -16,3 +16,5 @@ export const integrationViews = [
export const userFields = {
foo_enabled: true,
};
+
+export const bodyClasses = 'ui-light-indigo ui-light gl-dark';
diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js
index fa808ad39d8..a9fbe0fe552 100644
--- a/spec/frontend/search_autocomplete_spec.js
+++ b/spec/frontend/search_autocomplete_spec.js
@@ -1,11 +1,10 @@
/* eslint-disable no-unused-expressions, consistent-return, no-param-reassign, default-case, no-return-assign */
-
import AxiosMockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import axios from '~/lib/utils/axios_utils';
import initSearchAutocomplete from '~/search_autocomplete';
import '~/lib/utils/common_utils';
-import axios from '~/lib/utils/axios_utils';
describe('Search autocomplete dropdown', () => {
let widget = null;
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 646f6b1f59d..b6b29faef79 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -1,8 +1,7 @@
+import { config as testUtilsConfig } from '@vue/test-utils';
+import * as jqueryMatchers from 'custom-jquery-matchers';
import Vue from 'vue';
import 'jquery';
-
-import * as jqueryMatchers from 'custom-jquery-matchers';
-import { config as testUtilsConfig } from '@vue/test-utils';
import { setGlobalDateToFakeDate } from 'helpers/fake_date';
import Translate from '~/vue_shared/translate';
import { getJSONFixture, loadHTMLFixture, setHTMLFixture } from './__helpers__/fixtures';
diff --git a/spec/frontend_integration/ide/helpers/start.js b/spec/frontend_integration/ide/helpers/start.js
index ed698b40a93..67d490b2b2e 100644
--- a/spec/frontend_integration/ide/helpers/start.js
+++ b/spec/frontend_integration/ide/helpers/start.js
@@ -1,7 +1,7 @@
import { TEST_HOST } from 'helpers/test_constants';
-import extendStore from '~/ide/stores/extend';
import { initIde } from '~/ide';
import Editor from '~/ide/lib/editor';
+import extendStore from '~/ide/stores/extend';
import { IDE_DATASET } from './mock_data';
export default (container, { isRepoEmpty = false, path = '' } = {}) => {
diff --git a/spec/frontend_integration/ide/ide_integration_spec.js b/spec/frontend_integration/ide/ide_integration_spec.js
index 00a73661d14..3ce88de11fe 100644
--- a/spec/frontend_integration/ide/ide_integration_spec.js
+++ b/spec/frontend_integration/ide/ide_integration_spec.js
@@ -1,8 +1,8 @@
-import { waitForText } from 'helpers/wait_for_text';
-import waitForPromises from 'helpers/wait_for_promises';
import { setTestTimeout } from 'helpers/timeout';
-import { useOverclockTimers } from 'test_helpers/utils/overclock_timers';
+import waitForPromises from 'helpers/wait_for_promises';
+import { waitForText } from 'helpers/wait_for_text';
import { createCommitId } from 'test_helpers/factories/commit_id';
+import { useOverclockTimers } from 'test_helpers/utils/overclock_timers';
import * as ideHelper from './helpers/ide_helper';
import startWebIDE from './helpers/start';
diff --git a/spec/frontend_integration/test_helpers/factories/commit.js b/spec/frontend_integration/test_helpers/factories/commit.js
index 09bb5fd589b..196295addbe 100644
--- a/spec/frontend_integration/test_helpers/factories/commit.js
+++ b/spec/frontend_integration/test_helpers/factories/commit.js
@@ -1,5 +1,5 @@
-import { withValues } from '../utils/obj';
import { getCommit } from '../fixtures';
+import { withValues } from '../utils/obj';
import { createCommitId } from './commit_id';
export const createNewCommit = ({ id = createCommitId(), message }, orig = getCommit()) => {
diff --git a/spec/javascripts/fly_out_nav_browser_spec.js b/spec/javascripts/fly_out_nav_browser_spec.js
index f92994594a9..12ea0e262bc 100644
--- a/spec/javascripts/fly_out_nav_browser_spec.js
+++ b/spec/javascripts/fly_out_nav_browser_spec.js
@@ -3,6 +3,7 @@
// see: https://gitlab.com/groups/gitlab-org/-/epics/895#what-if-theres-a-karma-spec-which-is-simply-unmovable-to-jest-ie-it-is-dependent-on-a-running-browser-environment
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
+import { SIDEBAR_COLLAPSED_CLASS } from '~/contextual_sidebar';
import {
calculateTop,
showSubLevelItems,
@@ -19,7 +20,6 @@ import {
setSidebar,
subItemsMouseLeave,
} from '~/fly_out_nav';
-import { SIDEBAR_COLLAPSED_CLASS } from '~/contextual_sidebar';
describe('Fly out sidebar navigation', () => {
let el;
diff --git a/spec/javascripts/monitoring/components/dashboard_resize_browser_spec.js b/spec/javascripts/monitoring/components/dashboard_resize_browser_spec.js
index bbcdc0b879f..ec8d2778c1f 100644
--- a/spec/javascripts/monitoring/components/dashboard_resize_browser_spec.js
+++ b/spec/javascripts/monitoring/components/dashboard_resize_browser_spec.js
@@ -5,14 +5,14 @@
* https://gitlab.com/groups/gitlab-org/-/epics/895#what-if-theres-a-karma-spec-which-is-simply-unmovable-to-jest-ie-it-is-dependent-on-a-running-browser-environment
*/
-import Vue from 'vue';
import { createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
+import Vue from 'vue';
+import axios from '~/lib/utils/axios_utils';
import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores';
-import axios from '~/lib/utils/axios_utils';
-import { mockApiEndpoint } from '../mock_data';
import { metricsDashboardPayload, dashboardProps } from '../fixture_data';
+import { mockApiEndpoint } from '../mock_data';
import { setupStoreWithData } from '../store_utils';
const localVue = createLocalVue();
diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js
index faa07775aaa..bf56a50b67f 100644
--- a/spec/javascripts/test_bundle.js
+++ b/spec/javascripts/test_bundle.js
@@ -2,19 +2,18 @@
jasmine/no-global-setup, no-underscore-dangle, no-console
*/
+import { config as testUtilsConfig } from '@vue/test-utils';
+import jasmineDiff from 'jasmine-diff';
import $ from 'jquery';
import 'core-js/features/set-immediate';
import 'vendor/jasmine-jquery';
import '~/commons';
import Vue from 'vue';
-import jasmineDiff from 'jasmine-diff';
-import { config as testUtilsConfig } from '@vue/test-utils';
-import Translate from '~/vue_shared/translate';
-
import { getDefaultAdapter } from '~/lib/utils/axios_utils';
-import { FIXTURES_PATH, TEST_HOST } from './test_constants';
+import Translate from '~/vue_shared/translate';
import customMatchers from './matchers';
+import { FIXTURES_PATH, TEST_HOST } from './test_constants';
// Tech debt issue TBD
testUtilsConfig.logModifiedComponents = false;
diff --git a/spec/lib/bulk_imports/common/transformers/award_emoji_transformer_spec.rb b/spec/lib/bulk_imports/common/transformers/award_emoji_transformer_spec.rb
new file mode 100644
index 00000000000..5b560a30bf5
--- /dev/null
+++ b/spec/lib/bulk_imports/common/transformers/award_emoji_transformer_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Common::Transformers::AwardEmojiTransformer do
+ describe '#transform' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:bulk_import) { create(:bulk_import) }
+ let_it_be(:entity) { create(:bulk_import_entity, bulk_import: bulk_import, group: group) }
+ let_it_be(:context) { BulkImports::Pipeline::Context.new(entity) }
+
+ let(:hash) do
+ {
+ 'name' => 'thumbs up',
+ 'user' => {
+ 'public_email' => email
+ }
+ }
+ end
+
+ before do
+ group.add_developer(user)
+ end
+
+ shared_examples 'sets user_id and removes user key' do
+ it 'sets found user_id and removes user key' do
+ transformed_hash = subject.transform(context, hash)
+
+ expect(transformed_hash['user']).to be_nil
+ expect(transformed_hash['user_id']).to eq(user.id)
+ end
+ end
+
+ context 'when user can be found by email' do
+ let(:email) { user.email }
+
+ include_examples 'sets user_id and removes user key'
+ end
+
+ context 'when user cannot be found by email' do
+ let(:user) { bulk_import.user }
+ let(:email) { nil }
+
+ include_examples 'sets user_id and removes user key'
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/graphql/get_group_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_group_query_spec.rb
index 78f99c19346..ef46da7062b 100644
--- a/spec/lib/bulk_imports/groups/graphql/get_group_query_spec.rb
+++ b/spec/lib/bulk_imports/groups/graphql/get_group_query_spec.rb
@@ -4,12 +4,13 @@ require 'spec_helper'
RSpec.describe BulkImports::Groups::Graphql::GetGroupQuery do
describe '#variables' do
- let(:entity) { double(source_full_path: 'test') }
+ let(:entity) { double(source_full_path: 'test', bulk_import: nil) }
+ let(:context) { BulkImports::Pipeline::Context.new(entity) }
it 'returns query variables based on entity information' do
expected = { full_path: entity.source_full_path }
- expect(described_class.variables(entity)).to eq(expected)
+ expect(described_class.variables(context)).to eq(expected)
end
end
diff --git a/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb
index 2d8f4fb399a..247da200d68 100644
--- a/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb
+++ b/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb
@@ -4,12 +4,13 @@ require 'spec_helper'
RSpec.describe BulkImports::Groups::Graphql::GetLabelsQuery do
describe '#variables' do
- let(:entity) { double(source_full_path: 'test', next_page_for: 'next_page') }
+ let(:entity) { double(source_full_path: 'test', next_page_for: 'next_page', bulk_import: nil) }
+ let(:context) { BulkImports::Pipeline::Context.new(entity) }
it 'returns query variables based on entity information' do
expected = { full_path: entity.source_full_path, cursor: entity.next_page_for }
- expect(described_class.variables(entity)).to eq(expected)
+ expect(described_class.variables(context)).to eq(expected)
end
end
diff --git a/spec/lib/bulk_imports/groups/graphql/get_members_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_members_query_spec.rb
index 4bbd60d4970..5d05f5a2d30 100644
--- a/spec/lib/bulk_imports/groups/graphql/get_members_query_spec.rb
+++ b/spec/lib/bulk_imports/groups/graphql/get_members_query_spec.rb
@@ -5,11 +5,12 @@ require 'spec_helper'
RSpec.describe BulkImports::Groups::Graphql::GetMembersQuery do
it 'has a valid query' do
entity = create(:bulk_import_entity)
+ context = BulkImports::Pipeline::Context.new(entity)
query = GraphQL::Query.new(
GitlabSchema,
described_class.to_s,
- variables: described_class.variables(entity)
+ variables: described_class.variables(context)
)
result = GitlabSchema.static_validator.validate(query)
diff --git a/spec/lib/bulk_imports/importers/group_importer_spec.rb b/spec/lib/bulk_imports/importers/group_importer_spec.rb
index 83d29bb051d..b4fdb7b5e5b 100644
--- a/spec/lib/bulk_imports/importers/group_importer_spec.rb
+++ b/spec/lib/bulk_imports/importers/group_importer_spec.rb
@@ -4,8 +4,9 @@ require 'spec_helper'
RSpec.describe BulkImports::Importers::GroupImporter do
let(:user) { create(:user) }
+ let(:group) { create(:group) }
let(:bulk_import) { create(:bulk_import) }
- let(:bulk_import_entity) { create(:bulk_import_entity, :started, bulk_import: bulk_import) }
+ let(:bulk_import_entity) { create(:bulk_import_entity, :started, bulk_import: bulk_import, group: group) }
let(:bulk_import_configuration) { create(:bulk_import_configuration, bulk_import: bulk_import) }
let(:context) { BulkImports::Pipeline::Context.new(bulk_import_entity) }
@@ -21,7 +22,11 @@ RSpec.describe BulkImports::Importers::GroupImporter do
expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::MembersPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::LabelsPipeline, context: context
- expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context) if Gitlab.ee?
+
+ if Gitlab.ee?
+ expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context)
+ expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicAwardEmojiPipeline'.constantize, context: context)
+ end
subject.execute
@@ -29,7 +34,7 @@ RSpec.describe BulkImports::Importers::GroupImporter do
end
context 'when failed' do
- let(:bulk_import_entity) { create(:bulk_import_entity, :failed, bulk_import: bulk_import) }
+ let(:bulk_import_entity) { create(:bulk_import_entity, :failed, bulk_import: bulk_import, group: group) }
it 'does not transition entity to finished state' do
allow(bulk_import_entity).to receive(:start!)
diff --git a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb
index d7d32123975..3804dc52a77 100644
--- a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb
+++ b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb
@@ -11,6 +11,41 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do
expect { |b| subject.observe(migration, &b) }.to yield_control
end
+ context 'behavior with observers' do
+ subject { described_class.new(observers).observe(migration) {} }
+
+ let(:observers) { [observer] }
+ let(:observer) { instance_double('Gitlab::Database::Migrations::Observers::MigrationObserver', before: nil, after: nil, record: nil) }
+
+ it 'calls #before, #after, #record on given observers' do
+ expect(observer).to receive(:before).ordered
+ expect(observer).to receive(:after).ordered
+ expect(observer).to receive(:record).ordered do |observation|
+ expect(observation.migration).to eq(migration)
+ end
+
+ subject
+ end
+
+ it 'ignores errors coming from observers #before' do
+ expect(observer).to receive(:before).and_raise('some error')
+
+ subject
+ end
+
+ it 'ignores errors coming from observers #after' do
+ expect(observer).to receive(:after).and_raise('some error')
+
+ subject
+ end
+
+ it 'ignores errors coming from observers #record' do
+ expect(observer).to receive(:record).and_raise('some error')
+
+ subject
+ end
+ end
+
context 'on successful execution' do
subject { described_class.new.observe(migration) {} }
diff --git a/spec/lib/gitlab/database/migrations/observers/total_database_size_change_spec.rb b/spec/lib/gitlab/database/migrations/observers/total_database_size_change_spec.rb
new file mode 100644
index 00000000000..73466471944
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/observers/total_database_size_change_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::Observers::TotalDatabaseSizeChange do
+ subject { described_class.new }
+
+ let(:observation) { Gitlab::Database::Migrations::Observation.new }
+ let(:connection) { ActiveRecord::Base.connection }
+ let(:query) { 'select pg_database_size(current_database())' }
+
+ it 'records the size change' do
+ expect(connection).to receive(:execute).with(query).once.and_return([{ 'pg_database_size' => 1024 }])
+ expect(connection).to receive(:execute).with(query).once.and_return([{ 'pg_database_size' => 256 }])
+
+ subject.before
+ subject.after
+ subject.record(observation)
+
+ expect(observation.total_database_size_change).to eq(256 - 1024)
+ end
+
+ context 'out of order calls' do
+ before do
+ allow(connection).to receive(:execute).with(query).and_return([{ 'pg_database_size' => 1024 }])
+ end
+
+ it 'does not record anything if before size is unknown' do
+ subject.after
+
+ expect { subject.record(observation) }.not_to change { observation.total_database_size_change }
+ end
+
+ it 'does not record anything if after size is unknown' do
+ subject.before
+
+ expect { subject.record(observation) }.not_to change { observation.total_database_size_change }
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 2ce6dad1c61..860c015e166 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -2831,6 +2831,79 @@ RSpec.describe User do
end
end
+ describe '#following?' do
+ it 'check if following another user' do
+ user = create :user
+ followee1 = create :user
+
+ expect(user.follow(followee1)).to be_truthy
+
+ expect(user.following?(followee1)).to be_truthy
+
+ expect(user.unfollow(followee1)).to be_truthy
+
+ expect(user.following?(followee1)).to be_falsey
+ end
+ end
+
+ describe '#follow' do
+ it 'follow another user' do
+ user = create :user
+ followee1 = create :user
+ followee2 = create :user
+
+ expect(user.followees).to be_empty
+
+ expect(user.follow(followee1)).to be_truthy
+ expect(user.follow(followee1)).to be_falsey
+
+ expect(user.followees).to contain_exactly(followee1)
+
+ expect(user.follow(followee2)).to be_truthy
+ expect(user.follow(followee2)).to be_falsey
+
+ expect(user.followees).to contain_exactly(followee1, followee2)
+ end
+
+ it 'follow itself is not possible' do
+ user = create :user
+
+ expect(user.followees).to be_empty
+
+ expect(user.follow(user)).to be_falsey
+
+ expect(user.followees).to be_empty
+ end
+ end
+
+ describe '#unfollow' do
+ it 'unfollow another user' do
+ user = create :user
+ followee1 = create :user
+ followee2 = create :user
+
+ expect(user.followees).to be_empty
+
+ expect(user.follow(followee1)).to be_truthy
+ expect(user.follow(followee1)).to be_falsey
+
+ expect(user.follow(followee2)).to be_truthy
+ expect(user.follow(followee2)).to be_falsey
+
+ expect(user.followees).to contain_exactly(followee1, followee2)
+
+ expect(user.unfollow(followee1)).to be_truthy
+ expect(user.unfollow(followee1)).to be_falsey
+
+ expect(user.followees).to contain_exactly(followee2)
+
+ expect(user.unfollow(followee2)).to be_truthy
+ expect(user.unfollow(followee2)).to be_falsey
+
+ expect(user.followees).to be_empty
+ end
+ end
+
describe '.find_by_private_commit_email' do
context 'with email' do
let_it_be(:user) { create(:user) }
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index bd8e4a59195..d70a8bd692d 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -652,6 +652,34 @@ RSpec.describe API::Users do
expect(response).to match_response_schema('public_api/v4/user/basic')
expect(json_response.keys).not_to include 'created_at'
end
+
+ it "returns the `followers` field for public users" do
+ get api("/users/#{user.id}")
+
+ expect(response).to match_response_schema('public_api/v4/user/basic')
+ expect(json_response.keys).to include 'followers'
+ end
+
+ it "does not return the `followers` field for private users" do
+ get api("/users/#{private_user.id}")
+
+ expect(response).to match_response_schema('public_api/v4/user/basic')
+ expect(json_response.keys).not_to include 'followers'
+ end
+
+ it "returns the `following` field for public users" do
+ get api("/users/#{user.id}")
+
+ expect(response).to match_response_schema('public_api/v4/user/basic')
+ expect(json_response.keys).to include 'following'
+ end
+
+ it "does not return the `following` field for private users" do
+ get api("/users/#{private_user.id}")
+
+ expect(response).to match_response_schema('public_api/v4/user/basic')
+ expect(json_response.keys).not_to include 'following'
+ end
end
it "returns a 404 error if user id not found" do
@@ -688,6 +716,128 @@ RSpec.describe API::Users do
end
end
+ describe 'POST /users/:id/follow' do
+ let(:followee) { create(:user) }
+
+ context 'on an unfollowed user' do
+ it 'follows the user' do
+ post api("/users/#{followee.id}/follow", user)
+
+ expect(user.followees).to contain_exactly(followee)
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'on a followed user' do
+ before do
+ user.follow(followee)
+ end
+
+ it 'does not change following' do
+ post api("/users/#{followee.id}/follow", user)
+
+ expect(user.followees).to contain_exactly(followee)
+ expect(response).to have_gitlab_http_status(:not_modified)
+ end
+ end
+ end
+
+ describe 'POST /users/:id/unfollow' do
+ let(:followee) { create(:user) }
+
+ context 'on a followed user' do
+ before do
+ user.follow(followee)
+ end
+
+ it 'unfollow the user' do
+ post api("/users/#{followee.id}/unfollow", user)
+
+ expect(user.followees).to be_empty
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'on an unfollowed user' do
+ it 'does not change following' do
+ post api("/users/#{followee.id}/unfollow", user)
+
+ expect(user.followees).to be_empty
+ expect(response).to have_gitlab_http_status(:not_modified)
+ end
+ end
+ end
+
+ describe 'GET /users/:id/followers' do
+ let(:follower) { create(:user) }
+
+ context 'user has followers' do
+ it 'lists followers' do
+ follower.follow(user)
+
+ get api("/users/#{user.id}/followers", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ end
+
+ it 'do not lists followers if profile is private' do
+ follower.follow(private_user)
+
+ get api("/users/#{private_user.id}/followers", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+ end
+
+ context 'user does not have any follower' do
+ it 'does list nothing' do
+ get api("/users/#{user.id}/followers", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_empty
+ end
+ end
+ end
+
+ describe 'GET /users/:id/following' do
+ let(:followee) { create(:user) }
+
+ context 'user has followers' do
+ it 'lists following user' do
+ user.follow(followee)
+
+ get api("/users/#{user.id}/following", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ end
+
+ it 'do not lists following user if profile is private' do
+ user.follow(private_user)
+
+ get api("/users/#{private_user.id}/following", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+ end
+
+ context 'user does not have any follower' do
+ it 'does list nothing' do
+ get api("/users/#{user.id}/following", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_empty
+ end
+ end
+ end
+
describe "POST /users" do
it "creates user" do
expect do
diff --git a/spec/services/ci/daily_build_group_report_result_service_spec.rb b/spec/services/ci/daily_build_group_report_result_service_spec.rb
index a5e8f1e2b63..e878e55454d 100644
--- a/spec/services/ci/daily_build_group_report_result_service_spec.rb
+++ b/spec/services/ci/daily_build_group_report_result_service_spec.rb
@@ -3,17 +3,13 @@
require 'spec_helper'
RSpec.describe Ci::DailyBuildGroupReportResultService, '#execute' do
- let!(:pipeline) { create(:ci_pipeline, created_at: '2020-02-06 00:01:10') }
- let!(:rspec_job) { create(:ci_build, pipeline: pipeline, name: '3/3 rspec', coverage: 80) }
- let!(:karma_job) { create(:ci_build, pipeline: pipeline, name: '2/2 karma', coverage: 90) }
- let!(:extra_job) { create(:ci_build, pipeline: pipeline, name: 'extra', coverage: nil) }
- let!(:group) { create(:group, :private) }
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: create(:project, group: group), created_at: '2020-02-06 00:01:10') }
+ let_it_be(:rspec_job) { create(:ci_build, pipeline: pipeline, name: '3/3 rspec', coverage: 80) }
+ let_it_be(:karma_job) { create(:ci_build, pipeline: pipeline, name: '2/2 karma', coverage: 90) }
+ let_it_be(:extra_job) { create(:ci_build, pipeline: pipeline, name: 'extra', coverage: nil) }
let(:coverages) { Ci::DailyBuildGroupReportResult.all }
- before do
- pipeline.project.group = group
- end
-
it 'creates daily code coverage record for each job in the pipeline that has coverage value' do
described_class.new.execute(pipeline)
@@ -25,7 +21,7 @@ RSpec.describe Ci::DailyBuildGroupReportResultService, '#execute' do
group_name: rspec_job.group_name,
data: { 'coverage' => rspec_job.coverage },
date: pipeline.created_at.to_date,
- group_id: pipeline.project.group.id
+ group_id: group.id
)
end
@@ -37,7 +33,7 @@ RSpec.describe Ci::DailyBuildGroupReportResultService, '#execute' do
group_name: karma_job.group_name,
data: { 'coverage' => karma_job.coverage },
date: pipeline.created_at.to_date,
- group_id: pipeline.project.group.id
+ group_id: group.id
)
end
@@ -162,10 +158,6 @@ RSpec.describe Ci::DailyBuildGroupReportResultService, '#execute' do
let!(:some_job) { create(:ci_build, pipeline: new_pipeline, name: 'foo') }
- before do
- new_pipeline.project.group = group
- end
-
it 'does nothing' do
expect { described_class.new.execute(new_pipeline) }.not_to raise_error
end