diff options
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 Binary files differnew file mode 100644 index 00000000000..7f54f17821c --- /dev/null +++ b/doc/user/img/activity_followed_users_v13_9.png 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 Binary files differnew file mode 100644 index 00000000000..85d54ff3aad --- /dev/null +++ b/doc/user/profile/img/profile_following_v13_9.png 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 |