diff options
Diffstat (limited to 'app')
38 files changed, 304 insertions, 129 deletions
diff --git a/app/assets/javascripts/actioncable_connection_monitor.js b/app/assets/javascripts/actioncable_connection_monitor.js new file mode 100644 index 00000000000..fc4e436c7fb --- /dev/null +++ b/app/assets/javascripts/actioncable_connection_monitor.js @@ -0,0 +1,142 @@ +/* eslint-disable no-restricted-globals */ + +import { logger } from '@rails/actioncable'; + +// This is based on https://github.com/rails/rails/blob/5a477890c809d4a17dc0dede43c6b8cef81d8175/actioncable/app/javascript/action_cable/connection_monitor.js +// so that we can take advantage of the improved reconnection logic. We can remove this once we upgrade @rails/actioncable to a version that includes this. + +// Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting +// revival reconnections if things go astray. Internal class, not intended for direct user manipulation. + +const now = () => new Date().getTime(); + +const secondsSince = (time) => (now() - time) / 1000; +class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; + } + + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener('visibilitychange', this.visibilityDidChange); + logger.log( + `ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`, + ); + } + } + + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener('visibilitychange', this.visibilityDidChange); + logger.log('ConnectionMonitor stopped'); + } + } + + isRunning() { + return this.startedAt && !this.stoppedAt; + } + + recordPing() { + this.pingedAt = now(); + } + + recordConnect() { + this.reconnectAttempts = 0; + this.recordPing(); + delete this.disconnectedAt; + logger.log('ConnectionMonitor recorded connect'); + } + + recordDisconnect() { + this.disconnectedAt = now(); + logger.log('ConnectionMonitor recorded disconnect'); + } + + // Private + + startPolling() { + this.stopPolling(); + this.poll(); + } + + stopPolling() { + clearTimeout(this.pollTimeout); + } + + poll() { + this.pollTimeout = setTimeout(() => { + this.reconnectIfStale(); + this.poll(); + }, this.getPollInterval()); + } + + getPollInterval() { + const { staleThreshold, reconnectionBackoffRate } = this.constructor; + const backoff = (1 + reconnectionBackoffRate) ** Math.min(this.reconnectAttempts, 10); + const jitterMax = this.reconnectAttempts === 0 ? 1.0 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1000 * backoff * (1 + jitter); + } + + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log( + `ConnectionMonitor detected stale connection. reconnectAttempts = ${ + this.reconnectAttempts + }, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${ + this.constructor.staleThreshold + } s`, + ); + this.reconnectAttempts += 1; + if (this.disconnectedRecently()) { + logger.log( + `ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince( + this.disconnectedAt, + )} s`, + ); + } else { + logger.log('ConnectionMonitor reopening'); + this.connection.reopen(); + } + } + } + + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + + disconnectedRecently() { + return ( + this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold + ); + } + + visibilityDidChange() { + if (document.visibilityState === 'visible') { + setTimeout(() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log( + `ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`, + ); + this.connection.reopen(); + } + }, 200); + } + } +} + +ConnectionMonitor.staleThreshold = 6; // Server::Connections::BEAT_INTERVAL * 2 (missed two pings) +ConnectionMonitor.reconnectionBackoffRate = 0.15; + +export default ConnectionMonitor; diff --git a/app/assets/javascripts/actioncable_consumer.js b/app/assets/javascripts/actioncable_consumer.js index 5658ffc1a38..aeb61e61a3d 100644 --- a/app/assets/javascripts/actioncable_consumer.js +++ b/app/assets/javascripts/actioncable_consumer.js @@ -1,3 +1,10 @@ import { createConsumer } from '@rails/actioncable'; +import ConnectionMonitor from './actioncable_connection_monitor'; -export default createConsumer(); +const consumer = createConsumer(); + +if (consumer.connection) { + consumer.connection.monitor = new ConnectionMonitor(consumer.connection); +} + +export default consumer; diff --git a/app/assets/javascripts/admin/users/components/user_avatar.vue b/app/assets/javascripts/admin/users/components/user_avatar.vue index ff0e91fcb8f..ce22595609d 100644 --- a/app/assets/javascripts/admin/users/components/user_avatar.vue +++ b/app/assets/javascripts/admin/users/components/user_avatar.vue @@ -1,5 +1,5 @@ <script> -import { GlAvatarLink, GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { truncate } from '~/lib/utils/text_utility'; import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from '../constants'; @@ -8,7 +8,6 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - GlAvatarLink, GlAvatarLabeled, GlBadge, GlIcon, @@ -27,6 +26,11 @@ export default { adminUserHref() { return this.adminUserPath.replace('id', this.user.username); }, + adminUserMailto() { + // NOTE: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/require-i18n-strings + return `mailto:${this.user.email}`; + }, userNoteShort() { return truncate(this.user.note, LENGTH_OF_USER_NOTE_TOOLTIP); }, @@ -36,10 +40,9 @@ export default { </script> <template> - <gl-avatar-link + <div v-if="user" - class="js-user-link" - :href="adminUserHref" + class="js-user-link gl-display-inline-block" :data-user-id="user.id" :data-username="user.username" > @@ -48,6 +51,8 @@ export default { :src="user.avatarUrl" :label="user.name" :sub-label="user.email" + :label-link="adminUserHref" + :sub-label-link="adminUserMailto" > <template #meta> <div v-if="user.note" class="gl-text-gray-500 gl-p-1"> @@ -60,5 +65,5 @@ export default { </div> </template> </gl-avatar-labeled> - </gl-avatar-link> + </div> </template> diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue index ea68df9ce12..85fca589279 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue @@ -13,8 +13,8 @@ export default { </script> <template> - <span class="gl-ml-4"> - <gl-button variant="success" @click="setAddColumnFormVisibility(true)" + <span class="gl-ml-3 gl-display-flex gl-align-items-center"> + <gl-button variant="confirm" @click="setAddColumnFormVisibility(true)" >{{ __('Create list') }} </gl-button> </span> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue index a2dbd52369f..64d02dbdc54 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue @@ -10,7 +10,6 @@ import { } from '@gitlab/ui'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import createFlash from '~/flash'; -import { BV_DROPDOWN_HIDE } from '~/lib/utils/constants'; import { __, s__ } from '~/locale'; import projectMilestones from '../../graphql/project_milestones.query.graphql'; @@ -73,21 +72,20 @@ export default { return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone; }, }, - mounted() { - this.$root.$on(BV_DROPDOWN_HIDE, () => { - this.$refs.sidebarItem.collapse(); - }); - }, methods: { ...mapActions(['setActiveIssueMilestone']), handleOpen() { this.edit = true; this.$refs.dropdown.show(); }, + handleClose() { + this.edit = false; + this.$refs.sidebarItem.collapse(); + }, async setMilestone(milestoneId) { this.loading = true; this.searchTitle = ''; - this.$refs.sidebarItem.collapse(); + this.handleClose(); try { const input = { milestoneId, projectPath: this.projectPath }; @@ -116,7 +114,7 @@ export default { :title="$options.i18n.milestone" :loading="loading" @open="handleOpen()" - @close="edit = false" + @close="handleClose" > <template v-if="hasMilestone" #collapsed> <strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong> @@ -126,6 +124,7 @@ export default { :text="dropdownText" :header-text="$options.i18n.assignMilestone" block + @hide="handleClose" > <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" /> <gl-dropdown-item diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue index 59ee47937c9..74805f8a681 100644 --- a/app/assets/javascripts/boards/components/toggle_focus.vue +++ b/app/assets/javascripts/boards/components/toggle_focus.vue @@ -1,11 +1,14 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { __ } from '~/locale'; import { hide } from '~/tooltips'; export default { components: { - GlIcon, + GlButton, + }, + directives: { + GlTooltip, }, props: { issueBoardsContentSelector: { @@ -35,18 +38,15 @@ export default { </script> <template> - <div class="board-extra-actions"> - <a + <div class="board-extra-actions gl-ml-3 gl-display-flex gl-align-items-center"> + <gl-button ref="toggleFocusModeButton" - href="#" - class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn" + v-gl-tooltip + :icon="isFullscreen ? 'minimize' : 'maximize'" + class="js-focus-mode-btn" data-qa-selector="focus_mode_button" - role="button" - :aria-label="$options.i18n.toggleFocusMode" :title="$options.i18n.toggleFocusMode" @click="toggleFocusMode" - > - <gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" /> - </a> + /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index 84ce6674104..84883ead125 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -95,7 +95,12 @@ export default { <p v-if="hasTags" class="build-detail-row" data-testid="job-tags"> <span class="font-weight-bold">{{ __('Tags:') }}</span> - <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{ tag }}</span> + <span + v-for="(tag, i) in job.tags" + :key="i" + class="badge badge-pill badge-primary gl-badge sm" + >{{ tag }}</span + > </p> </div> </template> diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index aa197edd449..2a020a66fd2 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -2,8 +2,7 @@ import $ from 'jquery'; import Sortable from 'sortablejs'; - -import { hide, dispose } from '~/tooltips'; +import { dispose } from '~/tooltips'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; @@ -30,7 +29,6 @@ export default class LabelManager { } bindEvents() { - this.prioritizedLabels.find('.btn-action').on('mousedown', this, this.onButtonActionClick); return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick); } @@ -46,11 +44,6 @@ export default class LabelManager { _this.toggleEmptyState($label, $btn, action); } - onButtonActionClick(e) { - e.stopPropagation(); - hide(e.currentTarget); - } - toggleEmptyState() { this.emptyState.classList.toggle( 'hidden', diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 413e43c638b..16e7645592c 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -12,25 +12,23 @@ import initSearchSettings from '~/search_settings'; import initProjectPermissionsSettings from '../shared/permissions'; import initProjectLoadingSpinner from '../shared/save_project_loader'; -document.addEventListener('DOMContentLoaded', () => { - initFilePickers(); - initConfirmDangerModal(); - initSettingsPanels(); - initProjectDeleteButton(); - mountBadgeSettings(PROJECT_BADGE); +initFilePickers(); +initConfirmDangerModal(); +initSettingsPanels(); +initProjectDeleteButton(); +mountBadgeSettings(PROJECT_BADGE); - new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new - initServiceDesk(); +new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new +initServiceDesk(); - initProjectLoadingSpinner(); - initProjectPermissionsSettings(); - setupTransferEdit('.js-project-transfer-form', 'select.select2'); +initProjectLoadingSpinner(); +initProjectPermissionsSettings(); +setupTransferEdit('.js-project-transfer-form', 'select.select2'); - dirtySubmitFactory( - document.querySelectorAll( - '.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form', - ), - ); +dirtySubmitFactory( + document.querySelectorAll( + '.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form', + ), +); - initSearchSettings(); -}); +initSearchSettings(); diff --git a/app/assets/javascripts/pages/projects/environments/index/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js index 4d5106f6d5f..554ed4f9786 100644 --- a/app/assets/javascripts/pages/projects/environments/index/index.js +++ b/app/assets/javascripts/pages/projects/environments/index/index.js @@ -1,3 +1,3 @@ import initEnvironments from '~/environments/'; -document.addEventListener('DOMContentLoaded', initEnvironments); +initEnvironments(); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index f029b26fa78..2730e0f0b84 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -23,17 +23,15 @@ function mountRemoveMemberModal() { }); } -document.addEventListener('DOMContentLoaded', () => { - groupsSelect(); - memberExpirationDate(); - memberExpirationDate('.js-access-expiration-date-groups'); - mountRemoveMemberModal(); - initInviteMembersModal(); - initInviteMembersTrigger(); +groupsSelect(); +memberExpirationDate(); +memberExpirationDate('.js-access-expiration-date-groups'); +mountRemoveMemberModal(); +initInviteMembersModal(); +initInviteMembersTrigger(); - new Members(); // eslint-disable-line no-new - new UsersSelect(); // eslint-disable-line no-new -}); +new Members(); // eslint-disable-line no-new +new UsersSelect(); // eslint-disable-line no-new if (window.gon.features.vueProjectMembersList) { const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; diff --git a/app/assets/javascripts/pages/projects/tags/show/index.js b/app/assets/javascripts/pages/projects/tags/show/index.js index 651cc05ca4f..6f5406f554f 100644 --- a/app/assets/javascripts/pages/projects/tags/show/index.js +++ b/app/assets/javascripts/pages/projects/tags/show/index.js @@ -1,10 +1,8 @@ import { redirectTo, getBaseURL, stripFinalUrlSegment } from '~/lib/utils/url_utility'; import { initRemoveTag } from '../remove_tag'; -document.addEventListener('DOMContentLoaded', () => { - initRemoveTag({ - onDelete: (path = '') => { - redirectTo(stripFinalUrlSegment([getBaseURL(), path].join(''))); - }, - }); +initRemoveTag({ + onDelete: (path = '') => { + redirectTo(stripFinalUrlSegment([getBaseURL(), path].join(''))); + }, }); diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 4a4cbbdaa70..de4bbb36141 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -109,7 +109,7 @@ export default { <div v-for="(key, keyIndex) in keys" :key="key" - class="break-word gl-text-black-normal" + class="break-word" :class="{ 'mb-3 bold': keyIndex == 0 }" > {{ item[key] }} diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 0b1d7d24d21..71a93701dc4 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -94,8 +94,7 @@ class Projects::NotesController < Projects::ApplicationController def create_rate_limit key = :notes_create - - return unless rate_limiter.throttled?(key, scope: [current_user]) + return unless rate_limiter.throttled?(key, scope: [current_user], users_allowlist: rate_limit_users_allowlist) rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user) render plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests @@ -104,4 +103,8 @@ class Projects::NotesController < Projects::ApplicationController def rate_limiter ::Gitlab::ApplicationRateLimiter end + + def rate_limit_users_allowlist + Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist + end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 463b989c493..a7c7839dc9f 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -9,7 +9,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] before_action do - push_frontend_feature_flag(:vue_project_members_list, @project) + push_frontend_feature_flag(:vue_project_members_list, @project, default_enabled: :yaml) end feature_category :authentication_and_authorization diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb index ad90e6598c1..a157a5abdf2 100644 --- a/app/graphql/mutations/notes/create/base.rb +++ b/app/graphql/mutations/notes/create/base.rb @@ -57,12 +57,18 @@ module Mutations end def verify_rate_limit!(current_user) - rate_limiter, key = ::Gitlab::ApplicationRateLimiter, :notes_create - return unless rate_limiter.throttled?(key, scope: [current_user]) + return unless rate_limit_throttled? raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'This endpoint has been requested too many times. Try again later.' end + + def rate_limit_throttled? + rate_limiter = ::Gitlab::ApplicationRateLimiter + allowlist = Gitlab::CurrentSettings.current_application_settings.notes_create_limit_allowlist + + rate_limiter.throttled?(:notes_create, scope: [current_user], users_allowlist: allowlist) + end end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2a1652cf2ba..8268ab1ad56 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -280,7 +280,7 @@ module ApplicationHelper def page_class class_names = [] - class_names << 'issue-boards-page' if current_controller?(:boards) + class_names << 'issue-boards-page gl-overflow-hidden' if current_controller?(:boards) class_names << 'environment-logs-page' if current_controller?(:logs) class_names << 'with-performance-bar' if performance_bar_enabled? class_names << system_message_class diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index f92011958dc..b3b90c79076 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -329,6 +329,7 @@ module ApplicationSettingsHelper :email_restrictions, :issues_create_limit, :notes_create_limit, + :notes_create_limit_allowlist_raw, :raw_blob_request_limit, :project_import_limit, :project_export_limit, diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index db286005ff4..6d375a19ffb 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -447,6 +447,10 @@ class ApplicationSetting < ApplicationRecord validates :notes_create_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :notes_create_limit_allowlist, + length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, + allow_nil: false + attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 9d99b638af6..e5284d15a49 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -93,7 +93,6 @@ module ApplicationSettingImplementation import_sources: Settings.gitlab['import_sources'], invisible_captcha_enabled: false, issues_create_limit: 300, - notes_create_limit: 300, local_markdown_version: 0, login_recaptcha_protection_enabled: false, max_artifacts_size: Settings.artifacts['max_size'], @@ -101,6 +100,8 @@ module ApplicationSettingImplementation max_import_size: 0, minimum_password_length: DEFAULT_MINIMUM_PASSWORD_LENGTH, mirror_available: true, + notes_create_limit: 300, + notes_create_limit_allowlist: [], notify_on_unknown_sign_in: true, outbound_local_requests_whitelist: [], password_authentication_enabled_for_git: true, @@ -270,6 +271,14 @@ module ApplicationSettingImplementation self.protected_paths = strings_to_array(values) end + def notes_create_limit_allowlist_raw + array_to_string(self.notes_create_limit_allowlist) + end + + def notes_create_limit_allowlist_raw=(values) + self.notes_create_limit_allowlist = strings_to_array(values).map(&:downcase) + end + def asset_proxy_allowlist=(values) values = strings_to_array(values) if values.is_a?(String) diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb index f5e52c04944..e2d10cc7e78 100644 --- a/app/models/design_management/design.rb +++ b/app/models/design_management/design.rb @@ -228,17 +228,6 @@ module DesignManagement project end - def immediately_before?(next_design) - return false if next_design.relative_position <= relative_position - - interloper = self.class.on_issue(issue).where( - "relative_position <@ int4range(?, ?, '()')", - *[self, next_design].map(&:relative_position) - ) - - !interloper.exists? - end - def notes_with_associations notes.includes(:author) end diff --git a/app/services/design_management/move_designs_service.rb b/app/services/design_management/move_designs_service.rb index ca715b10351..129f93edf5e 100644 --- a/app/services/design_management/move_designs_service.rb +++ b/app/services/design_management/move_designs_service.rb @@ -16,7 +16,6 @@ module DesignManagement return error(:cannot_move) unless current_user.can?(:move_design, current_design) return error(:no_neighbors) unless neighbors.present? return error(:not_distinct) unless all_distinct? - return error(:not_adjacent) if any_in_gap? return error(:not_same_issue) unless all_same_issue? move_nulls_to_end @@ -54,12 +53,6 @@ module DesignManagement ids.uniq.size == ids.size end - def any_in_gap? - return false unless previous_design&.relative_position && next_design&.relative_position - - !previous_design.immediately_before?(next_design) - end - def all_same_issue? issue.designs.id_in(ids).count == ids.size end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index ffed0a957c2..8cf84e32e85 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -101,8 +101,30 @@ module MergeRequests %w(title description).each do |action| next unless @issuable_changes.key?(action) - Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter + # Track edits to title or description + # + merge_request_activity_counter .public_send("track_#{action}_edit_action".to_sym, user: current_user) # rubocop:disable GitlabSecurity/PublicSend + + # Track changes to Draft/WIP status + # + if action == "title" + old_title, new_title = @issuable_changes["title"] + old_title_wip = MergeRequest.work_in_progress?(old_title) + new_title_wip = MergeRequest.work_in_progress?(new_title) + + if !old_title_wip && new_title_wip + # Marked as Draft/WIP + # + merge_request_activity_counter + .track_marked_as_draft_action(user: current_user) + elsif old_title_wip && !new_title_wip + # Unmarked as Draft/WIP + # + merge_request_activity_counter + .track_unmarked_as_draft_action(user: current_user) + end + end end end diff --git a/app/views/admin/application_settings/_note_limits.html.haml b/app/views/admin/application_settings/_note_limits.html.haml index 3045c967b00..9578da90170 100644 --- a/app/views/admin/application_settings/_note_limits.html.haml +++ b/app/views/admin/application_settings/_note_limits.html.haml @@ -5,5 +5,8 @@ .form-group = f.label :notes_create_limit, _('Max requests per minute per user'), class: 'label-bold' = f.number_field :notes_create_limit, class: 'form-control gl-form-input' + .form-group + = f.label :notes_create_limit_allowlist, _('List of users to be excluded from the limit'), class: 'label-bold' + = f.text_area :notes_create_limit_allowlist_raw, placeholder: 'username1, username2', class: 'form-control gl-form-input', rows: 5 = f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index e4517dca6d0..c2599238bce 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -31,7 +31,7 @@ = render 'shared/group_tips' .form-actions = f.submit _('Create group'), class: "gl-button btn btn-success" - = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-cancel" + = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-default btn-cancel" - else .form-actions diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 67e6e510923..705fd9bbd0f 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -7,7 +7,7 @@ .d-flex.justify-content-between.flex-wrap - providers.each do |provider| - has_icon = provider_has_icon?(provider) - = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "gl-button btn d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}" do + = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}" do - if has_icon = provider_image_tag(provider) %span diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index ece886b3cdd..43e0802ee2a 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -2,7 +2,7 @@ = _("Create an account using:") .d-flex.justify-content-between.flex-wrap - providers.each do |provider| - = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn gl-button gl-display-flex gl-align-items-center gl-text-left gl-mb-2 gl-p-2 omniauth-btn oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do + = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn gl-button btn-default gl-display-flex gl-align-items-center gl-text-left gl-mb-2 gl-p-2 omniauth-btn oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.ml-2 diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml index 3872bbcd062..64860c61082 100644 --- a/app/views/groups/_new_group_fields.html.haml +++ b/app/views/groups/_new_group_fields.html.haml @@ -18,5 +18,5 @@ = render_if_exists 'shared/groups/invite_members' .row .form-actions.col-sm-12 - = f.submit _('Create group'), class: "btn btn-success" - = link_to _('Cancel'), dashboard_groups_path, class: 'btn btn-cancel' + = f.submit _('Create group'), class: "btn gl-button btn-success" + = link_to _('Cancel'), dashboard_groups_path, class: 'btn gl-button btn-default btn-cancel' diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml index c05d42a5846..977116af88f 100644 --- a/app/views/profiles/_email_settings.html.haml +++ b/app/views/profiles/_email_settings.html.haml @@ -4,7 +4,7 @@ - read_only_help_text = readonly ? s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } : user_email_help_text(@user) - help_text = email_change_disabled ? s_("Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO.") % { group_name: @user.managing_group.name } : read_only_help_text -= form.text_field :email, required: true, class: 'input-lg', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled += form.text_field :email, required: true, class: 'input-lg gl-form-input', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled = form.select :public_email, options_for_select(@user.public_verified_emails, selected: @user.public_email), { help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") }, control_class: 'select2 input-lg', disabled: email_change_disabled diff --git a/app/views/profiles/_name.html.haml b/app/views/profiles/_name.html.haml index 87f1634b4f3..aea38bf4c3b 100644 --- a/app/views/profiles/_name.html.haml +++ b/app/views/profiles/_name.html.haml @@ -1,5 +1,5 @@ - if user.read_only_attribute?(:name) - = form.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, + = form.text_field :name, class: 'gl-form-input', required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } - else - = form.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") + = form.text_field :name, class: 'gl-form-input', label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index b1f4966f731..4689fd5272a 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -65,7 +65,7 @@ = status_form.hidden_field :emoji, id: 'js-status-emoji-field' = status_form.text_field :message, id: 'js-status-message-field', - class: 'form-control input-lg', + class: 'form-control gl-form-input input-lg', label: s_("Profiles|Your status"), prepend: emoji_button, append: reset_message_button, @@ -100,20 +100,20 @@ .col-lg-8 .row = render 'profiles/name', form: f, user: @user - = f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' } + = f.text_field :id, class: 'gl-form-input', readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' } = render_if_exists 'profiles/email_settings', form: f - = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") - = f.text_field :linkedin, class: 'input-md', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") - = f.text_field :twitter, class: 'input-md', placeholder: s_("Profiles|@username") - = f.text_field :website_url, class: 'input-lg', placeholder: s_("Profiles|website.com") + = f.text_field :skype, class: 'input-md gl-form-input', placeholder: s_("Profiles|username") + = f.text_field :linkedin, class: 'input-md gl-form-input', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") + = f.text_field :twitter, class: 'input-md gl-form-input', placeholder: s_("Profiles|@username") + = f.text_field :website_url, class: 'input-lg gl-form-input', placeholder: s_("Profiles|website.com") - if @user.read_only_attribute?(:location) - = f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } + = f.text_field :location, class: 'gl-form-input', readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } - else - = f.text_field :location, label: s_('Profiles|Location'), class: 'input-lg', placeholder: s_("Profiles|City, country") - = f.text_field :job_title, class: 'input-md' - = f.text_field :organization, label: s_('Profiles|Organization'), class: 'input-md', help: s_("Profiles|Who you represent or work for") - = f.text_area :bio, label: s_('Profiles|Bio'), rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters") + = f.text_field :location, label: s_('Profiles|Location'), class: 'input-lg gl-form-input', placeholder: s_("Profiles|City, country") + = f.text_field :job_title, class: 'input-md gl-form-input' + = f.text_field :organization, label: s_('Profiles|Organization'), class: 'input-md gl-form-input', help: s_("Profiles|Who you represent or work for") + = f.text_area :bio, class: 'gl-form-input', label: s_('Profiles|Bio'), rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters") %hr %h5= s_("Private profile") .checkbox-icon-inline-wrapper diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml index d1ff52548cd..7eb86e6ba3f 100644 --- a/app/views/projects/_project_templates.html.haml +++ b/app/views/projects/_project_templates.html.haml @@ -1,6 +1,6 @@ - f ||= local_assigns[:f] -.project-templates-buttons.col-sm-12 +.project-templates-buttons %ul.nav-tabs.nav-links.nav.scrolling-tabs %li.built-in-tab %a.nav-link.active{ href: "#built-in", data: { toggle: 'tab'} } diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index b3c209d564b..beb435d268a 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,6 +1,6 @@ - page_title _("Members") - group = @project.group -- vue_project_members_list_enabled = Feature.enabled?(:vue_project_members_list, @project) +- vue_project_members_list_enabled = Feature.enabled?(:vue_project_members_list, @project, default_enabled: :yaml) .js-remove-member-modal .row.gl-mt-3 diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index c70c0572c2b..95d7f075964 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -29,7 +29,7 @@ %ul - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group) %li - %button.js-promote-project-label-button.btn.btn-transparent.btn-action{ disabled: true, type: 'button', + %button.js-promote-project-label-button.btn.btn-transparent{ disabled: true, type: 'button', data: { url: promote_project_label_path(label.project, label), label_title: label.title, label_color: label.color, diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml index 132a951fd34..1a22a66d185 100644 --- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml +++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml @@ -1,5 +1,5 @@ -.dropdown.gl-ml-3#js-add-list - %button.gl-button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data } +.dropdown.gl-display-flex.gl-align-items-center.gl-ml-3#js-add-list + %button.gl-button.btn.btn-confirm.btn-confirm-secondary.js-new-board-list{ type: "button", data: board_list_data } Add list .dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 1ebb160e591..d1e74cc771e 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -193,6 +193,8 @@ .filter-dropdown-container.d-flex.flex-column.flex-md-row - if type == :boards #js-board-labels-toggle + - if current_user + #js-board-epics-swimlanes-toggle .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } - if user_can_admin_list - if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml) @@ -200,9 +202,7 @@ - else = render 'shared/issuable/board_create_list_dropdown', board: board - if @project - #js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } - - if current_user - #js-board-epics-swimlanes-toggle + #js-add-issues-btn{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } #js-toggle-focus-btn - elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown = render 'shared/issuable/sort_dropdown' diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml index 1f05dcf83bc..a867421298b 100644 --- a/app/views/shared/issuable/_sidebar_todo.html.haml +++ b/app/views/shared/issuable/_sidebar_todo.html.haml @@ -12,4 +12,4 @@ data: todo_button_data } %span.issuable-todo-inner.js-issuable-todo-inner< = is_collapsed ? button_icon : button_title - = loading_icon + = loading_icon(css_class: is_collapsed ? '' : 'gl-ml-3') diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index a419e749f35..d2bee57992d 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -8,7 +8,7 @@ = markdown_field(label, :description) .float-right.d-none.d-lg-block - = link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn gl-button btn-default-tertiary btn-action' do + = link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn gl-button btn-default-tertiary' do - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), _('open issue') - = link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn gl-button btn-default-tertiary btn-action' do + = link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn gl-button btn-default-tertiary' do - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), _('closed issue') |