diff options
Diffstat (limited to 'app')
31 files changed, 380 insertions, 123 deletions
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 |