diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-11 15:09:05 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-11 15:09:05 +0300 |
commit | 28e90894e1e6f17320f5b1d2fff6fe736bf65dff (patch) | |
tree | 21d63bf124b6064eb1650acc3e2aabe6dbc99f58 /app | |
parent | a48957b317edf23b1bcfc6df0c098a824eae86f4 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
23 files changed, 222 insertions, 433 deletions
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue index 6e7c87b8515..69021dde0e9 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue @@ -70,7 +70,7 @@ export default { captureException({ error, component: this.$options.name }); }, pollInterval() { - if (this.runner?.status === STATUS_ONLINE) { + if (this.isRunnerOnline) { // stop polling return 0; } @@ -97,9 +97,6 @@ export default { } return s__('Runners|Register runner'); }, - status() { - return this.runner?.status; - }, tokenMessage() { if (this.token) { return s__( @@ -122,15 +119,34 @@ export default { runCommand() { return runCommand({ platform: this.platform }); }, + isRunnerOnline() { + return this.runner?.status === STATUS_ONLINE; + }, + }, + created() { + window.addEventListener('beforeunload', this.onBeforeunload); + }, + destroyed() { + window.removeEventListener('beforeunload', this.onBeforeunload); }, methods: { toggleDrawer() { this.$emit('toggleDrawer'); }, + onBeforeunload(event) { + if (this.isRunnerOnline) { + return undefined; + } + + const str = s__('Runners|You may lose access to the runner token if you leave this page.'); + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.returnValue = str; // Chrome requires returnValue to be set + return str; + }, }, EXECUTORS_HELP_URL, SERVICE_COMMANDS_HELP_URL, - STATUS_ONLINE, I18N_REGISTRATION_SUCCESS, }; </script> @@ -225,7 +241,7 @@ export default { </gl-sprintf> </p> </section> - <section v-if="status == $options.STATUS_ONLINE"> + <section v-if="isRunnerOnline"> <h2 class="gl-font-size-h2">🎉 {{ $options.I18N_REGISTRATION_SUCCESS }}</h2> <p class="gl-pl-6"> diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index f482fabc5f6..3e310f941ec 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -81,6 +81,14 @@ export const config = { }); }, }, + userPermissions: { + read(permission = {}) { + return { + ...permission, + setWorkItemMetadata: false, + }; + }, + }, }, }, MemberInterfaceConnection: { diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue index 34b7d95322e..d1c0e757a91 100644 --- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -92,6 +92,6 @@ export default { {{ $options.i18n.emptyHint }} </div> </gl-collapse> - <hr class="gl-my-2" /> + <hr class="gl-my-2 gl-mx-4" /> </section> </template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index 2bb355736fd..3fdc5124111 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -134,7 +134,7 @@ export default { <ul class="gl-p-0 gl-m-0"> <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static /> </ul> - <hr class="gl-my-2" /> + <hr class="gl-my-2 gl-mx-4" /> </section> <pinned-section diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index 082c261977b..650fa798db6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -130,7 +130,7 @@ export default { <span v-if="approvalLeftMessage">{{ message }}</span> <span v-else class="gl-font-weight-bold">{{ message }}</span> <user-avatar-list - class="gl-display-inline-block gl-vertical-align-middle gl-pt-1" + class="gl-display-inline-flex gl-vertical-align-middle" :img-size="24" :items="approvers" /> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index 71cf85c75a7..6552a874c3a 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -90,7 +90,7 @@ export default { </script> <template> - <span ref="userAvatar" class="gl-display-inline-flex"> + <span ref="userAvatar"> <gl-avatar :class="{ lazy: lazy, diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index 8a05960869c..713c08e20e9 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -1,8 +1,8 @@ <script> -import { GlAvatar, GlButton } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; import { ASC } from '~/notes/constants'; +import { __ } from '~/locale'; import { clearDraft } from '~/lib/utils/autosave'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getWorkItemQuery } from '../../utils'; @@ -17,8 +17,6 @@ export default { avatarUrl: window.gon.current_user_avatar_url, }, components: { - GlAvatar, - GlButton, WorkItemNoteSignedOut, WorkItemCommentLocked, WorkItemCommentForm, @@ -75,11 +73,16 @@ export default { required: false, default: () => ({}), }, + isNewDiscussion: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { workItem: {}, - isEditing: false, + isEditing: this.isNewDiscussion, isSubmitting: false, isSubmittingWithKeydown: false, }; @@ -118,23 +121,9 @@ export default { property: `type_${this.workItemType}`, }; }, - isLockedOutOrSignedOut() { - return !this.signedIn || !this.canUpdate; - }, - lockedOutUserWarningInReplies() { - return this.addPadding && this.isLockedOutOrSignedOut; - }, - timelineEntryClass() { - return { - 'timeline-entry gl-mb-3 note note-wrapper note-comment': true, - 'gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-p-5! gl-mx-n3 gl-mb-n2!': this - .lockedOutUserWarningInReplies, - }; - }, timelineEntryInnerClass() { return { - 'timeline-entry-inner': true, - 'gl-pb-3': this.addPadding, + 'timeline-entry-inner': this.isNewDiscussion, }; }, timelineContentClass() { @@ -155,6 +144,18 @@ export default { canUpdate() { return this.workItem?.userPermissions?.updateWorkItem; }, + workItemState() { + return this.workItem?.state; + }, + commentButtonText() { + return this.isNewDiscussion ? __('Comment') : __('Reply'); + }, + timelineEntryClass() { + return this.isNewDiscussion + ? 'timeline-entry note-form' + : // eslint-disable-next-line @gitlab/require-i18n-strings + 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix'; + }, }, watch: { autofocus: { @@ -226,9 +227,13 @@ export default { } }, cancelEditing() { - this.isEditing = false; + this.isEditing = this.isNewDiscussion; this.$emit('cancelEditing'); }, + showReplyForm() { + this.isEditing = true; + this.$emit('startReplying'); + }, }, }; </script> @@ -242,9 +247,6 @@ export default { :is-project-archived="isProjectArchived" /> <div v-else :class="timelineEntryInnerClass"> - <div class="timeline-avatar gl-float-left"> - <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" /> - </div> <div :class="timelineContentClass"> <div :class="parentClass"> <work-item-comment-form @@ -253,17 +255,27 @@ export default { :aria-label="__('Add a reply')" :is-submitting="isSubmitting" :autosave-key="autosaveKey" + :is-new-discussion="isNewDiscussion" :autocomplete-data-sources="autocompleteDataSources" :markdown-preview-path="markdownPreviewPath" + :work-item-state="workItemState" + :work-item-id="workItemId" + :autofocus="autofocus" + :comment-button-text="commentButtonText" @submitForm="updateWorkItem" @cancelEditing="cancelEditing" /> - <gl-button + <textarea v-else - class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!" - @click="isEditing = true" - >{{ __('Add a reply') }}</gl-button - > + ref="textarea" + rows="1" + class="reply-placeholder-text-field gl-font-regular!" + data-testid="note-reply-textarea" + :placeholder="__('Reply')" + :aria-label="__('Reply to comment')" + @focus="showReplyForm" + @click="showReplyForm" + ></textarea> </div> </div> </div> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue index 8390ae5b2e1..f9f24366725 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -1,10 +1,22 @@ <script> import { GlButton } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__, __ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; +import { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + STATE_OPEN, + STATE_EVENT_REOPEN, + STATE_EVENT_CLOSE, + TRACKING_CATEGORY_SHOW, + i18n, +} from '~/work_items/constants'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; +import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; export default { constantOptions: { @@ -14,8 +26,13 @@ export default { GlButton, MarkdownEditor, }, + mixins: [Tracking.mixin()], inject: ['fullPath'], props: { + workItemId: { + type: String, + required: true, + }, workItemType: { type: String, required: true, @@ -52,13 +69,36 @@ export default { required: false, default: () => ({}), }, + isNewDiscussion: { + type: Boolean, + required: false, + default: false, + }, + workItemState: { + type: String, + required: false, + default: STATE_OPEN, + }, + autofocus: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { commentText: getDraft(this.autosaveKey) || this.initialValue || '', + updateInProgress: false, }; }, computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'work_item_task_status', + property: `type_${this.workItemType}`, + }; + }, formFieldProps() { return { 'aria-label': this.ariaLabel, @@ -67,6 +107,17 @@ export default { name: 'work-item-add-or-edit-comment', }; }, + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + toggleWorkItemStateText() { + return this.isWorkItemOpen + ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }) + : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }); + }, + cancelButtonText() { + return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel'); + }, }, methods: { setCommentText(newText) { @@ -99,13 +150,55 @@ export default { this.$emit('cancelEditing'); clearDraft(this.autosaveKey); }, + async toggleWorkItemState() { + const input = { + id: this.workItemId, + stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, + }; + + this.updateInProgress = true; + + try { + this.track('updated_state'); + + const { mutation, variables } = getUpdateWorkItemMutation({ + workItemParentId: this.workItemParentId, + input, + }); + + const { data } = await this.$apollo.mutate({ + mutation, + variables, + }); + + const errors = data.workItemUpdate?.errors; + + if (errors?.length) { + this.$emit('error', i18n.updateError); + } + } catch (error) { + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + + this.$emit('error', msg); + Sentry.captureException(error); + } + + this.updateInProgress = false; + }, + cancelButtonAction() { + if (this.isNewDiscussion) { + this.toggleWorkItemState(); + } else { + this.cancelEditing(); + } + }, }, }; </script> <template> - <div class="timeline-discussion-body"> - <div class="note-body"> + <div class="timeline-discussion-body gl-overflow-visible!"> + <div class="note-body gl-p-0! gl-overflow-visible!"> <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1"> <markdown-editor :value="commentText" @@ -113,11 +206,12 @@ export default { :markdown-docs-path="$options.constantOptions.markdownDocsPath" :autocomplete-data-sources="autocompleteDataSources" :form-field-props="formFieldProps" + :add-spacing-classes="false" data-testid="work-item-add-comment" class="gl-mb-3" - autofocus use-bottom-toolbar supports-quick-actions + :autofocus="autofocus" @input="setCommentText" @keydown.meta.enter="$emit('submitForm', commentText)" @keydown.ctrl.enter="$emit('submitForm', commentText)" @@ -127,6 +221,7 @@ export default { category="primary" variant="confirm" data-testid="confirm-button" + :disabled="!commentText.length" :loading="isSubmitting" @click="$emit('submitForm', commentText)" >{{ commentButtonText }} @@ -135,8 +230,9 @@ export default { data-testid="cancel-button" category="primary" class="gl-ml-3" - @click="cancelEditing" - >{{ __('Cancel') }} + :loading="updateInProgress" + @click="cancelButtonAction" + >{{ cancelButtonText }} </gl-button> </form> </div> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue index 6cf15ba50ec..21fc8f99366 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue @@ -248,6 +248,7 @@ export default { :add-padding="true" :autocomplete-data-sources="autocompleteDataSources" :markdown-preview-path="markdownPreviewPath" + @startReplying="showReplyForm" @cancelEditing="hideReplyForm" @replied="onReplied" @replying="onReplying" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index 8b25d305398..5ccc5526ce8 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -310,6 +310,9 @@ export default { :comment-button-text="__('Save comment')" :autocomplete-data-sources="autocompleteDataSources" :markdown-preview-path="markdownPreviewPath" + :work-item-id="workItemId" + :autofocus="isEditing" + class="gl-pl-3 gl-mt-3" @cancelEditing="isEditing = false" @submitForm="updateNote" /> diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index a1f1eda8bc5..00cdc224320 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -1,6 +1,7 @@ <script> import { GlSkeletonLoader, GlModal } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import { uniqueId } from 'lodash'; import { __ } from '~/locale'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants'; @@ -96,6 +97,7 @@ export default { sortOrder: ASC, noteToDelete: null, discussionFilter: WORK_ITEM_NOTES_FILTER_ALL_NOTES, + addNoteKey: uniqueId(`work-item-add-note-${this.workItemId}`), }; }, computed: { @@ -134,6 +136,7 @@ export default { fetchByIid: this.fetchByIid, workItemType: this.workItemType, sortOrder: this.sortOrder, + isNewDiscussion: true, markdownPreviewPath: this.markdownPreviewPath, autocompleteDataSources: this.autocompleteDataSources, }; @@ -278,6 +281,9 @@ export default { filterDiscussions(filterValue) { this.discussionFilter = filterValue; }, + updateKey() { + this.addNoteKey = uniqueId(`work-item-add-note-${this.workItemId}`); + }, async fetchMoreNotes() { this.isLoadingMore = true; // copied from discussions batch logic - every fetchMore call has a higher @@ -361,12 +367,17 @@ export default { </div> <div v-else class="issuable-discussion gl-mb-5 gl-clearfix!"> <template v-if="!initialLoading"> - <ul class="notes main-notes-list timeline gl-clearfix!"> - <work-item-add-note - v-if="formAtTop && !commentsDisabled" - v-bind="workItemCommentFormProps" - @error="$emit('error', $event)" - /> + <div v-if="formAtTop && !commentsDisabled" class="js-comment-form"> + <ul class="notes notes-form timeline"> + <work-item-add-note + v-bind="workItemCommentFormProps" + :key="addNoteKey" + @cancelEditing="updateKey" + @error="$emit('error', $event)" + /> + </ul> + </div> + <ul class="notes main-notes-list timeline"> <template v-for="discussion in notesArray"> <system-note v-if="isSystemNote(discussion)" @@ -393,17 +404,21 @@ export default { </template> </template> - <work-item-add-note - v-if="!formAtTop && !commentsDisabled" - v-bind="workItemCommentFormProps" - @error="$emit('error', $event)" - /> - <work-item-history-only-filter-note v-if="commentsDisabled" @changeFilter="filterDiscussions" /> </ul> + <div v-if="!formAtTop && !commentsDisabled" class="js-comment-form"> + <ul class="notes notes-form timeline"> + <work-item-add-note + v-bind="workItemCommentFormProps" + :key="addNoteKey" + @cancelEditing="updateKey" + @error="$emit('error', $event)" + /> + </ul> + </div> </template> <template v-if="showLoadingMoreSkeleton"> diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index fda71fabe22..40fb0fbc91d 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -15,6 +15,10 @@ extend type WorkItem { mockWidgets: [LocalWorkItemWidget] } +extend type WorkItemPermissions { + setWorkItemMetadata: Boolean +} + input LocalUserInput { id: ID! name: String diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index 3651cad48f6..86640a6d994 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -27,7 +27,7 @@ fragment WorkItem on WorkItem { userPermissions { deleteWorkItem updateWorkItem - setWorkItemMetadata + setWorkItemMetadata @client } widgets { ...WorkItemWidgets diff --git a/app/assets/stylesheets/page_bundles/issues_list.scss b/app/assets/stylesheets/page_bundles/issues_list.scss index f39dee12126..41515a98e0a 100644 --- a/app/assets/stylesheets/page_bundles/issues_list.scss +++ b/app/assets/stylesheets/page_bundles/issues_list.scss @@ -23,6 +23,11 @@ margin-bottom: 2px; } + .issue-labels, + .author-link { + display: inline-block; + } + .icon-merge-request-unmerged { height: 13px; margin-bottom: 3px; diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb deleted file mode 100644 index cf7ba0e5aaf..00000000000 --- a/app/channels/awareness_channel.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -class AwarenessChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass - REFRESH_INTERVAL = ENV.fetch("GITLAB_AWARENESS_REFRESH_INTERVAL_SEC", 60) - private_constant :REFRESH_INTERVAL - - # Produces a refresh interval value, based of the - # GITLAB_AWARENESS_REFRESH_INTERVAL_SEC environment variable or the given - # default. Makes sure, that the interval after a jitter is applied, is never - # less than half the predefined interval. - def self.refresh_interval(range: -10..10) - min = REFRESH_INTERVAL / 2.to_f - [min.to_i, REFRESH_INTERVAL.to_i + rand(range)].max.seconds - end - private_class_method :refresh_interval - - # keep clients updated about session membership - periodically every: refresh_interval do - transmit payload - end - - def subscribed - reject unless valid_subscription? - return if subscription_rejected? - - stream_for session, coder: ActiveSupport::JSON - - session.join(current_user) - AwarenessChannel.broadcast_to(session, payload) - end - - def unsubscribed - return if subscription_rejected? - - session.leave(current_user) - AwarenessChannel.broadcast_to(session, payload) - end - - # Allows a client to let the server know they are still around. This is not - # like a heartbeat mechanism. This can be triggered by any action that results - # in a meaningful "presence" update. Like scrolling the screen (debounce), - # window becoming active, user starting to type in a text field, etc. - def touch - session.touch!(current_user) - - transmit payload - end - - private - - def valid_subscription? - current_user.present? && path.present? - end - - def payload - { collaborators: collaborators } - end - - def collaborators - session.online_users_with_last_activity.map do |user, last_activity| - collaborator(user, last_activity) - end - end - - def collaborator(user, last_activity) - { - id: user.id, - name: user.name, - username: user.username, - avatar_url: user.avatar_url(size: 36), - last_activity: last_activity, - last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words( - Time.zone.now, last_activity - ) - } - end - - def session - @session ||= AwarenessSession.for(path) - end - - def path - params[:path] - end -end diff --git a/app/graphql/types/permission_types/work_item.rb b/app/graphql/types/permission_types/work_item.rb index 25d6b3e924d..f35f42001e0 100644 --- a/app/graphql/types/permission_types/work_item.rb +++ b/app/graphql/types/permission_types/work_item.rb @@ -6,7 +6,7 @@ module Types graphql_name 'WorkItemPermissions' description 'Check permissions for the current user on a work item' - abilities :read_work_item, :update_work_item, :delete_work_item, :admin_work_item, :set_work_item_metadata + abilities :read_work_item, :update_work_item, :delete_work_item, :admin_work_item end end end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index e2e89c9abca..00cf8e395bb 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -47,6 +47,7 @@ module AuthHelper def qa_class_for_provider(provider) { + github: 'qa-github-login-button', saml: 'qa-saml-login-button' }[provider.to_sym] end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 2442856d7fe..f2fa82aebdb 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -132,7 +132,7 @@ module PreferencesHelper Gitlab::CurrentSettings.gitpod_url.presence || 'https://gitpod.io/' end - # Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too + # Ensure that anyone adding new options updates `localized_dashboard_choices` too def validate_dashboard_choices!(user_dashboards) if user_dashboards.size != localized_dashboard_choices.size raise "`User` defines #{user_dashboards.size} dashboard choices," \ diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb deleted file mode 100644 index 0b652984630..00000000000 --- a/app/models/awareness_session.rb +++ /dev/null @@ -1,245 +0,0 @@ -# frozen_string_literal: true - -# A Redis backed session store for real-time collaboration. A session is defined -# by its documents and the users that join this session. An online user can have -# two states within the session: "active" and "away". -# -# By design, session must eventually be cleaned up. If this doesn't happen -# explicitly, all keys used within the session model must have an expiry -# timestamp set. -class AwarenessSession # rubocop:disable Gitlab/NamespacedClass - # An awareness session expires automatically after 1 hour of no activity - SESSION_LIFETIME = 1.hour - private_constant :SESSION_LIFETIME - - # Expire user awareness keys after some time of inactivity - USER_LIFETIME = 1.hour - private_constant :USER_LIFETIME - - PRESENCE_LIFETIME = 10.minutes - private_constant :PRESENCE_LIFETIME - - KEY_NAMESPACE = "gitlab:awareness" - private_constant :KEY_NAMESPACE - - class << self - def for(value = nil) - # Creates a unique value for situations where we have no unique value to - # create a session with. This could be when creating a new issue, a new - # merge request, etc. - value = SecureRandom.uuid unless value.present? - - # We use SHA-256 based session identifiers (similar to abbreviated git - # hashes). There is always a chance for Hash collisions (birthday - # problem), we therefore have to pick a good tradeoff between the amount - # of data stored and the probability of a collision. - # - # The approximate probability for a collision can be calculated: - # - # p ~= n^2 / 2m - # ~= (2^18)^2 / (2 * 16^15) - # ~= 2^36 / 2^61 - # - # n is the number of awareness sessions and m the number of possibilities - # for each item. For a hex number, this is 16^c, where c is the number of - # characters. With 260k (~2^18) sessions, the probability for a collision - # is ~2^-25. - # - # The number of 15 is selected carefully. The integer representation fits - # nicely into a signed 64 bit integer and eventually allows Redis to - # optimize its memory usage. 16 chars would exceed the space for - # this datatype. - id = Digest::SHA256.hexdigest(value.to_s)[0, 15] - - AwarenessSession.new(id) - end - end - - def initialize(id) - @id = id - end - - def join(user) - user_key = user_sessions_key(user.id) - - with_redis do |redis| - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.pipelined do |pipeline| - pipeline.sadd?(user_key, id_i) - pipeline.expire(user_key, USER_LIFETIME.to_i) - - pipeline.zadd(users_key, timestamp.to_f, user.id) - - # We also mark for expiry when a session key is created (first user joins), - # because some users might never actively leave a session and the key could - # therefore become stale, w/o us noticing. - reset_session_expiry(pipeline) - end - end - end - - nil - end - - def leave(user) - user_key = user_sessions_key(user.id) - - with_redis do |redis| - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.pipelined do |pipeline| - pipeline.srem?(user_key, id_i) - pipeline.zrem(users_key, user.id) - end - end - - # cleanup orphan sessions and users - # - # this needs to be a second pipeline due to the delete operations being - # dependent on the result of the cardinality checks - user_sessions_count, session_users_count = - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.pipelined do |pipeline| - pipeline.scard(user_key) - pipeline.zcard(users_key) - end - end - - Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.pipelined do |pipeline| - pipeline.del(user_key) unless user_sessions_count > 0 - - unless session_users_count > 0 - pipeline.del(users_key) - @id = nil - end - end - end - end - - nil - end - - def present?(user, threshold: PRESENCE_LIFETIME) - with_redis do |redis| - user_timestamp = redis.zscore(users_key, user.id) - break false unless user_timestamp.present? - - timestamp - user_timestamp < threshold - end - end - - def away?(user, threshold: PRESENCE_LIFETIME) - !present?(user, threshold: threshold) - end - - # Updates the last_activity timestamp for a user in this session - def touch!(user) - with_redis do |redis| - redis.pipelined do |pipeline| - pipeline.zadd(users_key, timestamp.to_f, user.id) - - # extend the session lifetime due to user activity - reset_session_expiry(pipeline) - end - end - - nil - end - - def size - with_redis do |redis| - redis.zcard(users_key) - end - end - - def to_param - id&.to_s - end - - def to_s - "awareness_session=#{id}" - end - - def online_users_with_last_activity(threshold: PRESENCE_LIFETIME) - users_with_last_activity.filter do |_user, last_activity| - user_online?(last_activity, threshold: threshold) - end - end - - def users - User.where(id: user_ids) - end - - def users_with_last_activity - # where in (x, y, [...z]) is a set and does not maintain any order, we need - # to make sure to establish a stable order for both, the pairs returned from - # redis and the ActiveRecord query. Using IDs in ascending order. - user_ids, last_activities = user_ids_with_last_activity - .sort_by(&:first) - .transpose - - return [] if user_ids.blank? - - users = User.where(id: user_ids).order(id: :asc) - users.zip(last_activities) - end - - private - - attr_reader :id - - def user_online?(last_activity, threshold:) - last_activity.to_i + threshold.to_i > Time.zone.now.to_i - end - - # converts session id from hex to integer representation - def id_i - Integer(id, 16) if id.present? - end - - def users_key - "#{KEY_NAMESPACE}:session:#{id}:users" - end - - def user_sessions_key(user_id) - "#{KEY_NAMESPACE}:user:#{user_id}:sessions" - end - - def with_redis - Gitlab::Redis::SharedState.with do |redis| - yield redis if block_given? - end - end - - def timestamp - Time.now.to_i - end - - def user_ids - with_redis do |redis| - redis.zrange(users_key, 0, -1) - end - end - - # Returns an array of tuples, where the first element in the tuple represents - # the user ID and the second part the last_activity timestamp. - def user_ids_with_last_activity - pairs = with_redis do |redis| - redis.zrange(users_key, 0, -1, with_scores: true) - end - - # map data type of score (float) to Time - pairs.map do |user_id, score| - [user_id, Time.zone.at(score.to_i)] - end - end - - # We want sessions to cleanup automatically after a certain period of - # inactivity. This sets the expiry timestamp for this session to - # [SESSION_LIFETIME]. - def reset_session_expiry(redis) - redis.expire(users_key, SESSION_LIFETIME) - - nil - end -end diff --git a/app/models/concerns/awareness.rb b/app/models/concerns/awareness.rb deleted file mode 100644 index da87d87e838..00000000000 --- a/app/models/concerns/awareness.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Awareness - extend ActiveSupport::Concern - - KEY_NAMESPACE = "gitlab:awareness" - private_constant :KEY_NAMESPACE - - def join(session) - session.join(self) - - nil - end - - def leave(session) - session.leave(self) - - nil - end - - def session_ids - with_redis do |redis| - redis - .smembers(user_sessions_key) - # converts session ids from (internal) integer to hex presentation - .map { |key| key.to_i.to_s(16) } - end - end - - private - - def user_sessions_key - "#{KEY_NAMESPACE}:user:#{id}:sessions" - end - - def with_redis - Gitlab::Redis::SharedState.with do |redis| - yield redis if block_given? - end - end -end diff --git a/app/models/user.rb b/app/models/user.rb index 86e8aace514..71ea185b6f1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,7 +9,6 @@ class User < ApplicationRecord include Gitlab::SQL::Pattern include AfterCommitQueue include Avatarable - include Awareness include Referable include Sortable include CaseSensitivity diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 95d9caa686d..7156a0e5931 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -8,7 +8,7 @@ .title-container.hide-when-top-nav-responsive-open.gl-transition-medium.gl-display-flex.gl-align-items-stretch.gl-pt-0.gl-mr-3 .title %span.gl-sr-only GitLab - = link_to root_path, title: _('Dashboard'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do + = link_to root_path, title: _('Homepage'), id: 'logo', class: 'has-tooltip', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation_top') do = brand_header_logo .gl-display-flex.gl-align-items-center - if Gitlab.com_and_canary? diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 23dd824c268..c16469bbf79 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -78,7 +78,7 @@ = f.select :layout, layout_choices, {}, class: 'gl-form-select custom-select' .form-text.text-muted = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' } - .js-listbox-input{ data: { label: s_('Preferences|Dashboard'), description: s_('Preferences|Choose what content you want to see by default on your dashboard.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard } } + .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard } } = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific |