diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-14 18:07:17 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-14 18:07:17 +0300 |
commit | 2aea9a0c91723b8800b016335930c59390cda7c9 (patch) | |
tree | f223b5ef5cf7ee847cfc4875b3c02ec265f91c2d | |
parent | 14a0edee5c04b04b5281f99ce7f6ba75b919dba1 (diff) |
Add latest changes from gitlab-org/gitlab@master
88 files changed, 1776 insertions, 492 deletions
diff --git a/Gemfile.lock b/Gemfile.lock index 542591f657c..6193a196c86 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ PATH remote: gems/activerecord-gitlab specs: - activerecord-gitlab (0.1.0) - activerecord (>= 6.1.7.3) + activerecord-gitlab (0.2.0) + activerecord (>= 7) PATH remote: gems/click_house-client diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index 2721851f03b..96889f0059c 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -8,6 +8,7 @@ import { scrollToElement } from '~/lib/utils/common_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '~/vue_shared/constants'; import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; export default { components: { @@ -80,6 +81,8 @@ export default { async submitReview() { this.isSubmitting = true; + trackSavedUsingEditor(this.$refs.markdownEditor.isContentEditorActive, 'MergeRequest_review'); + try { await this.publishReview(this.noteData); @@ -131,6 +134,7 @@ export default { </template> <div class="common-note-form gfm-form"> <markdown-editor + ref="markdownEditor" v-model="noteData.note" :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" class="js-no-autosize" diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index b4590f37ecd..2168685e703 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -196,7 +196,7 @@ export default { class="gl-ml-3" :registration-token="registrationToken" :type="$options.INSTANCE_TYPE" - right + placement="right" /> </div> </div> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue index cb5b3076301..0154cd2a3ec 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_dropdown.vue @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider, GlIcon } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlDropdownForm, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlIcon, +} from '@gitlab/ui'; import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; @@ -25,10 +31,10 @@ export default { ), }, components: { - GlDropdown, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, GlDropdownForm, - GlDropdownItem, - GlDropdownDivider, GlIcon, RegistrationToken, RunnerInstructionsModal, @@ -74,27 +80,29 @@ export default { onTokenReset(token) { this.currentRegistrationToken = token; - this.$refs.runnerRegistrationDropdown.hide(true); + this.$refs.runnerRegistrationDropdown.close(); + }, + onCopy() { + this.$refs.runnerRegistrationDropdown.close(); }, }, }; </script> <template> - <gl-dropdown + <gl-disclosure-dropdown ref="runnerRegistrationDropdown" - menu-class="gl-w-auto!" + :toggle-text="actionText" toggle-class="gl-px-3!" variant="default" category="tertiary" v-bind="$attrs" + icon="ellipsis_v" + text-sr-only + no-caret > - <template #button-content> - <span class="gl-sr-only">{{ actionText }}</span> - <gl-icon name="ellipsis_v" /> - </template> <gl-dropdown-form class="gl-p-4!"> - <registration-token input-id="token-value" :value="currentRegistrationToken"> + <registration-token input-id="token-value" :value="currentRegistrationToken" @copy="onCopy"> <template #label-description> <gl-icon name="warning" class="gl-text-orange-500" /> <span class="gl-text-secondary"> @@ -103,16 +111,20 @@ export default { </template> </registration-token> </gl-dropdown-form> - <gl-dropdown-divider /> - <gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick"> - {{ $options.i18n.showInstallationInstructions }} - <runner-instructions-modal - ref="runnerInstructionsModal" - :registration-token="currentRegistrationToken" - data-testid="runner-instructions-modal" - /> - </gl-dropdown-item> - <gl-dropdown-divider /> - <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" /> - </gl-dropdown> + <gl-disclosure-dropdown-group bordered> + <gl-disclosure-dropdown-item @action="onShowInstructionsClick"> + <template #list-item> + {{ $options.i18n.showInstallationInstructions }} + <runner-instructions-modal + ref="runnerInstructionsModal" + :registration-token="currentRegistrationToken" + data-testid="runner-instructions-modal" + /> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> + <gl-disclosure-dropdown-group bordered> + <registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" /> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue index b196bccf66f..339c92a427f 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_token.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token.vue @@ -31,6 +31,7 @@ export default { onCopy() { // value already in the clipboard, simply notify the user this.$toast?.show(s__('Runners|Registration token copied!')); + this.$emit('copy'); }, }, I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'), diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue index 6ce88fc54de..47ca3ed6227 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -19,7 +19,7 @@ export default { name: 'RunnerRegistrationTokenReset', i18n, components: { - GlDropdownItem, + GlDisclosureDropdownItem, GlLoadingIcon, GlModal, }, @@ -124,18 +124,20 @@ export default { }; </script> <template> - <gl-dropdown-item v-gl-modal="$options.modalId"> - {{ __('Reset registration token') }} - <gl-modal - size="sm" - :modal-id="$options.modalId" - :action-primary="actionPrimary" - :action-secondary="actionSecondary" - :title="$options.i18n.modalTitle" - @primary="handleModalPrimary" - > - <p>{{ $options.i18n.modalCopy }}</p> - </gl-modal> - <gl-loading-icon v-if="loading" inline /> - </gl-dropdown-item> + <gl-disclosure-dropdown-item v-gl-modal="$options.modalId"> + <template #list-item> + {{ __('Reset registration token') }} + <gl-modal + size="sm" + :modal-id="$options.modalId" + :action-primary="actionPrimary" + :action-secondary="actionSecondary" + :title="$options.i18n.modalTitle" + @primary="handleModalPrimary" + > + <p>{{ $options.i18n.modalCopy }}</p> + </gl-modal> + <gl-loading-icon v-if="loading" inline /> + </template> + </gl-disclosure-dropdown-item> </template> diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 9e69137d382..71584c40a38 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -235,7 +235,7 @@ export default { class="gl-ml-3" :registration-token="registrationToken" :type="$options.GROUP_TYPE" - right + placement="right" /> </div> </div> diff --git a/app/assets/javascripts/design_management/components/design_description/description_form.vue b/app/assets/javascripts/design_management/components/design_description/description_form.vue index 890d7f80f8d..413442074f0 100644 --- a/app/assets/javascripts/design_management/components/design_description/description_form.vue +++ b/app/assets/javascripts/design_management/components/design_description/description_form.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlFormGroup, GlAlert, GlTooltipDirective } from '@gitlab/ui'; - import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -9,7 +8,7 @@ import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { toggleMarkCheckboxes } from '~/behaviors/markdown/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; - +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import updateDesignDescriptionMutation from '../../graphql/mutations/update_design_description.mutation.graphql'; import { UPDATE_DESCRIPTION_ERROR } from '../../utils/error_messages'; @@ -110,6 +109,11 @@ export default { async updateDesignDescription() { this.isSubmitting = true; + if (this.$refs.markdownEditor) { + // eslint-disable-next-line @gitlab/require-i18n-strings + trackSavedUsingEditor(this.$refs.markdownEditor.isContentEditorActive, 'Design'); + } + try { const designDescriptionInput = { description: this.descriptionText, id: this.design.id }; @@ -165,6 +169,7 @@ export default { </gl-alert> </div> <markdown-editor + ref="markdownEditor" :value="descriptionText" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index a8b900afed2..b6e86cc2a60 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -106,7 +106,10 @@ export default { @shown="$emit('shown')" @hidden="$emit('hidden')" > - <template #button-content><slot name="button-content"></slot></template> + <template #button-content> + <slot name="button-content"></slot> + <span class="gl-sr-only">{{ __('Add reaction') }}</span> + </template> <gl-search-box-by-type v-model="searchValue" class="gl-mx-5! gl-mb-2!" diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index a1525ad2bec..1c1acddb90b 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -7,6 +7,9 @@ import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; import UsersSelect from '~/users_select'; import ZenMode from '~/zen_mode'; import { containsSensitiveToken, confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; +import { EDITING_MODE_CONTENT_EDITOR } from '~/vue_shared/constants'; +import { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; @@ -47,6 +50,13 @@ function getFallbackKey() { return ['autosave', document.location.pathname, searchTerm].join('/'); } +function getIssuableType() { + if (document.location.pathname.includes('merge_requests')) return MERGE_REQUEST_NOTEABLE_TYPE; + if (document.location.pathname.includes('issues')) return ISSUE_NOTEABLE_TYPE; + // eslint-disable-next-line @gitlab/require-i18n-strings + return 'Other'; +} + export default class IssuableForm { static addAutosave(map, id, element, searchTerm, fallbackKey) { if (!element) return; @@ -144,6 +154,11 @@ export default class IssuableForm { async handleSubmit(event) { event.preventDefault(); + trackSavedUsingEditor( + localStorage.getItem('gl-markdown-editor-mode') === EDITING_MODE_CONTENT_EDITOR, + getIssuableType(), + ); + const form = event.target; const descriptionText = this.descriptionField().val(); diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index c9769dc9275..a1463d0e911 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -2,6 +2,8 @@ import { __ } from '~/locale'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; +import { ISSUE_NOTEABLE_TYPE } from '~/notes/constants'; import updateMixin from '../../mixins/update'; export default { @@ -55,6 +57,10 @@ export default { focus() { this.$refs.textarea?.focus(); }, + saveIssuable() { + trackSavedUsingEditor(this.$refs.markdownEditor.isContentEditorActive, ISSUE_NOTEABLE_TYPE); + this.updateIssuable(); + }, }, }; </script> @@ -63,6 +69,7 @@ export default { <div class="common-note-form"> <label class="sr-only" for="issue-description">{{ __('Description') }}</label> <markdown-editor + ref="markdownEditor" :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)" class="gl-mt-3" :value="value" @@ -75,8 +82,8 @@ export default { autofocus data-qa-selector="description_field" @input="$emit('input', $event)" - @keydown.meta.enter="updateIssuable" - @keydown.ctrl.enter="updateIssuable" + @keydown.meta.enter="saveIssuable" + @keydown.ctrl.enter="saveIssuable" /> </div> </template> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 97444bb1129..c6d94a3b7b7 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -16,6 +16,7 @@ import { sprintf } from '~/locale'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import * as constants from '../constants'; import eventHub from '../event_hub'; @@ -255,6 +256,11 @@ export default { this.isSubmitting = true; + trackSavedUsingEditor( + this.$refs.markdownEditor.isContentEditorActive, + `${this.noteableType}_${this.noteType}`, + ); + this.saveNote(noteData) .then(() => { this.restartPolling(); diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 47e0ace1ea7..d17db6c6b4c 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -318,6 +318,8 @@ export default { /> <emoji-picker v-if="canAwardEmoji" + v-gl-tooltip + :title="$options.i18n.addReactionLabel" toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary" data-testid="note-emoji-button" @click="setAwardEmoji" @@ -365,7 +367,8 @@ export default { <gl-disclosure-dropdown v-gl-tooltip :title="$options.i18n.moreActionsLabel" - :aria-label="$options.i18n.moreActionsLabel" + :toggle-text="$options.i18n.moreActionsLabel" + text-sr-only icon="ellipsis_v" category="tertiary" placement="right" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index c564951bee7..4e816038539 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -5,6 +5,7 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import glFeaturesFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import eventHub from '../event_hub'; import issuableStateMixin from '../mixins/issuable_state'; import resolvable from '../mixins/resolvable'; @@ -296,6 +297,11 @@ export default { const beforeSubmitDiscussionState = this.discussionResolved; this.isSubmitting = true; + trackSavedUsingEditor( + this.$refs.markdownEditor.isContentEditorActive, + `${this.getNoteableData.noteableType}_note`, + ); + this.$emit( 'handleFormUpdate', this.updatedNoteBody, diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 4f68c7984e8..5bc630c61cb 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -15,8 +15,8 @@ import { setUrlFragment } from '~/lib/utils/url_utility'; import { s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; import { - SAVED_USING_CONTENT_EDITOR_ACTION, WIKI_CONTENT_EDITOR_TRACKING_LABEL, WIKI_FORMAT_LABEL, WIKI_FORMAT_UPDATED_ACTION, @@ -257,9 +257,8 @@ export default { }, trackFormSubmit() { - if (this.isContentEditorActive) { - this.track(SAVED_USING_CONTENT_EDITOR_ACTION); - } + // eslint-disable-next-line @gitlab/require-i18n-strings + trackSavedUsingEditor(this.isContentEditorActive, 'Wiki'); }, trackWikiFormat() { diff --git a/app/assets/javascripts/pages/shared/wikis/constants.js b/app/assets/javascripts/pages/shared/wikis/constants.js index 94d086158f1..3e685292971 100644 --- a/app/assets/javascripts/pages/shared/wikis/constants.js +++ b/app/assets/javascripts/pages/shared/wikis/constants.js @@ -1,5 +1,4 @@ export const CONTENT_EDITOR_LOADED_ACTION = 'content_editor_loaded'; -export const SAVED_USING_CONTENT_EDITOR_ACTION = 'saved_using_content_editor'; export const WIKI_CONTENT_EDITOR_TRACKING_LABEL = 'wiki_content_editor'; export const WIKI_FORMAT_LABEL = 'wiki_format'; export const WIKI_FORMAT_UPDATED_ACTION = 'wiki_format_updated'; diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index fd74ded7dc8..59f03b41144 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -210,7 +210,6 @@ export default { @hidden="setIsMenuOpen(false)" > <template #button-content> - <span class="gl-sr-only">{{ __('Add reaction') }}</span> <span class="reaction-control-icon reaction-control-icon-neutral"> <gl-icon name="slight-smile" /> </span> diff --git a/app/assets/javascripts/vue_shared/components/markdown/tracking.js b/app/assets/javascripts/vue_shared/components/markdown/tracking.js new file mode 100644 index 00000000000..2628054ae5f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/tracking.js @@ -0,0 +1,14 @@ +import Tracking from '~/tracking'; + +export const EDITOR_TRACKING_LABEL = 'editor_tracking'; +export const EDITOR_TYPE_ACTION = 'editor_type_used'; +export const EDITOR_TYPE_PLAIN_TEXT_EDITOR = 'editor_type_plain_text_editor'; +export const EDITOR_TYPE_RICH_TEXT_EDITOR = 'editor_type_rich_text_editor'; + +export const trackSavedUsingEditor = (isRichText, context) => { + Tracking.event(undefined, EDITOR_TYPE_ACTION, { + label: EDITOR_TRACKING_LABEL, + editorType: isRichText ? EDITOR_TYPE_RICH_TEXT_EDITOR : EDITOR_TYPE_PLAIN_TEXT_EDITOR, + context, + }); +}; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 98083fbc72a..9bf6ed45483 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -39,6 +39,12 @@ } .approvers-select { + width: calc(70% - #{$gl-spacing-scale-5}); + + .gl-new-dropdown-toggle { + @include gl-w-full; + } + .dropdown-menu { @include gl-w-full; @include gl-max-w-none; diff --git a/app/controllers/concerns/onboarding/status.rb b/app/controllers/concerns/onboarding/status.rb new file mode 100644 index 00000000000..986f3f17847 --- /dev/null +++ b/app/controllers/concerns/onboarding/status.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Onboarding + class Status + def initialize(user) + @user = user + end + + def continue_full_onboarding? + false + end + + def single_invite? + # If there are more than one member it will mean we have been invited to multiple projects/groups and + # are not able to distinguish which one we should putting the user in after registration + members.count == 1 + end + + def last_invited_member + members.last + end + + def last_invited_member_source + last_invited_member&.source + end + + def invite_with_tasks_to_be_done? + return false if members.empty? + + MemberTask.for_members(members).exists? + end + + private + + attr_reader :user + + def members + @members ||= user.members + end + end +end diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index ec7e7c0b1f0..76f181e3ce8 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -7,10 +7,10 @@ module Registrations include ::Gitlab::Utils::StrongMemoize layout 'minimal' - skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update] - before_action :require_current_user + skip_before_action :required_signup_info, :check_two_factor_requirement helper_method :welcome_update_params + helper_method :onboarding_status feature_category :user_management @@ -26,6 +26,7 @@ module Registrations if result.success? track_event('successfully_submitted_form') successful_update_hooks + redirect_to update_success_path else render :show @@ -34,14 +35,10 @@ module Registrations private - def registering_from_invite?(members) - # If there are more than one member it will mean we have been invited to multiple projects/groups and - # are not able to distinguish which one we should putting the user in after registration - members.count == 1 && members.last.source.present? - end + def authenticate_user! + return if current_user - def require_current_user - return redirect_to new_user_registration_path unless current_user + redirect_to new_user_registration_path end def completed_welcome_step? @@ -53,33 +50,28 @@ module Registrations end def path_for_signed_in_user(user) - stored_location_for(user) || members_activity_path(user.members) - end - - def members_activity_path(members) - return dashboard_projects_path unless members.any? - return dashboard_projects_path unless members.last.source.present? - - members.last.source.activity_path + stored_location_for(user) || last_member_activity_path end # overridden in EE def complete_signup_onboarding? - false + onboarding_status.continue_full_onboarding? end - def invites_with_tasks_to_be_done? - MemberTask.for_members(user_members).exists? + def last_member_activity_path + return dashboard_projects_path unless onboarding_status.last_invited_member_source.present? + + onboarding_status.last_invited_member_source.activity_path end def update_success_path - if invites_with_tasks_to_be_done? + if onboarding_status.invite_with_tasks_to_be_done? issues_dashboard_path(assignee_username: current_user.username) elsif complete_signup_onboarding? # trials/regular registration on .com signup_onboarding_path - elsif registering_from_invite?(user_members) # invites w/o tasks due to order - flash[:notice] = helpers.invite_accepted_notice(user_members.last) - members_activity_path(user_members) + elsif onboarding_status.single_invite? # invites w/o tasks due to order + flash[:notice] = helpers.invite_accepted_notice(onboarding_status.last_invited_member) + onboarding_status.last_invited_member_source.activity_path else # Subscription registrations goes through here as well. # Invites will come here too if there is more than 1. @@ -87,11 +79,6 @@ module Registrations end end - def user_members - current_user.members - end - strong_memoize_attr :user_members - # overridden in EE def successful_update_hooks; end @@ -105,6 +92,11 @@ module Registrations def welcome_update_params {} end + + def onboarding_status + Onboarding::Status.new(current_user) + end + strong_memoize_attr :onboarding_status end end diff --git a/app/graphql/types/ide_type.rb b/app/graphql/types/ide_type.rb new file mode 100644 index 00000000000..34447577f23 --- /dev/null +++ b/app/graphql/types/ide_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + class IdeType < BaseObject + graphql_name 'Ide' + description 'IDE settings and feature flags.' + + authorize :read_user + + field :code_suggestions_enabled, GraphQL::Types::Boolean, null: false, + description: 'Indicates whether AI assisted code suggestions are enabled.' + + def code_suggestions_enabled + object.can?(:access_code_suggestions) + end + end +end diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index 0a67cdf749e..9e5f6810aca 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -202,6 +202,12 @@ module Types null: true, description: 'Pronouns of the user.' + field :ide, + type: Types::IdeType, + null: true, + description: 'IDE settings.', + method: :itself + definition_methods do def resolve_type(object, context) # in the absence of other information, we cannot tell - just default to diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index af804865b8b..08f725de980 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -20,6 +20,7 @@ class PersonalAccessToken < ApplicationRecord serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize belongs_to :user + belongs_to :previous_personal_access_token, class_name: 'PersonalAccessToken' after_initialize :set_default_scopes, if: :persisted? before_save :ensure_token diff --git a/app/services/personal_access_tokens/revoke_token_family_service.rb b/app/services/personal_access_tokens/revoke_token_family_service.rb new file mode 100644 index 00000000000..547ba6c3bdc --- /dev/null +++ b/app/services/personal_access_tokens/revoke_token_family_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module PersonalAccessTokens + class RevokeTokenFamilyService + def initialize(token) + @token = token + end + + def execute + # Despite using #update_all, there should only be a single active token. + # A token family is a chain of rotated tokens. Once rotated, the + # previous token is revoked. + pat_family.active.update_all(revoked: true) + + ServiceResponse.success + end + + private + + attr_reader :token + + def pat_family + # rubocop: disable CodeReuse/ActiveRecord + cte = Gitlab::SQL::RecursiveCTE.new(:personal_access_tokens_cte) + personal_access_token_table = Arel::Table.new(:personal_access_tokens) + + cte << PersonalAccessToken + .where(personal_access_token_table[:previous_personal_access_token_id].eq(token.id)) + cte << PersonalAccessToken + .from([personal_access_token_table, cte.table]) + .where(personal_access_token_table[:previous_personal_access_token_id].eq(cte.table[:id])) + PersonalAccessToken.with.recursive(cte.to_arel).from(cte.alias_to(personal_access_token_table)) + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/app/services/personal_access_tokens/rotate_service.rb b/app/services/personal_access_tokens/rotate_service.rb index 64b0c5c98a9..b765aacef68 100644 --- a/app/services/personal_access_tokens/rotate_service.rb +++ b/app/services/personal_access_tokens/rotate_service.rb @@ -41,6 +41,7 @@ module PersonalAccessTokens def create_token_params(token) { name: token.name, + previous_personal_access_token_id: token.id, impersonation: token.impersonation, scopes: token.scopes, expires_at: Date.today + EXPIRATION_PERIOD } diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index afb49c48146..7e93e44c463 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -2,11 +2,11 @@ - add_page_specific_style 'page_bundles/tree' .file-finder-holder.tree-holder.clearfix.js-file-finder{ 'data-file-find-url': "#{escape_javascript(project_files_path(@project, @ref, format: :json))}", 'data-find-tree-url': escape_javascript(project_tree_path(@project, @ref)), 'data-blob-url-template': escape_javascript(project_blob_path(@project, @ref)) } - .nav-block - .tree-ref-holder + .nav-block.gl-xs-mr-0 + .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full #js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, namespace: "/-/find_file" } } - %ul.breadcrumb.repo-breadcrumb - %li.breadcrumb-item + %ul.breadcrumb.repo-breadcrumb.gl-flex-nowrap + %li.breadcrumb-item.gl-white-space-nowrap = link_to project_tree_path(@project, @ref) do = @project.path %li.file-finder.breadcrumb-item diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index 3841113231c..28699ca27f3 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -14,18 +14,23 @@ = render 'shared/wikis/main_links' - if @page.historical? - .warning_message - = s_("WikiHistoricalPage|This is an old version of this page.") - - most_recent_link = link_to s_("WikiHistoricalPage|most recent version"), wiki_page_path(@wiki, @page) - - history_link = link_to s_("WikiHistoricalPage|history"), wiki_page_path(@wiki, @page, action: :history) - = (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe + = render Pajamas::AlertComponent.new(variant: :warning, + dismissible: false) do |c| + - c.with_body do + = s_("WikiHistoricalPage|This is an old version of this page.") + - c.with_actions do + .gl-display-flex.gl-gap-3 + = render Pajamas::ButtonComponent.new(category: :primary, variant: :confirm, href: wiki_page_path(@wiki, @page)) do + = s_('WikiHistoricalPage|Go to most recent version') + = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :history)) do + = s_('WikiHistoricalPage|Browse history') .gl-mt-5.gl-mb-3 .gl-display-flex.gl-justify-content-space-between %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page.human_title %div - if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding - = link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' } + = render Pajamas::ButtonComponent.new(href: wiki_page_path(@wiki, @page, action: :edit), icon: 'pencil', button_options: { class: 'js-wiki-edit', title: "Edit", data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }}) .js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki-page-content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } } diff --git a/config/feature_flags/development/command_palette.yml b/config/feature_flags/development/command_palette.yml index cba513c305e..3a7935e6bf5 100644 --- a/config/feature_flags/development/command_palette.yml +++ b/config/feature_flags/development/command_palette.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/413060 milestone: '16.1' type: development group: group::foundations -default_enabled: false +default_enabled: true diff --git a/config/feature_flags/development/content_editor_on_issues.yml b/config/feature_flags/development/content_editor_on_issues.yml index 7e8a6870952..79aaccee828 100644 --- a/config/feature_flags/development/content_editor_on_issues.yml +++ b/config/feature_flags/development/content_editor_on_issues.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/375172 milestone: '15.5' type: development group: group::knowledge -default_enabled: false +default_enabled: true diff --git a/db/migrate/20230630101337_add_previous_personal_access_token_to_personal_access_tokens.rb b/db/migrate/20230630101337_add_previous_personal_access_token_to_personal_access_tokens.rb new file mode 100644 index 00000000000..870723abea9 --- /dev/null +++ b/db/migrate/20230630101337_add_previous_personal_access_token_to_personal_access_tokens.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddPreviousPersonalAccessTokenToPersonalAccessTokens < Gitlab::Database::Migration[2.1] + enable_lock_retries! + + def up + add_column :personal_access_tokens, :previous_personal_access_token_id, :bigint, null: true + end + + def down + remove_column :personal_access_tokens, :previous_personal_access_token_id + end +end diff --git a/db/migrate/20230630101342_add_index_to_personal_access_tokens_on_previous_personal_access_token_id.rb b/db/migrate/20230630101342_add_index_to_personal_access_tokens_on_previous_personal_access_token_id.rb new file mode 100644 index 00000000000..9d07d9f4118 --- /dev/null +++ b/db/migrate/20230630101342_add_index_to_personal_access_tokens_on_previous_personal_access_token_id.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddIndexToPersonalAccessTokensOnPreviousPersonalAccessTokenId < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + INDEX_NAME = 'idx_personal_access_tokens_on_previous_personal_access_token_id' + + def up + add_concurrent_index :personal_access_tokens, :previous_personal_access_token_id, name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :personal_access_tokens, INDEX_NAME + end +end diff --git a/db/migrate/20230630101347_add_fk_to_personal_access_tokens_on_previous_personal_access_token_id.rb b/db/migrate/20230630101347_add_fk_to_personal_access_tokens_on_previous_personal_access_token_id.rb new file mode 100644 index 00000000000..a740b386e47 --- /dev/null +++ b/db/migrate/20230630101347_add_fk_to_personal_access_tokens_on_previous_personal_access_token_id.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddFkToPersonalAccessTokensOnPreviousPersonalAccessTokenId < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + def up + add_concurrent_foreign_key( + :personal_access_tokens, + :personal_access_tokens, + column: :previous_personal_access_token_id, + on_delete: :nullify) + end + + def down + with_lock_retries do + remove_foreign_key_if_exists :personal_access_tokens, column: :previous_personal_access_token_id + end + end +end diff --git a/db/schema_migrations/20230630101337 b/db/schema_migrations/20230630101337 new file mode 100644 index 00000000000..1473c1bf651 --- /dev/null +++ b/db/schema_migrations/20230630101337 @@ -0,0 +1 @@ +53020e29ac265baaca73fe0b5e861d3bafbadc7cc082ac49f766ba7f4e5ae912
\ No newline at end of file diff --git a/db/schema_migrations/20230630101342 b/db/schema_migrations/20230630101342 new file mode 100644 index 00000000000..a8fdc05fabc --- /dev/null +++ b/db/schema_migrations/20230630101342 @@ -0,0 +1 @@ +6b3518efb850118b371ae2806557201ba3fa657a6d631d1225feaf77fe6dff85
\ No newline at end of file diff --git a/db/schema_migrations/20230630101347 b/db/schema_migrations/20230630101347 new file mode 100644 index 00000000000..aa87ba3ebf9 --- /dev/null +++ b/db/schema_migrations/20230630101347 @@ -0,0 +1 @@ +f1413f18b4efc28d0f23582a7a6bc0f27b1bb44eb44d471561cf6447410158cf
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d0d637ecef5..e7f833bd86f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -20140,7 +20140,8 @@ CREATE TABLE personal_access_tokens ( token_digest character varying, expire_notification_delivered boolean DEFAULT false NOT NULL, last_used_at timestamp with time zone, - after_expiry_notification_delivered boolean DEFAULT false NOT NULL + after_expiry_notification_delivered boolean DEFAULT false NOT NULL, + previous_personal_access_token_id bigint ); CREATE SEQUENCE personal_access_tokens_id_seq @@ -29976,6 +29977,8 @@ CREATE UNIQUE INDEX idx_packages_on_project_id_name_version_unique_when_npm ON p CREATE INDEX idx_packages_packages_on_project_id_name_version_package_type ON packages_packages USING btree (project_id, name, version, package_type); +CREATE INDEX idx_personal_access_tokens_on_previous_personal_access_token_id ON personal_access_tokens USING btree (previous_personal_access_token_id); + CREATE INDEX idx_pkgs_debian_group_distribution_keys_on_distribution_id ON packages_debian_group_distribution_keys USING btree (distribution_id); CREATE INDEX idx_pkgs_debian_project_distribution_keys_on_distribution_id ON packages_debian_project_distribution_keys USING btree (distribution_id); @@ -36110,6 +36113,9 @@ ALTER TABLE ONLY agent_activity_events ALTER TABLE ONLY issue_links ADD CONSTRAINT fk_c900194ff2 FOREIGN KEY (source_id) REFERENCES issues(id) ON DELETE CASCADE; +ALTER TABLE ONLY personal_access_tokens + ADD CONSTRAINT fk_c951fbf57e FOREIGN KEY (previous_personal_access_token_id) REFERENCES personal_access_tokens(id) ON DELETE SET NULL; + ALTER TABLE ONLY jira_tracker_data ADD CONSTRAINT fk_c98abcd54c FOREIGN KEY (integration_id) REFERENCES integrations(id) ON DELETE CASCADE; diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 456a396284a..aa2607e259b 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -17005,6 +17005,16 @@ Helm file metadata. | <a id="helmfilemetadatametadata"></a>`metadata` | [`PackageHelmMetadataType!`](#packagehelmmetadatatype) | Metadata of the Helm chart. | | <a id="helmfilemetadataupdatedat"></a>`updatedAt` | [`Time!`](#time) | Date of most recent update. | +### `Ide` + +IDE settings and feature flags. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="idecodesuggestionsenabled"></a>`codeSuggestionsEnabled` | [`Boolean!`](#boolean) | Indicates whether AI assisted code suggestions are enabled. | + ### `IncidentManagementOncallRotation` Describes an incident management on-call rotation. @@ -17859,6 +17869,7 @@ A user assigned to a merge request. | <a id="mergerequestassigneegroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. | | <a id="mergerequestassigneegroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) | | <a id="mergerequestassigneeid"></a>`id` | [`ID!`](#id) | ID of the user. | +| <a id="mergerequestassigneeide"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="mergerequestassigneejobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | | <a id="mergerequestassigneelinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="mergerequestassigneelocation"></a>`location` | [`String`](#string) | Location of the user. | @@ -18138,6 +18149,7 @@ The author of the merge request. | <a id="mergerequestauthorgroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. | | <a id="mergerequestauthorgroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) | | <a id="mergerequestauthorid"></a>`id` | [`ID!`](#id) | ID of the user. | +| <a id="mergerequestauthoride"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="mergerequestauthorjobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | | <a id="mergerequestauthorlinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="mergerequestauthorlocation"></a>`location` | [`String`](#string) | Location of the user. | @@ -18464,6 +18476,7 @@ A user participating in a merge request. | <a id="mergerequestparticipantgroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. | | <a id="mergerequestparticipantgroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) | | <a id="mergerequestparticipantid"></a>`id` | [`ID!`](#id) | ID of the user. | +| <a id="mergerequestparticipantide"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="mergerequestparticipantjobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | | <a id="mergerequestparticipantlinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="mergerequestparticipantlocation"></a>`location` | [`String`](#string) | Location of the user. | @@ -18778,6 +18791,7 @@ A user assigned to a merge request as a reviewer. | <a id="mergerequestreviewergroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. | | <a id="mergerequestreviewergroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) | | <a id="mergerequestreviewerid"></a>`id` | [`ID!`](#id) | ID of the user. | +| <a id="mergerequestrevieweride"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="mergerequestreviewerjobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | | <a id="mergerequestreviewerlinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="mergerequestreviewerlocation"></a>`location` | [`String`](#string) | Location of the user. | @@ -23355,6 +23369,7 @@ Core represention of a GitLab user. | <a id="usercoregroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. | | <a id="usercoregroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) | | <a id="usercoreid"></a>`id` | [`ID!`](#id) | ID of the user. | +| <a id="usercoreide"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="usercorejobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | | <a id="usercorelinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="usercorelocation"></a>`location` | [`String`](#string) | Location of the user. | @@ -28405,6 +28420,7 @@ Implementations: | <a id="usergroupcount"></a>`groupCount` | [`Int`](#int) | Group count for the user. | | <a id="usergroupmemberships"></a>`groupMemberships` | [`GroupMemberConnection`](#groupmemberconnection) | Group memberships of the user. (see [Connections](#connections)) | | <a id="userid"></a>`id` | [`ID!`](#id) | ID of the user. | +| <a id="useride"></a>`ide` | [`Ide`](#ide) | IDE settings. | | <a id="userjobtitle"></a>`jobTitle` | [`String`](#string) | Job title of the user. | | <a id="userlinkedin"></a>`linkedin` | [`String`](#string) | LinkedIn profile name of the user. | | <a id="userlocation"></a>`location` | [`String`](#string) | Location of the user. | diff --git a/doc/api/groups.md b/doc/api/groups.md index eaed56f8d2d..dc80c05b400 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -837,7 +837,7 @@ The `default_branch_protection` attribute determines whether users with the Deve | `1` | Partial protection. Users with the Developer or Maintainer role can: <br>- Push new commits | | `2` | Full protection. Only users with the Maintainer role can: <br>- Push new commits | | `3` | Protected against pushes. Users with the Maintainer role can: <br>- Push new commits<br>- Force push changes<br>- Accept merge requests<br>Users with the Developer role can:<br>- Accept merge requests| -| `4` | Protected against pushes except initial push. User with the Developer rope can: <br>- Push commit to empty repository.<br> Users with the Maintainer role can: <br>- Push new commits<br>- Force push changes<br>- Accept merge requests<br>Users with the Developer role can:<br>- Accept merge requests| +| `4` | Protected against pushes except initial push. User with the Developer role can: <br>- Push commit to empty repository.<br> Users with the Maintainer role can: <br>- Push new commits<br>- Force push changes<br>- Accept merge requests<br>Users with the Developer role can:<br>- Accept merge requests| ## New Subgroup diff --git a/doc/user/project/repository/branches/default.md b/doc/user/project/repository/branches/default.md index 100450aefe7..ae978e2123d 100644 --- a/doc/user/project/repository/branches/default.md +++ b/doc/user/project/repository/branches/default.md @@ -96,7 +96,7 @@ unless a subgroup configuration overrides it. ## Protect initial default branches **(FREE SELF)** -> Full protection after initial push [added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118729) in GitLab 16.0. +> Full protection after initial push [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118729) in GitLab 16.0. GitLab administrators and group owners can define [branch protections](../../../project/protected_branches.md) to apply to every repository's [default branch](#default-branch) diff --git a/doc/user/search/command_palette.md b/doc/user/search/command_palette.md index 6440384c254..0d4e4ad7cb3 100644 --- a/doc/user/search/command_palette.md +++ b/doc/user/search/command_palette.md @@ -7,19 +7,21 @@ type: reference # Command palette **(FREE)** -> Introduced in GitLab 16.2 [with a flag](../../administration/feature_flags.md) named `command_palette`. Disabled by default. +> Introduced in GitLab 16.2 [with a flag](../../administration/feature_flags.md) named `command_palette`. Enabled by default. You can use command palette to narrow down the scope of your search or to find an object more quickly. FLAG: -On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to enable the feature flag named `command_palette`. On GitLab.com, this feature is not available. +On self-managed GitLab, by default this feature is available. +To hide the feature, ask an administrator to [disable the feature flag](../../administration/feature_flags.md) named `command_palette`. +On GitLab.com, this feature is available. ## Open the command palette To open the command palette: -1. On the left sidebar, at the top, select **Search GitLab** (**{search}**). +1. On the left sidebar, at the top, select **Search GitLab** (**{search}**) or use the <kbd>/</kbd> key to enable. 1. Type one of the special characters: - <kbd>></kbd> - Create a new object or find a menu item. diff --git a/gems/activerecord-gitlab/.rubocop.yml b/gems/activerecord-gitlab/.rubocop.yml index 8c670b439d3..5fce096769a 100644 --- a/gems/activerecord-gitlab/.rubocop.yml +++ b/gems/activerecord-gitlab/.rubocop.yml @@ -1,2 +1,18 @@ inherit_from: - ../config/rubocop.yml + +# FIXME +Gitlab/RSpec/AvoidSetup: + Enabled: false + +Database/EstablishConnection: + Enabled: false + +Database/MultipleDatabases: + Enabled: false + +RSpec/EnvAssignment: + Enabled: false + +RSpec/MultipleMemoizedHelpers: + Enabled: false diff --git a/gems/activerecord-gitlab/Gemfile.lock b/gems/activerecord-gitlab/Gemfile.lock index b250ae79d4b..ba0118eb8e3 100644 --- a/gems/activerecord-gitlab/Gemfile.lock +++ b/gems/activerecord-gitlab/Gemfile.lock @@ -1,23 +1,22 @@ PATH remote: . specs: - activerecord-gitlab (0.1.0) - activerecord (>= 6.1.7.3) + activerecord-gitlab (0.2.0) + activerecord (>= 7) GEM remote: https://rubygems.org/ specs: - activemodel (6.1.7.3) - activesupport (= 6.1.7.3) - activerecord (6.1.7.3) - activemodel (= 6.1.7.3) - activesupport (= 6.1.7.3) - activesupport (6.1.7.3) + activemodel (7.0.6) + activesupport (= 7.0.6) + activerecord (7.0.6) + activemodel (= 7.0.6) + activesupport (= 7.0.6) + activesupport (7.0.6) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - zeitwerk (~> 2.3) ast (2.4.2) concurrent-ruby (1.2.2) diff-lcs (1.5.0) @@ -30,6 +29,7 @@ GEM i18n (1.13.0) concurrent-ruby (~> 1.0) json (2.6.3) + mini_portile2 (2.8.2) minitest (5.18.0) parallel (1.23.0) parser (3.2.2.3) @@ -83,10 +83,11 @@ GEM rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) ruby-progressbar (1.13.0) + sqlite3 (1.6.3) + mini_portile2 (~> 2.8.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.4.2) - zeitwerk (2.6.8) PLATFORMS ruby @@ -97,6 +98,7 @@ DEPENDENCIES rspec (~> 3.12) rubocop (~> 1.50) rubocop-rspec (~> 2.22) + sqlite3 (~> 1.6) BUNDLED WITH 2.4.14 diff --git a/gems/activerecord-gitlab/activerecord-gitlab.gemspec b/gems/activerecord-gitlab/activerecord-gitlab.gemspec index 36346b04602..267938d0de3 100644 --- a/gems/activerecord-gitlab/activerecord-gitlab.gemspec +++ b/gems/activerecord-gitlab/activerecord-gitlab.gemspec @@ -18,10 +18,11 @@ Gem::Specification.new do |spec| spec.files = Dir["lib/**/*.rb"] spec.require_paths = ["lib"] - spec.add_runtime_dependency "activerecord", ">= 6.1.7.3" + spec.add_runtime_dependency "activerecord", ">= 7" spec.add_development_dependency "gitlab-styles", "~> 10.1.0" spec.add_development_dependency "rspec", "~> 3.12" spec.add_development_dependency "rubocop", "~> 1.50" spec.add_development_dependency "rubocop-rspec", "~> 2.22" + spec.add_development_dependency "sqlite3", "~> 1.6" end diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches.rb index 2063c272ed2..257602497f0 100644 --- a/gems/activerecord-gitlab/lib/active_record/gitlab_patches.rb +++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches.rb @@ -3,6 +3,7 @@ require "active_record" require_relative "gitlab_patches/version" require_relative "gitlab_patches/rescue_from" +require_relative "gitlab_patches/partitioning" module ActiveRecord module GitlabPatches diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning.rb new file mode 100644 index 00000000000..cf0bd4849e2 --- /dev/null +++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "partitioning/associations/builder/association" +require_relative "partitioning/reflection/abstract_reflection" +require_relative "partitioning/reflection/association_reflection" +require_relative "partitioning/reflection/macro_reflection" +require_relative "partitioning/base" + +module ActiveRecord + module GitlabPatches + # This allows to filter data by a dedicated column for association and joins to ActiveRecord::Base. + # + # class ApplicationRecord < ActiveRecord::Base + # belongs_to :pipeline, + # -> (build) { where(partition_id: build.partition_id) }, + # partition_foreign_key: :partition_id + # + # To control the join filter + # def self.use_partition_id_filter? + # Feature.enabled?(...) + # end + # end + module Partitioning + ActiveSupport.on_load(:active_record) do + ::ActiveRecord::Associations::Builder::Association.prepend( + ActiveRecord::GitlabPatches::Partitioning::Associations::Builder::Association + ) + ::ActiveRecord::Reflection::AbstractReflection.prepend( + ActiveRecord::GitlabPatches::Partitioning::Reflection::AbstractReflection + ) + ::ActiveRecord::Reflection::AssociationReflection.prepend( + ActiveRecord::GitlabPatches::Partitioning::Reflection::AssociationReflection + ) + ::ActiveRecord::Reflection::MacroReflection.prepend( + ActiveRecord::GitlabPatches::Partitioning::Reflection::MacroReflection + ) + ::ActiveRecord::Base.prepend( + ActiveRecord::GitlabPatches::Partitioning::Base + ) + end + end + end +end diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/associations/builder/association.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/associations/builder/association.rb new file mode 100644 index 00000000000..3c92ba91c31 --- /dev/null +++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/associations/builder/association.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ActiveRecord + module GitlabPatches + module Partitioning + module Associations + module Builder + module Association + extend ActiveSupport::Concern + + class_methods do + def valid_options(options) + super + [:partition_foreign_key] + end + end + end + end + end + end + end +end diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/base.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/base.rb new file mode 100644 index 00000000000..0c8a248b984 --- /dev/null +++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/base.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +if ::ActiveRecord::VERSION::STRING >= "7.1" + raise 'New version of active-record detected, please remove or update this patch' +end + +module ActiveRecord + module GitlabPatches + module Partitioning + module Base + extend ActiveSupport::Concern + + def _query_constraints_hash + constraints_hash = super + + return constraints_hash unless self.class.use_partition_id_filter? + + if self.class.query_constraints_list.nil? + { @primary_key => id_in_database } # rubocop:disable Gitlab/ModuleWithInstanceVariables + else + self.class.query_constraints_list.index_with do |column_name| + attribute_in_database(column_name) + end + end + end + + class_methods do + def use_partition_id_filter? + false + end + + def query_constraints(*columns_list) + raise ArgumentError, "You must specify at least one column to be used in querying" if columns_list.empty? + + @query_constraints_list = columns_list.map(&:to_s) + end + + def query_constraints_list # :nodoc: + @query_constraints_list ||= if base_class? || primary_key != base_class.primary_key + primary_key if primary_key.is_a?(Array) + else + base_class.query_constraints_list + end + end + end + end + end + end +end diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/abstract_reflection.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/abstract_reflection.rb new file mode 100644 index 00000000000..7532cd120a5 --- /dev/null +++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/abstract_reflection.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ActiveRecord + module GitlabPatches + module Partitioning + module Reflection + module AbstractReflection + extend ActiveSupport::Concern + + def join_scope(table, foreign_table, foreign_klass) + klass_scope = super + return klass_scope unless respond_to?(:options) + + partition_foreign_key = options[:partition_foreign_key] + if partition_foreign_key && klass.use_partition_id_filter? + klass_scope.where!(table[:partition_id].eq(foreign_table[partition_foreign_key])) + end + + klass_scope + end + end + end + end + end +end diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/association_reflection.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/association_reflection.rb new file mode 100644 index 00000000000..299ceaab973 --- /dev/null +++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/association_reflection.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ActiveRecord + module GitlabPatches + module Partitioning + module Reflection + module AssociationReflection + def check_eager_loadable! + return if scope && scope.arity == 1 && options.key?(:partition_foreign_key) + + super + end + end + end + end + end +end diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/macro_reflection.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/macro_reflection.rb new file mode 100644 index 00000000000..7ec7da44253 --- /dev/null +++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/partitioning/reflection/macro_reflection.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ActiveRecord + module GitlabPatches + module Partitioning + module Reflection + module MacroReflection + def scope_for(relation, owner = nil) + if scope.arity == 1 && owner.nil? && options.key?(:partition_foreign_key) + relation + else + super + end + end + end + end + end + end +end diff --git a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/version.rb b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/version.rb index 4045f1d5f31..00c5f254da8 100644 --- a/gems/activerecord-gitlab/lib/active_record/gitlab_patches/version.rb +++ b/gems/activerecord-gitlab/lib/active_record/gitlab_patches/version.rb @@ -3,7 +3,7 @@ module ActiveRecord module GitlabPatches module Version - VERSION = "0.1.0" + VERSION = "0.2.0" end end end diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/belongs_to_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/belongs_to_spec.rb new file mode 100644 index 00000000000..900a270c0a8 --- /dev/null +++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/belongs_to_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::BelongsTo', :partitioning do + let(:pipeline) { Pipeline.create!(partition_id: 100) } + let(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) } + + it 'finds associated record using partition_id' do + find_statement = <<~SQL.squish + SELECT \"pipelines\".* + FROM \"pipelines\" + WHERE \"pipelines\".\"id\" = #{pipeline.id} + AND \"pipelines\".\"partition_id\" = #{job.partition_id} + LIMIT 1 + SQL + + result = QueryRecorder.log do + job.reset.pipeline + end + + expect(result).to include(find_statement) + end + + it 'builds records using partition_id' do + pipeline = job.build_pipeline + + expect(pipeline.partition_id).to eq(job.partition_id) + end + + it 'saves records using partition_id' do + create_statement = <<~SQL.squish + INSERT INTO \"pipelines\" (\"partition_id\") VALUES (#{job.partition_id}) + SQL + + result = QueryRecorder.log do + job.build_pipeline.save! + end + + expect(result).to include(create_statement) + end + + it 'creates records using partition_id' do + create_statement = <<~SQL.squish + INSERT INTO \"pipelines\" (\"partition_id\") VALUES (#{job.partition_id}) + SQL + + result = QueryRecorder.log do + job.create_pipeline! + end + + expect(result).to include(create_statement) + end +end diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_many_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_many_spec.rb new file mode 100644 index 00000000000..3d6b24de998 --- /dev/null +++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_many_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::HasMany', :partitioning do + let(:pipeline) { Pipeline.create!(partition_id: 100) } + let(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) } + + it 'finds individual records using partition_id' do + find_statement = <<~SQL.squish + SELECT \"jobs\".* + FROM \"jobs\" + WHERE \"jobs\".\"pipeline_id\" = #{pipeline.id} + AND \"jobs\".\"partition_id\" = #{pipeline.partition_id} + AND \"jobs\".\"id\" = #{job.id} + LIMIT 1 + SQL + + result = QueryRecorder.log do + pipeline.jobs.find(job.id) + end + + expect(result).to include(find_statement) + end + + it 'finds all records using partition_id' do + find_statement = <<~SQL.squish + SELECT \"jobs\".* + FROM \"jobs\" + WHERE \"jobs\".\"pipeline_id\" = #{pipeline.id} + AND \"jobs\".\"partition_id\" = #{pipeline.partition_id} + SQL + + result = QueryRecorder.log do + pipeline.jobs.all.to_a + end + + expect(result).to include(find_statement) + end + + it 'jobs records using partition_id' do + build = pipeline.jobs.new(name: 'test job') + + expect(build.pipeline_id).to eq(pipeline.id) + expect(build.partition_id).to eq(pipeline.partition_id) + end + + it 'saves records using partition_id' do + create_statement = <<~SQL.squish + INSERT INTO \"jobs\" (\"pipeline_id\", \"partition_id\", \"name\") + VALUES (#{pipeline.id}, #{pipeline.partition_id}, 'test job') + SQL + + result = QueryRecorder.log do + build = pipeline.jobs.new(name: 'test job') + build.save! + end + + expect(result).to include(create_statement) + end + + it 'creates records using partition_id' do + create_statement = <<~SQL.squish + INSERT INTO \"jobs\" (\"pipeline_id\", \"partition_id\", \"name\") + VALUES (#{pipeline.id}, #{pipeline.partition_id}, 'test job') + SQL + + result = QueryRecorder.log do + pipeline.jobs.create!(name: 'test job') + end + + expect(result).to include(create_statement) + end + + it 'deletes_all records using partition_id' do + delete_statement = <<~SQL.squish + DELETE FROM \"jobs\" + WHERE \"jobs\".\"pipeline_id\" = #{pipeline.id} + AND \"jobs\".\"partition_id\" = #{pipeline.partition_id} + SQL + + result = QueryRecorder.log do + pipeline.jobs.delete_all + end + + expect(result).to include(delete_statement) + end + + it 'destroy_all records using partition_id' do + destroy_statement = <<~SQL.squish + DELETE FROM \"jobs\" + WHERE \"jobs\".\"id\" = #{job.id} + AND \"jobs\".\"partition_id\" = #{pipeline.partition_id} + SQL + + result = QueryRecorder.log do + pipeline.jobs.destroy_all # rubocop: disable Cop/DestroyAll + end + + expect(result).to include(destroy_statement) + end + + it 'counts records using partition_id' do + destroy_statement = <<~SQL.squish + SELECT COUNT(*) + FROM \"jobs\" + WHERE \"jobs\".\"pipeline_id\" = #{pipeline.id} + AND \"jobs\".\"partition_id\" = #{pipeline.partition_id} + SQL + + result = QueryRecorder.log do + pipeline.jobs.count + end + + expect(result).to include(destroy_statement) + end +end diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_one_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_one_spec.rb new file mode 100644 index 00000000000..aeb565c6dad --- /dev/null +++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/associations/has_one_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::HasOne', :partitioning do + let(:pipeline) { Pipeline.create!(partition_id: 100) } + let(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) } + + it 'finds associated record using partition_id' do + find_statement = <<~SQL.squish + SELECT \"metadata\".* + FROM \"metadata\" + WHERE \"metadata\".\"job_id\" = #{job.id} + AND \"metadata\".\"partition_id\" = #{job.partition_id} + LIMIT 1 + SQL + + result = QueryRecorder.log do + job.reset.metadata + end + + expect(result).to include(find_statement) + end + + it 'builds records using partition_id' do + metadata = job.build_metadata + + expect(metadata.job_id).to eq(job.id) + expect(metadata.partition_id).to eq(job.partition_id) + end + + it 'saves records using partition_id' do + create_statement = <<~SQL.squish + INSERT INTO \"metadata\" (\"job_id\", \"partition_id\") VALUES (#{job.id}, #{job.partition_id}) + SQL + + result = QueryRecorder.log do + job.build_metadata.save! + end + + expect(result).to include(create_statement) + end + + it 'creates records using partition_id' do + create_statement = <<~SQL.squish + INSERT INTO \"metadata\" (\"job_id\", \"partition_id\") VALUES (#{job.id}, #{job.partition_id}) + SQL + + result = QueryRecorder.log do + job.create_metadata + end + + expect(result).to include(create_statement) + end + + it 'uses nested attributes on create' do + skip '`partitionable` will assign the `partition_id` value in this case.' + + statement1 = <<~SQL.squish + INSERT INTO \"jobs\" (\"pipeline_id\", \"partition_id\", \"name\") + VALUES (#{pipeline.id}, #{pipeline.partition_id}, 'test') + SQL + + statement2 = <<~SQL.squish + INSERT INTO \"metadata\" (\"job_id\", \"partition_id\", \"test_flag\") + VALUES (#{job.id}, #{job.partition_id}, 1) + SQL + + insert_statements = [statement1, statement2] + + result = QueryRecorder.log do + pipeline.jobs.create!(name: 'test', metadata_attributes: { test_flag: true }) + end + + insert_statements.each do |statement| + expect(result).to include(statement) + end + end + + it 'uses nested attributes on update' do + statement1 = <<~SQL.squish + UPDATE \"jobs\" SET \"name\" = 'other test' + WHERE \"jobs\".\"id\" = #{job.id} AND \"jobs\".\"partition_id\" = #{job.partition_id} + SQL + + statement2 = <<~SQL.squish + INSERT INTO \"metadata\" (\"job_id\", \"partition_id\", \"test_flag\") VALUES (#{job.id}, #{job.partition_id}, 1) + SQL + + update_statements = [statement1, statement2] + + job.name = 'other test' + job.metadata_attributes = { test_flag: true } + + result = QueryRecorder.log do + job.save! + end + + update_statements.each do |statement| + expect(result).to include(statement) + end + end +end diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/joins_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/joins_spec.rb new file mode 100644 index 00000000000..038fae43644 --- /dev/null +++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/joins_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::Joins', :partitioning do + let!(:pipeline) { Pipeline.create!(partition_id: 100) } + let!(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) } + let!(:metadata) { Metadata.create!(job: job, partition_id: job.partition_id) } + + it 'joins using partition_id' do + join_statement = <<~SQL.squish + SELECT \"pipelines\".* + FROM \"pipelines\" + INNER JOIN \"jobs\" ON \"jobs\".\"pipeline_id\" = \"pipelines\".\"id\" + AND \"jobs\".\"partition_id\" = \"pipelines\".\"partition_id\" + WHERE \"pipelines\".\"partition_id\" = #{pipeline.partition_id} + SQL + + result = QueryRecorder.log do + Pipeline.where(partition_id: pipeline.partition_id).joins(:jobs).to_a + end + + expect(result).to include(join_statement) + end + + it 'joins other models using partition_id' do + join_statement = <<~SQL.squish + SELECT \"pipelines\".* + FROM \"pipelines\" + INNER JOIN \"jobs\" ON \"jobs\".\"pipeline_id\" = \"pipelines\".\"id\" + AND \"jobs\".\"partition_id\" = \"pipelines\".\"partition_id\" + INNER JOIN \"metadata\" ON \"metadata\".\"job_id\" = \"jobs\".\"id\" + AND \"metadata\".\"partition_id\" = \"jobs\".\"partition_id\" + WHERE \"pipelines\".\"partition_id\" = #{pipeline.partition_id} + SQL + + result = QueryRecorder.log do + Pipeline.where(partition_id: pipeline.partition_id).joins(jobs: :metadata).to_a + end + + expect(result).to include(join_statement) + end +end diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/preloads_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/preloads_spec.rb new file mode 100644 index 00000000000..f37a563fe9e --- /dev/null +++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/preloads_spec.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::Preloads', :partitioning do + let(:project) { Project.create! } + + let!(:pipeline) { Pipeline.create!(project: project, partition_id: 100) } + let!(:other_pipeline) { Pipeline.create!(project: project, partition_id: 100) } + + let!(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) } + let!(:other_job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) } + + describe 'preload queries with single partition' do + it 'preloads metadata for jobs' do + statement1 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" WHERE \"jobs\".\"partition_id\" = 100 + SQL + + statement2 = <<~SQL.squish + SELECT \"metadata\".* FROM \"metadata\" + WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id}) + SQL + + preload_statements = [statement1, statement2] + + result = QueryRecorder.log do + Job.where(partition_id: 100).preload(:metadata).to_a + end + + preload_statements.each do |statement| + expect(result).to include(statement) + end + end + + it 'preloads jobs for pipelines' do + statement1 = <<~SQL.squish + SELECT \"pipelines\".* FROM \"pipelines\" WHERE \"pipelines\".\"partition_id\" = 100 + SQL + + statement2 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" + WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id}) + SQL + + preload_statements = [statement1, statement2] + + result = QueryRecorder.log do + Pipeline.where(partition_id: 100).preload(:jobs).to_a + end + + preload_statements.each do |statement| + expect(result).to include(statement) + end + end + + it 'preloads jobs and metadata for pipelines' do + statement1 = <<~SQL.squish + SELECT \"pipelines\".* FROM \"pipelines\" WHERE \"pipelines\".\"partition_id\" = 100 + SQL + + statement2 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" + WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id}) + SQL + + statement3 = <<~SQL.squish + SELECT \"metadata\".* FROM \"metadata\" + WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id}) + SQL + + preload_statements = [statement1, statement2, statement3] + + result = QueryRecorder.log do + Pipeline.where(partition_id: 100).preload(jobs: :metadata).to_a + end + + preload_statements.each do |statement| + expect(result).to include(statement) + end + end + end + + describe 'preload queries with multiple partitions' do + let!(:recent_pipeline) { Pipeline.create!(project: project, partition_id: 200) } + let!(:test_job) { Job.create!(pipeline: recent_pipeline, partition_id: recent_pipeline.partition_id) } + let!(:deploy_job) { Job.create!(pipeline: recent_pipeline, partition_id: recent_pipeline.partition_id) } + + it 'preloads metadata for jobs' do + statement1 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" WHERE \"jobs\".\"partition_id\" IN (100, 200) + SQL + + statement2 = <<~SQL.squish + SELECT \"metadata\".* FROM \"metadata\" + WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id}) + SQL + + statement3 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" WHERE \"jobs\".\"partition_id\" IN (100, 200) + SQL + + preload_statements = [statement1, statement2, statement3] + + result = QueryRecorder.log do + Job.where(partition_id: [100, 200]).preload(:metadata).to_a + end + + preload_statements.each do |statement| + expect(result).to include(statement) + end + end + + it 'preloads jobs for pipelines' do + statement1 = <<~SQL.squish + SELECT \"pipelines\".* FROM \"pipelines\" WHERE \"pipelines\".\"partition_id\" IN (100, 200) + SQL + + statement2 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" + WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id}) + SQL + + statement3 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" + WHERE \"jobs\".\"partition_id\" = 200 AND \"jobs\".\"pipeline_id\" = #{recent_pipeline.id} + SQL + + preload_statements = [statement1, statement2, statement3] + + result = QueryRecorder.log do + Pipeline.where(partition_id: [100, 200]).preload(:jobs).to_a + end + + preload_statements.each do |statement| + expect(result).to include(statement) + end + end + + it 'preloads jobs and metadata for pipelines' do + statement1 = <<~SQL.squish + SELECT \"pipelines\".* FROM \"pipelines\" WHERE \"pipelines\".\"partition_id\" IN (100, 200) + SQL + + statement2 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" + WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id}) + SQL + + statement3 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" + WHERE \"jobs\".\"partition_id\" = 200 AND \"jobs\".\"pipeline_id\" = #{recent_pipeline.id} + SQL + + statement4 = <<~SQL.squish + SELECT \"metadata\".* FROM \"metadata\" + WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id}) + SQL + + statement5 = <<~SQL.squish + SELECT \"metadata\".* FROM \"metadata\" + WHERE \"metadata\".\"partition_id\" = 200 AND \"metadata\".\"job_id\" IN (#{test_job.id}, #{deploy_job.id}) + SQL + + preload_statements = [statement1, statement2, statement3, statement4, statement5] + + result = QueryRecorder.log do + Pipeline.where(partition_id: [100, 200]).preload(jobs: :metadata).to_a + end + + preload_statements.each do |statement| + expect(result).to include(statement) + end + end + end + + describe 'includes queries' do + it 'preloads data for pipeline with multiple queries' do + statement1 = <<~SQL.squish + SELECT \"pipelines\".* FROM \"pipelines\" + WHERE \"pipelines\".\"project_id\" = 1 AND \"pipelines\".\"id\" + IN (#{pipeline.id}, #{other_pipeline.id}) AND \"pipelines\".\"partition_id\" = 100 + SQL + + statement2 = <<~SQL.squish + SELECT \"jobs\".* FROM \"jobs\" + WHERE \"jobs\".\"partition_id\" = 100 AND \"jobs\".\"pipeline_id\" IN (#{pipeline.id}, #{other_pipeline.id}) + SQL + + statement3 = <<~SQL.squish + SELECT \"metadata\".* FROM \"metadata\" + WHERE \"metadata\".\"partition_id\" = 100 AND \"metadata\".\"job_id\" IN (#{job.id}, #{other_job.id}) + SQL + + preload_statements = [statement1, statement2, statement3] + + result = QueryRecorder.log do + project.pipelines.includes(jobs: :metadata).where(id: [pipeline.id, other_pipeline.id], partition_id: 100).to_a + end + + preload_statements.each do |statement| + expect(result).to include(statement) + end + end + + it 'preloads data for pipeline with join query' do + preload_statement = <<~SQL.squish + SELECT \"pipelines\".\"id\" + AS t0_r0, \"pipelines\".\"project_id\" + AS t0_r1, \"pipelines\".\"partition_id\" + AS t0_r2, \"jobs\".\"id\" + AS t1_r0, \"jobs\".\"pipeline_id\" + AS t1_r1, \"jobs\".\"partition_id\" + AS t1_r2, \"jobs\".\"name\" + AS t1_r3, \"metadata\".\"id\" + AS t2_r0, \"metadata\".\"job_id\" + AS t2_r1, \"metadata\".\"partition_id\" + AS t2_r2, \"metadata\".\"test_flag\" + AS t2_r3 + FROM \"pipelines\" + LEFT OUTER JOIN \"jobs\" ON \"jobs\".\"pipeline_id\" = \"pipelines\".\"id\" + AND \"jobs\".\"partition_id\" = \"pipelines\".\"partition_id\" + LEFT OUTER JOIN \"metadata\" ON \"metadata\".\"job_id\" = \"jobs\".\"id\" + AND \"metadata\".\"partition_id\" = \"jobs\".\"partition_id\" + WHERE \"pipelines\".\"project_id\" = 1 + AND \"pipelines\".\"id\" + IN (#{pipeline.id}, #{other_pipeline.id}) + AND \"pipelines\".\"partition_id\" = 100 + SQL + + result = QueryRecorder.log do + project + .pipelines + .includes(jobs: :metadata) + .references(:jobs, :metadata) + .where(id: [1, 2], partition_id: 100) + .to_a + end + + expect(result).to include(preload_statement) + end + end +end diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/single_model_queries_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/single_model_queries_spec.rb new file mode 100644 index 00000000000..b035d7a6277 --- /dev/null +++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/partitioning/single_model_queries_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +RSpec.describe 'ActiveRecord::GitlabPatches::Partitioning::Associations::SingleModelQueries', :partitioning do + let(:project) { Project.create! } + let(:pipeline) { Pipeline.create!(project: project, partition_id: 100) } + let(:job) { Job.create!(pipeline: pipeline, partition_id: pipeline.partition_id) } + + it 'creates using id and partition_id' do + create_statement = <<~SQL.squish + INSERT INTO \"jobs\" (\"pipeline_id\", \"partition_id\") + VALUES (#{pipeline.id}, #{pipeline.partition_id}) + SQL + + result = QueryRecorder.log do + Job.create!(pipeline_id: pipeline.id, partition_id: pipeline.partition_id) + end + + expect(result).to include(create_statement) + end + + it 'finds with id and partition_id' do + find_statement = <<~SQL.squish + SELECT \"jobs\".* + FROM \"jobs\" + WHERE \"jobs\".\"id\" = #{job.id} + AND \"jobs\".\"partition_id\" = #{job.partition_id} + LIMIT 1 + SQL + + result = QueryRecorder.log do + Job.find_by!(id: job.id, partition_id: job.partition_id) + end + + expect(result).to include(find_statement) + end + + it 'saves using id and partition_id' do + update_statement = <<~SQL.squish + UPDATE \"jobs\" + SET \"name\" = 'test' + WHERE \"jobs\".\"id\" = #{job.id} + AND \"jobs\".\"partition_id\" = #{job.partition_id} + SQL + + result = QueryRecorder.log do + job.name = 'test' + + job.save! + end + + expect(result).to include(update_statement) + end + + it 'updates using id and partition_id' do + update_statement = <<~SQL.squish + UPDATE \"jobs\" + SET \"name\" = 'test2' + WHERE \"jobs\".\"id\" = #{job.id} + AND \"jobs\".\"partition_id\" = #{job.partition_id} + SQL + + result = QueryRecorder.log do + job.update!(name: 'test2') + end + + expect(result).to include(update_statement) + end + + it 'deletes using id and partition_id' do + delete_statement = <<~SQL.squish + DELETE FROM \"jobs\" + WHERE \"jobs\".\"id\" = #{job.id} + AND \"jobs\".\"partition_id\" = #{job.partition_id} + SQL + + result = QueryRecorder.log do + job.delete + end + + expect(result).to include(delete_statement) + end + + it 'destroys using id and partition_id' do + destroy_statement = <<~SQL.squish + DELETE FROM \"jobs\" + WHERE \"jobs\".\"id\" = #{job.id} + AND \"jobs\".\"partition_id\" = #{job.partition_id} + SQL + + result = QueryRecorder.log do + job.destroy + end + + expect(result).to include(destroy_statement) + end + + it 'destroy_all using partition_id' do + destroy_statement = <<~SQL.squish + DELETE FROM \"jobs\" + WHERE \"jobs\".\"id\" = #{job.id} + AND \"jobs\".\"partition_id\" = #{job.partition_id} + SQL + + result = QueryRecorder.log do + Job.where(id: job.id).destroy_all # rubocop: disable Cop/DestroyAll + end + + expect(result).to include(destroy_statement) + end +end diff --git a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/rescue_from_spec.rb b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/rescue_from_spec.rb index 22c8db7b174..c1537c3bd90 100644 --- a/gems/activerecord-gitlab/spec/active_record/gitlab_patches/rescue_from_spec.rb +++ b/gems/activerecord-gitlab/spec/active_record/gitlab_patches/rescue_from_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe ActiveRecord::GitlabPatches::RescueFrom do +RSpec.describe ActiveRecord::GitlabPatches::RescueFrom, :without_sqlite3 do let(:model_with_rescue_from) do Class.new(ActiveRecord::Base) do rescue_from ActiveRecord::ConnectionNotEstablished, with: :handle_exception @@ -16,12 +16,16 @@ RSpec.describe ActiveRecord::GitlabPatches::RescueFrom do end it 'triggers rescue_from' do + stub_const('ModelWithRescueFrom', model_with_rescue_from) + expect(model_with_rescue_from).to receive(:handle_exception) expect { model_with_rescue_from.all.load }.not_to raise_error end it 'does not trigger rescue_from' do + stub_const('ModelWithoutRescueFrom', model_without_rescue_from) + expect { model_without_rescue_from.all.load }.to raise_error(ActiveRecord::ConnectionNotEstablished) end end diff --git a/gems/activerecord-gitlab/spec/spec_helper.rb b/gems/activerecord-gitlab/spec/spec_helper.rb index 4250023b898..548295b3f2c 100644 --- a/gems/activerecord-gitlab/spec/spec_helper.rb +++ b/gems/activerecord-gitlab/spec/spec_helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +ENV["RAILS_ENV"] = "test" + require "active_record/gitlab_patches" RSpec.configure do |config| @@ -12,4 +14,18 @@ RSpec.configure do |config| config.expect_with :rspec do |c| c.syntax = :expect end + + Dir[File.expand_path("spec/support/**/*.rb")].each { |f| require f } + + config.around(:all, :partitioning) do |example| + ActiveRecord::Base.transaction do + example.run + + raise ActiveRecord::Rollback + end + end + + config.before(:all, :without_sqlite3) do + ActiveRecord::Base.remove_connection + end end diff --git a/gems/activerecord-gitlab/spec/support/database.rb b/gems/activerecord-gitlab/spec/support/database.rb new file mode 100644 index 00000000000..998d945c311 --- /dev/null +++ b/gems/activerecord-gitlab/spec/support/database.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.before(:all) do + ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") + ActiveRecord::Base.logger = Logger.new('/dev/null') + + ActiveRecord::Schema.define do + create_table :projects, force: true + + create_table :pipelines, force: true do |t| + t.integer :project_id + t.integer :partition_id + end + + create_table :jobs, force: true do |t| + t.integer :pipeline_id + t.integer :partition_id + t.string :name + end + + create_table :metadata, force: true do |t| + t.integer :job_id + t.integer :partition_id + t.boolean :test_flag, default: false + end + end + end +end diff --git a/gems/activerecord-gitlab/spec/support/models.rb b/gems/activerecord-gitlab/spec/support/models.rb new file mode 100644 index 00000000000..c0017656ea8 --- /dev/null +++ b/gems/activerecord-gitlab/spec/support/models.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class PartitionedRecord < ActiveRecord::Base + self.abstract_class = true + + def self.use_partition_id_filter? + true + end + + alias_method :reset, :reload +end + +class Project < ActiveRecord::Base + has_many :pipelines +end + +class Pipeline < PartitionedRecord + belongs_to :project + query_constraints :id, :partition_id + + has_many :jobs, + ->(pipeline) { where(partition_id: pipeline.partition_id) }, + partition_foreign_key: :partition_id, + dependent: :destroy +end + +class Job < PartitionedRecord + query_constraints :id, :partition_id + + belongs_to :pipeline, + ->(build) { where(partition_id: build.partition_id) }, + partition_foreign_key: :partition_id + + has_one :metadata, + ->(build) { where(partition_id: build.partition_id) }, + foreign_key: :job_id, + partition_foreign_key: :partition_id, + inverse_of: :job, + autosave: true + + accepts_nested_attributes_for :metadata +end + +class Metadata < PartitionedRecord + self.table_name = :metadata + query_constraints :id, :partition_id + + belongs_to :job, + ->(metadata) { where(partition_id: metadata.partition_id) } +end diff --git a/gems/activerecord-gitlab/spec/support/query_recorder.rb b/gems/activerecord-gitlab/spec/support/query_recorder.rb new file mode 100644 index 00000000000..5129bae9240 --- /dev/null +++ b/gems/activerecord-gitlab/spec/support/query_recorder.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class QueryRecorder + attr_reader :log + + def initialize(&block) + @log = [] + + ActiveRecord::Base.connection.unprepared_statement do + ActiveSupport::Notifications.subscribed(method(:callback), 'sql.active_record', &block) + end + end + + def callback(_name, _start, _finish, _message_id, values) + @log << values[:sql] + end + + def self.log(&block) + new(&block).log + end +end diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 111fac6f8a5..7f286ead925 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -196,6 +196,8 @@ module Gitlab when AccessTokenValidationService::EXPIRED raise ExpiredError when AccessTokenValidationService::REVOKED + revoke_token_family(access_token) + raise RevokedError when AccessTokenValidationService::IMPERSONATION_DISABLED raise ImpersonationDisabled @@ -399,6 +401,10 @@ module Gitlab raise UnauthorizedError unless job end end + + def revoke_token_family(token) + PersonalAccessTokens::RevokeTokenFamilyService.new(token).execute + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 12dfa4839ed..a4b2190b4fc 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5855,6 +5855,12 @@ msgstr "" msgid "ApprovalRule|Rule name" msgstr "" +msgid "ApprovalRule|Search in" +msgstr "" + +msgid "ApprovalRule|Search users or groups" +msgstr "" + msgid "ApprovalRule|Select eligible approvers by expertise or files changed." msgstr "" @@ -5864,6 +5870,12 @@ msgstr "" msgid "ApprovalRule|Try for free" msgstr "" +msgid "ApprovalRule|all groups" +msgstr "" + +msgid "ApprovalRule|project groups" +msgstr "" + msgid "ApprovalSettings|Keep approvals" msgstr "" @@ -40890,9 +40902,6 @@ msgstr "" msgid "Search users" msgstr "" -msgid "Search users or groups" -msgstr "" - msgid "Search your projects" msgstr "" @@ -51824,16 +51833,13 @@ msgstr "" msgid "WikiEmpty|You've enabled the Confluence Workspace integration. Your wiki will be viewable directly within Confluence. We are hard at work integrating Confluence more seamlessly into GitLab. If you'd like to stay up to date, follow our %{wiki_confluence_epic_link_start}Confluence epic%{wiki_confluence_epic_link_end}." msgstr "" -msgid "WikiHistoricalPage|This is an old version of this page." +msgid "WikiHistoricalPage|Browse history" msgstr "" -msgid "WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}." +msgid "WikiHistoricalPage|Go to most recent version" msgstr "" -msgid "WikiHistoricalPage|history" -msgstr "" - -msgid "WikiHistoricalPage|most recent version" +msgid "WikiHistoricalPage|This is an old version of this page." msgstr "" msgid "WikiPageConfirmDelete|Are you sure you want to delete this page?" diff --git a/spec/controllers/concerns/onboarding/status_spec.rb b/spec/controllers/concerns/onboarding/status_spec.rb new file mode 100644 index 00000000000..3f6e597a235 --- /dev/null +++ b/spec/controllers/concerns/onboarding/status_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Onboarding::Status, feature_category: :onboarding do + let_it_be(:member) { create(:group_member) } + let_it_be(:user) { member.user } + let_it_be(:tasks_to_be_done) { %w[ci code] } + let_it_be(:source) { member.group } + + describe '#continue_full_onboarding?' do + subject { described_class.new(nil).continue_full_onboarding? } + + it { is_expected.to eq(false) } + end + + describe '#single_invite?' do + subject { described_class.new(user).single_invite? } + + context 'when there is only one member for the user' do + context 'when the member source exists' do + it { is_expected.to eq(true) } + end + end + + context 'when there is more than one member for the user' do + before do + create(:group_member, user: user) + end + + it { is_expected.to eq(false) } + end + + context 'when there are no members for the user' do + let(:user) { build_stubbed(:user) } + + it { is_expected.to eq(false) } + end + end + + describe '#last_invited_member' do + subject { described_class.new(user).last_invited_member } + + it { is_expected.to eq(member) } + + context 'when another member exists and is most recent' do + let!(:last_member) { create(:group_member, user: user) } + + it { is_expected.to eq(last_member) } + end + + context 'when there are no members' do + let_it_be(:user) { build_stubbed(:user) } + + it { is_expected.to be_nil } + end + end + + describe '#last_invited_member_source' do + subject { described_class.new(user).last_invited_member_source } + + context 'when a member exists' do + it { is_expected.to eq(source) } + end + + context 'when no members exist' do + let_it_be(:user) { build_stubbed(:user) } + + it { is_expected.to be_nil } + end + + context 'when another member exists and is most recent' do + let!(:last_member_source) { create(:group_member, user: user).group } + + it { is_expected.to eq(last_member_source) } + end + end + + describe '#invite_with_tasks_to_be_done?' do + subject { described_class.new(user).invite_with_tasks_to_be_done? } + + context 'when there are tasks_to_be_done with one member' do + let_it_be(:member) { create(:group_member, user: user, tasks_to_be_done: tasks_to_be_done) } + + it { is_expected.to eq(true) } + end + + context 'when there are multiple members and the tasks_to_be_done is on only one of them' do + before do + create(:group_member, user: user, tasks_to_be_done: tasks_to_be_done) + end + + it { is_expected.to eq(true) } + end + + context 'when there are no tasks_to_be_done' do + it { is_expected.to eq(false) } + end + + context 'when there are no members' do + let_it_be(:user) { build_stubbed(:user) } + + it { is_expected.to eq(false) } + end + end +end diff --git a/spec/controllers/registrations/welcome_controller_spec.rb b/spec/controllers/registrations/welcome_controller_spec.rb index 4118754144c..5a3feefc1ba 100644 --- a/spec/controllers/registrations/welcome_controller_spec.rb +++ b/spec/controllers/registrations/welcome_controller_spec.rb @@ -117,7 +117,7 @@ RSpec.describe Registrations::WelcomeController, feature_category: :system_acces end context 'when the new user already has more than 1 accepted group membership' do - it 'redirects to the most recent membership group activty page' do + it 'redirects to the most recent membership group activity page' do member2 = create(:group_member, user: user) expect(subject).to redirect_to(activity_group_path(member2.source)) diff --git a/spec/frontend/batch_comments/components/submit_dropdown_spec.js b/spec/frontend/batch_comments/components/submit_dropdown_spec.js index 5c33df882bf..7e2ff7f786f 100644 --- a/spec/frontend/batch_comments/components/submit_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/submit_dropdown_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import SubmitDropdown from '~/batch_comments/components/submit_dropdown.vue'; +import { mockTracking } from 'helpers/tracking_helper'; jest.mock('~/autosave'); @@ -10,9 +11,11 @@ Vue.use(Vuex); let wrapper; let publishReview; +let trackingSpy; function factory({ canApprove = true, shouldAnimateReviewButton = false } = {}) { publishReview = jest.fn(); + trackingSpy = mockTracking(undefined, null, jest.spyOn); const store = new Vuex.Store({ getters: { @@ -69,6 +72,20 @@ describe('Batch comments submit dropdown', () => { }); }); + it('tracks submit action', () => { + factory(); + + findCommentTextarea().setValue('Hello world'); + + findForm().vm.$emit('submit', { preventDefault: jest.fn() }); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'MergeRequest_review', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); + }); + it('switches to the overview tab after submit', async () => { window.mrTabs = { tabShown: jest.fn() }; diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js index 864b29782d4..e4373d1c198 100644 --- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -1,4 +1,10 @@ -import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm, GlIcon } from '@gitlab/ui'; +import { + GlModal, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDropdownForm, + GlIcon, +} from '@gitlab/ui'; import { createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -35,9 +41,10 @@ Vue.use(VueApollo); describe('RegistrationDropdown', () => { let wrapper; - const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findDropdownBtn = () => findDropdown().find('button'); - const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findRegistrationInstructionsDropdownItem = () => + wrapper.findComponent(GlDisclosureDropdownItem); const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); const findRegistrationTokenInput = () => @@ -54,9 +61,8 @@ describe('RegistrationDropdown', () => { .replace(/[\n\t\s]+/g, ' '); const openModal = async () => { - await findRegistrationInstructionsDropdownItem().trigger('click'); + await findRegistrationInstructionsDropdownItem().vm.$emit('action'); findModal().vm.$emit('shown'); - await waitForPromises(); }; @@ -67,6 +73,9 @@ describe('RegistrationDropdown', () => { type: INSTANCE_TYPE, ...props, }, + stubs: { + GlDisclosureDropdownItem, + }, ...options, }); }; @@ -188,6 +197,26 @@ describe('RegistrationDropdown', () => { }); }); + describe('Dropdown is expanded', () => { + beforeEach(() => { + createComponent({}, mountExtended); + findDropdownBtn().vm.$emit('click'); + }); + + it('has aria-expanded set to true', () => { + expect(findDropdownBtn().attributes('aria-expanded')).toBe('true'); + }); + + describe('when token is copied', () => { + it('should close dropdown', async () => { + findRegistrationToken().vm.$emit('copy'); + await nextTick(); + + expect(findDropdownBtn().attributes('aria-expanded')).toBeUndefined(); + }); + }); + }); + describe('When token is reset', () => { const newToken = 'mock1'; @@ -226,7 +255,8 @@ describe('RegistrationDropdown', () => { expect(findDropdown().props()).toMatchObject({ category: 'tertiary', variant: 'default', - text: '', + toggleText: I18N_REGISTER_INSTANCE_TYPE, + textSrOnly: true, }); expect(findDropdown().attributes()).toMatchObject({ diff --git a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js index db54bf0c80e..d599bc1291c 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui'; +import { GlDisclosureDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; @@ -27,7 +27,7 @@ describe('RegistrationTokenResetDropdownItem', () => { let showToast; const mockEvent = { preventDefault: jest.fn() }; - const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findModal = () => wrapper.findComponent(GlModal); const clickSubmit = () => findModal().vm.$emit('primary', mockEvent); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_spec.js index 48e6fb5d0ce..fd3896d5500 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_spec.js @@ -64,6 +64,12 @@ describe('RegistrationToken', () => { expect(showToastMock).toHaveBeenCalledTimes(1); expect(showToastMock).toHaveBeenCalledWith('Registration token copied!'); }); + + it('emits a copy event', () => { + findInputCopyToggleVisibility().vm.$emit('copy'); + + expect(wrapper.emitted('copy')).toHaveLength(1); + }); }); describe('When slots are used', () => { diff --git a/spec/frontend/design_management/components/design_description/description_form_spec.js b/spec/frontend/design_management/components/design_description/description_form_spec.js index 1eb0e8898d4..a61cc2af9b6 100644 --- a/spec/frontend/design_management/components/design_description/description_form_spec.js +++ b/spec/frontend/design_management/components/design_description/description_form_spec.js @@ -1,18 +1,15 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; - import { GlAlert } from '@gitlab/ui'; - import { mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; - import DescriptionForm from '~/design_management/components/design_description/description_form.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import updateDesignDescriptionMutation from '~/design_management/graphql/mutations/update_design_description.mutation.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; - +import { mockTracking } from 'helpers/tracking_helper'; import { designFactory, designUpdateFactory } from '../../mock_data/apollo_mock'; jest.mock('~/behaviors/markdown/render_gfm'); @@ -86,6 +83,8 @@ describe('Design description form', () => { const findAlert = () => wrapper.findComponent(GlAlert); describe('user has updateDesign permission', () => { + let trackingSpy; + const ctrlKey = { ctrlKey: true, }; @@ -96,6 +95,8 @@ describe('Design description form', () => { const errorMessage = 'Could not update description. Please try again.'; beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); + createComponent(); }); @@ -142,15 +143,16 @@ describe('Design description form', () => { }); }); - it.each` + describe.each` isKeyEvent | assertionName | key | keyData ${true} | ${'Ctrl + Enter keypress'} | ${'ctrl'} | ${ctrlKey} ${true} | ${'Meta + Enter keypress'} | ${'meta'} | ${metaKey} ${false} | ${'Save button click'} | ${''} | ${null} - `( - 'hides form and calls mutation when form is submitted via $assertionName', - async ({ isKeyEvent, keyData }) => { - const mockDesignUpdateResponseHandler = jest.fn().mockResolvedValue( + `('when form is submitted via $assertionName', ({ isKeyEvent, keyData }) => { + let mockDesignUpdateResponseHandler; + + beforeEach(async () => { + mockDesignUpdateResponseHandler = jest.fn().mockResolvedValue( designUpdateFactory({ description: mockDescription, descriptionHtml: `<p data-sourcepos="1:1-1:16" dir="auto">${mockDescription}</p>`, @@ -170,7 +172,9 @@ describe('Design description form', () => { } await nextTick(); + }); + it('hides form and calls mutation', async () => { expect(mockDesignUpdateResponseHandler).toHaveBeenCalledWith({ input: { description: 'Hello world', @@ -181,8 +185,16 @@ describe('Design description form', () => { await waitForPromises(); expect(findMarkdownEditor().exists()).toBe(false); - }, - ); + }); + + it('tracks submit action', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Design', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); + }); + }); it('shows error message when mutation fails', async () => { const failureHandler = jest.fn().mockRejectedValue(new Error(errorMessage)); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap deleted file mode 100644 index 9c131b0b650..00000000000 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap +++ /dev/null @@ -1,307 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design note component default should match the snapshot 1`] = ` -<timelineentryitem-stub - class="design-note note-form" - id="note_123" -> - <glavatarlink-stub - class="gl-float-left gl-mr-3 link-inherit-color js-user-link" - data-user-id="1" - data-username="foo-bar" - href="https://gitlab.com/user" - > - <glavatar-stub - alt="avatar" - entityid="0" - entityname="foo-bar" - shape="circle" - size="32" - src="https://gitlab.com/avatar" - /> - </glavatarlink-stub> - - <div - class="gl-display-flex gl-justify-content-space-between" - > - <div> - <gllink-stub - class="js-user-link link-inherit-color" - data-testid="user-link" - data-user-id="1" - data-username="foo-bar" - href="https://gitlab.com/user" - > - <span - class="note-header-author-name gl-font-weight-bold" - > - - </span> - - <!----> - - <span - class="note-headline-light" - > - @foo-bar - </span> - </gllink-stub> - - <span - class="note-headline-light note-headline-meta" - > - <span - class="system-note-message" - /> - - <gllink-stub - class="note-timestamp system-note-separator gl-display-block gl-mb-2 gl-font-sm link-inherit-color" - href="#note_123" - > - <timeagotooltip-stub - cssclass="" - datetimeformat="DATE_WITH_TIME_FORMAT" - time="2019-07-26T15:02:20Z" - tooltipplacement="bottom" - /> - </gllink-stub> - </span> - </div> - - <div - class="gl-display-flex gl-align-items-flex-start gl-mt-n2 gl-mr-n2" - > - - <div - class="emoji-picker" - data-testid="note-emoji-button" - > - <div - boundary="viewport" - class="dropdown b-dropdown gl-dropdown position-static btn-group" - id="__BVID__15" - lazy="" - menu-class="dropdown-extended-height" - no-flip="" - > - <!----> - <button - aria-expanded="false" - aria-haspopup="menu" - class="btn dropdown-toggle btn-default btn-md note-action-button note-emoji-button btn-icon btn-default-tertiary gl-button gl-dropdown-toggle btn-default-secondary" - id="__BVID__15__BV_toggle_" - type="button" - > - <svg - aria-hidden="true" - class="award-control-icon-neutral gl-button-icon gl-icon gl-icon s16" - data-testid="slight-smile-icon" - role="img" - > - <use - href="file-mock#slight-smile" - /> - </svg> - - <svg - aria-hidden="true" - class="award-control-icon-positive gl-button-icon gl-icon gl-left-3! gl-icon s16" - data-testid="smiley-icon" - role="img" - > - <use - href="file-mock#smiley" - /> - </svg> - - <svg - aria-hidden="true" - class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3! gl-icon s16" - data-testid="smile-icon" - role="img" - > - <use - href="file-mock#smile" - /> - </svg> - </button> - <ul - aria-labelledby="__BVID__15__BV_toggle_" - class="dropdown-menu dropdown-extended-height" - role="menu" - tabindex="-1" - > - <!----> - </ul> - </div> - </div> - - <!----> - - <!----> - </div> - </div> - - <div - class="note-text md" - data-qa-selector="note_content" - data-testid="note-text" - /> - - <div - class="awards js-awards-block gl-px-2 gl-mt-5" - > - <button - class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" - data-emoji-name="briefcase" - data-testid="award-button" - title="You reacted with :briefcase:" - type="button" - > - <!----> - - <!----> - - <span - class="award-emoji-block" - data-testid="award-html" - > - <gl-emoji - data-name="briefcase" - /> - </span> - - <span - class="gl-button-text" - > - - <span - class="js-counter" - > - 1 - </span> - </span> - </button> - <button - class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" - data-emoji-name="baseball" - data-testid="award-button" - title="You reacted with :baseball:" - type="button" - > - <!----> - - <!----> - - <span - class="award-emoji-block" - data-testid="award-html" - > - <gl-emoji - data-name="baseball" - /> - </span> - - <span - class="gl-button-text" - > - - <span - class="js-counter" - > - 1 - </span> - </span> - </button> - - <div - class="award-menu-holder gl-my-2" - > - <div - class="emoji-picker" - data-testid="emoji-picker" - title="Add reaction" - > - <div - boundary="scrollParent" - class="dropdown b-dropdown gl-dropdown btn-group" - id="__BVID__25" - lazy="" - menu-class="dropdown-extended-height" - no-flip="" - > - <!----> - <button - aria-expanded="false" - aria-haspopup="menu" - class="btn dropdown-toggle btn-default btn-md add-reaction-button btn-icon gl-relative! gl-button gl-dropdown-toggle btn-default-secondary" - id="__BVID__25__BV_toggle_" - type="button" - > - <span - class="gl-sr-only" - > - Add reaction - </span> - - <span - class="reaction-control-icon reaction-control-icon-neutral" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="slight-smile-icon" - role="img" - > - <use - href="file-mock#slight-smile" - /> - </svg> - </span> - - <span - class="reaction-control-icon reaction-control-icon-positive" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="smiley-icon" - role="img" - > - <use - href="file-mock#smiley" - /> - </svg> - </span> - - <span - class="reaction-control-icon reaction-control-icon-super-positive" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="smile-icon" - role="img" - > - <use - href="file-mock#smile" - /> - </svg> - </span> - </button> - <ul - aria-labelledby="__BVID__25__BV_toggle_" - class="dropdown-menu dropdown-extended-height" - role="menu" - tabindex="-1" - > - <!----> - </ul> - </div> - </div> - </div> - </div> - - <!----> -</timelineentryitem-stub> -`; diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 55188b6a3ec..8795b089551 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -109,10 +109,6 @@ describe('Design note component', () => { createComponent({ props: { note } }); }); - it('should match the snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - it('should render avatar with correct props', () => { expect(findUserAvatar().props()).toMatchObject({ src: note.author.avatarUrl, diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index d7e5f9083b0..b9652327e3d 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -4,6 +4,9 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import IssuableForm from '~/issuable/issuable_form'; import setWindowLocation from 'helpers/set_window_location_helper'; import { confirmSensitiveAction, i18n } from '~/lib/utils/secret_detection'; +import { mockTracking } from 'helpers/tracking_helper'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import { getSaveableFormChildren } from './helpers'; jest.mock('~/autosave'); @@ -20,9 +23,12 @@ const createIssuable = (form) => { }; describe('IssuableForm', () => { + let trackingSpy; let $form; let instance; + useLocalStorageSpy(); + beforeEach(() => { setHTMLFixture(` <form> @@ -32,6 +38,7 @@ describe('IssuableForm', () => { </form> `); $form = $('form'); + trackingSpy = mockTracking(undefined, null, jest.spyOn); }); afterEach(() => { @@ -266,6 +273,34 @@ describe('IssuableForm', () => { expect(resetAutosave).toHaveBeenCalled(); }); + it.each` + windowLocation | context | localStorageValue | editorType + ${'/gitlab-org/gitlab/-/issues/412699'} | ${'Issue'} | ${'contentEditor'} | ${'editor_type_rich_text_editor'} + ${'/gitlab-org/gitlab/-/merge_requests/125979/diffs'} | ${'MergeRequest'} | ${'contentEditor'} | ${'editor_type_rich_text_editor'} + ${'/groups/gitlab-org/-/milestones/8/edit'} | ${'Other'} | ${'contentEditor'} | ${'editor_type_rich_text_editor'} + ${'/gitlab-org/gitlab/-/issues/412699'} | ${'Issue'} | ${'markdownField'} | ${'editor_type_plain_text_editor'} + ${'/gitlab-org/gitlab/-/merge_requests/125979/diffs'} | ${'MergeRequest'} | ${'markdownField'} | ${'editor_type_plain_text_editor'} + ${'/groups/gitlab-org/-/milestones/8/edit'} | ${'Other'} | ${'markdownField'} | ${'editor_type_plain_text_editor'} + `( + 'tracks event on form submit', + ({ windowLocation, context, localStorageValue, editorType }) => { + setWindowLocation(`${TEST_HOST}/${windowLocation}`); + localStorage.setItem('gl-markdown-editor-mode', localStorageValue); + + issueDescription.value = 'sample message'; + + createIssuable($form); + + $form.submit(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context, + editorType, + label: 'editor_tracking', + }); + }, + ); + it('prevents form submission when token is present', () => { issueDescription.value = sensitiveMessage; diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index aa2571ab6d7..5e329d44acb 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -3,9 +3,11 @@ import DescriptionField from '~/issues/show/components/fields/description.vue'; import eventHub from '~/issues/show/event_hub'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { mockTracking } from 'helpers/tracking_helper'; describe('Description field component', () => { let wrapper; + let trackingSpy; const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); const mountComponent = ({ description = 'test', contentEditorOnIssues = false } = {}) => { @@ -28,6 +30,7 @@ describe('Description field component', () => { }; beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); jest.spyOn(eventHub, '$emit'); mountComponent({ contentEditorOnIssues: true }); @@ -52,24 +55,31 @@ describe('Description field component', () => { }); }); - it('triggers update with meta+enter', () => { - findMarkdownEditor().vm.$emit('keydown', { - type: 'keydown', - keyCode: 13, - metaKey: true, + describe.each` + testDescription | metaKey | ctrlKey + ${'when meta+enter is pressed'} | ${true} | ${false} + ${'when ctrl+enter is pressed'} | ${false} | ${true} + `('$testDescription', ({ metaKey, ctrlKey }) => { + beforeEach(() => { + findMarkdownEditor().vm.$emit('keydown', { + type: 'keydown', + keyCode: 13, + metaKey, + ctrlKey, + }); }); - expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); - }); - - it('triggers update with ctrl+enter', () => { - findMarkdownEditor().vm.$emit('keydown', { - type: 'keydown', - keyCode: 13, - ctrlKey: true, + it('triggers update', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); }); - expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + it('tracks event', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Issue', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); + }); }); it('emits input event when MarkdownEditor emits input event', () => { diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 9fc688fd8a6..a6d88bdd310 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -20,6 +20,7 @@ import eventHub from '~/notes/event_hub'; import { COMMENT_FORM } from '~/notes/i18n'; import notesModule from '~/notes/stores/modules'; import { sprintf } from '~/locale'; +import { mockTracking } from 'helpers/tracking_helper'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; jest.mock('autosize'); @@ -31,6 +32,7 @@ Vue.use(Vuex); describe('issue_comment_form component', () => { useLocalStorageSpy(); + let trackingSpy; let store; let wrapper; let axiosMock; @@ -137,6 +139,7 @@ describe('issue_comment_form component', () => { beforeEach(() => { axiosMock = new MockAdapter(axios); store = createStore(); + trackingSpy = mockTracking(undefined, null, jest.spyOn); }); afterEach(() => { @@ -159,6 +162,21 @@ describe('issue_comment_form component', () => { expect(wrapper.vm.stopPolling).toHaveBeenCalled(); }); + it('tracks event', () => { + mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + jest.spyOn(wrapper.vm, 'stopPolling'); + + findCloseReopenButton().trigger('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Issue_comment', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); + }); + it('does not report errors in the UI when the save succeeds', async () => { mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } }); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index fb81d5ce6d1..645aef21e38 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -7,6 +7,7 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { AT_WHO_ACTIVE_CLASS } from '~/gfm_auto_complete'; import eventHub from '~/environments/event_hub'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking } from 'helpers/tracking_helper'; import { noteableDataMock, notesDataMock, discussionMock, note } from '../mock_data'; jest.mock('~/lib/utils/autosave'); @@ -15,6 +16,7 @@ describe('issue_note_form component', () => { let store; let wrapper; let props; + let trackingSpy; const createComponentWrapper = (propsData = {}, provide = {}) => { wrapper = mountExtended(NoteForm, { @@ -52,6 +54,7 @@ describe('issue_note_form component', () => { noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.', noteId: '545', }; + trackingSpy = mockTracking(undefined, null, jest.spyOn); }); describe('noteHash', () => { @@ -222,6 +225,21 @@ describe('issue_note_form component', () => { expect(wrapper.emitted('handleFormUpdate')).toHaveLength(1); }); + + it('tracks event when save button is clicked', () => { + createComponentWrapper(); + + const textarea = wrapper.find('textarea'); + textarea.setValue('Foo'); + const saveButton = wrapper.find('.js-vue-issue-save'); + saveButton.vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Issue_note', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); + }); }); }); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js index d5b7ad73177..94549c4a73b 100644 --- a/spec/frontend/notes/mock_data.js +++ b/spec/frontend/notes/mock_data.js @@ -60,7 +60,7 @@ export const noteableDataMock = { updated_at: '2017-08-04T09:53:01.226Z', updated_by_id: 1, web_url: '/gitlab-org/gitlab-foss/issues/26', - noteableType: 'issue', + noteableType: 'Issue', blocked_by_issues: [], }; diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 3b2533b1a27..db889abad88 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -7,13 +7,7 @@ import { mockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { - CONTENT_EDITOR_LOADED_ACTION, - SAVED_USING_CONTENT_EDITOR_ACTION, - WIKI_CONTENT_EDITOR_TRACKING_LABEL, - WIKI_FORMAT_LABEL, - WIKI_FORMAT_UPDATED_ACTION, -} from '~/pages/shared/wikis/constants'; +import { WIKI_FORMAT_LABEL, WIKI_FORMAT_UPDATED_ACTION } from '~/pages/shared/wikis/constants'; import { DRAWIO_ORIGIN } from 'spec/test_constants'; jest.mock('~/emoji'); @@ -234,7 +228,22 @@ describe('WikiForm', () => { }); it('triggers wiki format tracking event', () => { - expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'wiki_format_updated', { + extra: { + old_format: 'markdown', + project_path: '/project/path/-/wikis/home', + value: 'markdown', + }, + label: 'wiki_format', + }); + }); + + it('tracks editor type used', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'editor_type_used', { + context: 'Wiki', + editorType: 'editor_type_plain_text_editor', + label: 'editor_tracking', + }); }); it('does not trim page content', () => { @@ -316,12 +325,6 @@ describe('WikiForm', () => { expect(findFormat().element.getAttribute('disabled')).toBeDefined(); }); - it('sends tracking event when editor loads', () => { - expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, { - label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, - }); - }); - describe('when triggering form submit', () => { const updatedMarkdown = 'hello **world**'; @@ -331,10 +334,6 @@ describe('WikiForm', () => { }); it('triggers tracking events on form submit', () => { - expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { - label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, - }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { label: WIKI_FORMAT_LABEL, extra: { diff --git a/spec/graphql/types/ide_type_spec.rb b/spec/graphql/types/ide_type_spec.rb new file mode 100644 index 00000000000..b0e43332fa8 --- /dev/null +++ b/spec/graphql/types/ide_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['Ide'], feature_category: :web_ide do + specify { expect(described_class.graphql_name).to eq('Ide') } + + it 'has the expected fields' do + expected_fields = %w[ + codeSuggestionsEnabled + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb index 7c280b9da42..af0f8a86b6c 100644 --- a/spec/graphql/types/user_type_spec.rb +++ b/spec/graphql/types/user_type_spec.rb @@ -56,6 +56,7 @@ RSpec.describe GitlabSchema.types['User'], feature_category: :user_profile do jobTitle createdAt pronouns + ide ] expect(described_class).to include_graphql_fields(*expected_fields) @@ -78,13 +79,13 @@ RSpec.describe GitlabSchema.types['User'], feature_category: :user_profile do let(:username) { requested_user.username } let(:query) do - %( + <<~GQL query { user(username: "#{username}") { name } } - ) + GQL end subject(:user_name) { GitlabSchema.execute(query, context: { current_user: current_user }).as_json.dig('data', 'user', 'name') } @@ -255,4 +256,40 @@ RSpec.describe GitlabSchema.types['User'], feature_category: :user_profile do is_expected.to have_graphql_type(Types::Users::NamespaceCommitEmailType.connection_type) end end + + describe 'ide field' do + subject { described_class.fields['ide'] } + + it 'returns ide' do + is_expected.to have_graphql_type(Types::IdeType) + end + + context 'code suggestions enabled' do + let(:current_user) { create(:user) } + let(:query) do + <<~GQL + query { + currentUser { + ide { + codeSuggestionsEnabled + } + } + } + GQL + end + + subject(:code_suggestions_enabled) do + GitlabSchema.execute(query, context: { current_user: current_user }) + .as_json + .dig('data', 'currentUser', 'ide', 'codeSuggestionsEnabled') + end + + it 'returns code suggestions enabled' do + allow(current_user).to receive(:can?).with(:access_code_suggestions).and_return(true) + + expect(current_user).to receive(:can?).with(:access_code_suggestions).and_return(true) + expect(code_suggestions_enabled).to be true + end + end + end end diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index 1a8a2ec2980..3d61339ba4e 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -505,6 +505,26 @@ RSpec.describe Gitlab::Auth::AuthFinders, feature_category: :system_access do end end end + + context 'automatic reuse detection' do + let_it_be(:token_3) { create(:personal_access_token, :revoked) } + let_it_be(:token_2) { create(:personal_access_token, :revoked, previous_personal_access_token_id: token_3.id) } + let_it_be(:token_1) { create(:personal_access_token, previous_personal_access_token_id: token_2.id) } + + context 'when a revoked token is used' do + before do + set_bearer_token(token_3.token) + end + + it 'revokes the latest rotated token' do + expect(token_1).not_to be_revoked + + expect { find_user_from_access_token }.to raise_error(Gitlab::Auth::RevokedError) + + expect(token_1.reload).to be_revoked + end + end + end end describe '#find_user_from_web_access_token' do diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index fd60d279110..7437e9b463e 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -21,6 +21,12 @@ RSpec.describe PersonalAccessToken, feature_category: :system_access do end end + describe 'associations' do + subject(:project_access_token) { create(:personal_access_token) } + + it { is_expected.to belong_to(:previous_personal_access_token).class_name('PersonalAccessToken') } + end + describe 'scopes' do describe '.project_access_tokens' do let_it_be(:user) { create(:user, :project_bot) } diff --git a/spec/services/personal_access_tokens/revoke_token_family_service_spec.rb b/spec/services/personal_access_tokens/revoke_token_family_service_spec.rb new file mode 100644 index 00000000000..3e32200cc77 --- /dev/null +++ b/spec/services/personal_access_tokens/revoke_token_family_service_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe PersonalAccessTokens::RevokeTokenFamilyService, feature_category: :system_access do + describe '#execute' do + let_it_be(:token_3) { create(:personal_access_token, :revoked) } + let_it_be(:token_2) { create(:personal_access_token, :revoked, previous_personal_access_token_id: token_3.id) } + let_it_be(:token_1) { create(:personal_access_token, previous_personal_access_token_id: token_2.id) } + + subject(:response) { described_class.new(token_3).execute } + + it 'revokes the latest token from the chain of rotated tokens' do + expect(response).to be_success + expect(token_1.reload).to be_revoked + end + end +end diff --git a/spec/services/personal_access_tokens/rotate_service_spec.rb b/spec/services/personal_access_tokens/rotate_service_spec.rb index e026b0b6485..522506870f6 100644 --- a/spec/services/personal_access_tokens/rotate_service_spec.rb +++ b/spec/services/personal_access_tokens/rotate_service_spec.rb @@ -25,6 +25,13 @@ RSpec.describe PersonalAccessTokens::RotateService, feature_category: :system_ac expect(new_token).not_to be_revoked end + it 'saves the previous token as previous PAT attribute' do + response + + new_token = response.payload[:personal_access_token] + expect(new_token.previous_personal_access_token).to eql(token) + end + context 'when user tries to rotate already revoked token' do let_it_be(:token, reload: true) { create(:personal_access_token, :revoked) } diff --git a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb index 03a3f02ae1d..c32e758d921 100644 --- a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb +++ b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb @@ -51,6 +51,7 @@ RSpec.shared_examples "a user type with merge request interaction type" do jobTitle createdAt pronouns + ide ] # TODO: 'workspaces' needs to be included, but only when this spec is run in EE context, to account for the diff --git a/spec/views/registrations/welcome/show.html.haml_spec.rb b/spec/views/registrations/welcome/show.html.haml_spec.rb index e229df555b1..866f4f62493 100644 --- a/spec/views/registrations/welcome/show.html.haml_spec.rb +++ b/spec/views/registrations/welcome/show.html.haml_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'registrations/welcome/show' do +RSpec.describe 'registrations/welcome/show', feature_category: :onboarding do let_it_be(:user) { create(:user) } before do |