diff options
Diffstat (limited to 'app')
40 files changed, 524 insertions, 85 deletions
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index b0652a9e2b0..dbdc7e43d2d 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -12,6 +12,7 @@ import axios from './lib/utils/axios_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { __ } from './locale'; +window.axios = axios; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js index 5b5a1507d38..23647d99656 100644 --- a/app/assets/javascripts/commons/vue.js +++ b/app/assets/javascripts/commons/vue.js @@ -6,3 +6,5 @@ if (process.env.NODE_ENV !== 'production') { } Vue.use(GlFeatureFlagsPlugin); + +Vue.config.ignoredElements = ['gl-emoji']; diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue new file mode 100644 index 00000000000..a11122d5403 --- /dev/null +++ b/app/assets/javascripts/emoji/components/category.vue @@ -0,0 +1,61 @@ +<script> +import { GlIntersectionObserver } from '@gitlab/ui'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import EmojiGroup from './emoji_group.vue'; + +export default { + components: { + GlIntersectionObserver, + EmojiGroup, + }, + props: { + category: { + type: String, + required: true, + }, + emojis: { + type: Array, + required: true, + }, + }, + data() { + return { + renderGroup: false, + }; + }, + computed: { + categoryTitle() { + return capitalizeFirstCharacter(this.category); + }, + }, + methods: { + categoryAppeared() { + this.renderGroup = true; + this.$emit('appear', this.category); + }, + categoryDissappeared() { + this.renderGroup = false; + }, + }, +}; +</script> + +<template> + <gl-intersection-observer class="gl-px-5 gl-h-full" @appear="categoryAppeared"> + <div class="gl-top-0 gl-py-3 gl-w-full emoji-picker-category-header"> + <b>{{ categoryTitle }}</b> + </div> + <template v-if="emojis.length"> + <emoji-group + v-for="(emojiGroup, index) in emojis" + :key="index" + :emojis="emojiGroup" + :render-group="renderGroup" + :click-emoji="(emoji) => $emit('click', emoji)" + /> + </template> + <p v-else> + {{ s__('AwardEmoji|No emojis found.') }} + </p> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/emoji/components/emoji_group.vue b/app/assets/javascripts/emoji/components/emoji_group.vue new file mode 100644 index 00000000000..539cd6963b1 --- /dev/null +++ b/app/assets/javascripts/emoji/components/emoji_group.vue @@ -0,0 +1,35 @@ +<script> +export default { + props: { + emojis: { + type: Array, + required: true, + }, + renderGroup: { + type: Boolean, + required: true, + }, + clickEmoji: { + type: Function, + required: true, + }, + }, +}; +</script> + +<template functional> + <div class="gl-display-flex gl-flex-wrap gl-mb-2"> + <template v-if="props.renderGroup"> + <button + v-for="emoji in props.emojis" + :key="emoji" + type="button" + class="gl-border-0 gl-bg-transparent gl-px-0 gl-py-2 gl-text-center emoji-picker-emoji" + data-testid="emoji-button" + @click="props.clickEmoji(emoji)" + > + <gl-emoji :data-name="emoji" /> + </button> + </template> + </div> +</template> diff --git a/app/assets/javascripts/emoji/components/emoji_list.vue b/app/assets/javascripts/emoji/components/emoji_list.vue new file mode 100644 index 00000000000..0d73d751c6d --- /dev/null +++ b/app/assets/javascripts/emoji/components/emoji_list.vue @@ -0,0 +1,44 @@ +<script> +import { chunk } from 'lodash'; +import { searchEmoji } from '~/emoji'; +import { EMOJIS_PER_ROW } from '../constants'; +import { getEmojiCategories, generateCategoryHeight } from './utils'; + +export default { + props: { + searchValue: { + type: String, + required: true, + }, + }, + data() { + return { render: false }; + }, + computed: { + filteredCategories() { + if (this.searchValue !== '') { + const emojis = chunk( + searchEmoji(this.searchValue).map(({ emoji }) => emoji.name), + EMOJIS_PER_ROW, + ); + + return { + search: { emojis, height: generateCategoryHeight(emojis.length) }, + }; + } + + return this.categories; + }, + }, + async mounted() { + this.categories = await getEmojiCategories(); + this.render = true; + }, +}; +</script> + +<template> + <div v-if="render"> + <slot :filtered-categories="filteredCategories"></slot> + </div> +</template> diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue new file mode 100644 index 00000000000..7cd20d82329 --- /dev/null +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -0,0 +1,121 @@ +<script> +import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; +import VirtualList from 'vue-virtual-scroll-list'; +import { CATEGORY_NAMES } from '~/emoji'; +import { CATEGORY_ICON_MAP } from '../constants'; +import Category from './category.vue'; +import EmojiList from './emoji_list.vue'; +import { getEmojiCategories } from './utils'; + +export default { + components: { + GlIcon, + GlDropdown, + GlSearchBoxByType, + VirtualList, + Category, + EmojiList, + }, + props: { + toggleClass: { + type: [Array, String, Object], + required: false, + default: () => [], + }, + }, + data() { + return { + currentCategory: null, + searchValue: '', + }; + }, + computed: { + categoryNames() { + return CATEGORY_NAMES.map((category) => ({ + name: category, + icon: CATEGORY_ICON_MAP[category], + })); + }, + }, + methods: { + categoryAppeared(category) { + this.currentCategory = category; + }, + async scrollToCategory(categoryName) { + const categories = await getEmojiCategories(); + const { top } = categories[categoryName]; + + this.$refs.virtualScoller.setScrollTop(top); + }, + selectEmoji(name) { + this.$emit('click', name); + this.$refs.dropdown.hide(); + }, + getBoundaryElement() { + return document.querySelector('.content-wrapper') || 'scrollParent'; + }, + onSearchInput() { + this.$refs.virtualScoller.setScrollTop(0); + this.$refs.virtualScoller.forceRender(); + }, + }, +}; +</script> + +<template> + <div class="emoji-picker"> + <gl-dropdown + ref="dropdown" + :toggle-class="toggleClass" + :boundary="getBoundaryElement()" + menu-class="dropdown-extended-height" + no-flip + right + lazy + > + <template #button-content><slot name="button-content"></slot></template> + <gl-search-box-by-type + v-model="searchValue" + class="gl-mx-5! gl-mb-2!" + autofocus + debounce="500" + @input="onSearchInput" + /> + <div + v-show="!searchValue" + class="gl-display-flex gl-mx-5 gl-border-b-solid gl-border-gray-100 gl-border-b-1" + > + <button + v-for="category in categoryNames" + :key="category.name" + :class="{ + 'gl-text-black-normal! emoji-picker-category-active': category.name === currentCategory, + }" + type="button" + class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab" + @click="scrollToCategory(category.name)" + > + <gl-icon :name="category.icon" :size="12" /> + </button> + </div> + <emoji-list :search-value="searchValue"> + <template #default="{ filteredCategories }"> + <virtual-list ref="virtualScoller" :size="258" :remain="1" :bench="2" variable> + <div + v-for="(category, categoryKey) in filteredCategories" + :key="categoryKey" + :style="{ height: category.height + 'px' }" + > + <category + :category="categoryKey" + :emojis="category.emojis" + @appear="categoryAppeared" + @click="selectEmoji" + /> + </div> + </virtual-list> + </template> + </emoji-list> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/emoji/components/utils.js b/app/assets/javascripts/emoji/components/utils.js new file mode 100644 index 00000000000..b95b56a1d6f --- /dev/null +++ b/app/assets/javascripts/emoji/components/utils.js @@ -0,0 +1,27 @@ +import { chunk, memoize } from 'lodash'; +import { initEmojiMap, getEmojiCategoryMap } from '~/emoji'; +import { EMOJIS_PER_ROW, EMOJI_ROW_HEIGHT, CATEGORY_ROW_HEIGHT } from '../constants'; + +export const generateCategoryHeight = (emojisLength) => + emojisLength * EMOJI_ROW_HEIGHT + CATEGORY_ROW_HEIGHT; + +export const getEmojiCategories = memoize(async () => { + await initEmojiMap(); + + const categories = await getEmojiCategoryMap(); + let top = 0; + + return Object.freeze( + Object.keys(categories).reduce((acc, category) => { + const emojis = chunk(categories[category], EMOJIS_PER_ROW); + const height = generateCategoryHeight(emojis.length); + const newAcc = { + ...acc, + [category]: { emojis, height, top }, + }; + top += height; + + return newAcc; + }, {}), + ); +}); diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js new file mode 100644 index 00000000000..bf73d1ca5a9 --- /dev/null +++ b/app/assets/javascripts/emoji/constants.js @@ -0,0 +1,14 @@ +export const CATEGORY_ICON_MAP = { + activity: 'dumbbell', + people: 'smiley', + nature: 'nature', + food: 'food', + travel: 'car', + objects: 'object', + symbols: 'heart', + flags: 'flag', +}; + +export const EMOJIS_PER_ROW = 9; +export const EMOJI_ROW_HEIGHT = 34; +export const CATEGORY_ROW_HEIGHT = 37; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index d022fcbeabe..d3b658a4020 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -2,6 +2,7 @@ import { escape, minBy } from 'lodash'; import emojiAliases from 'emojis/aliases.json'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; +import { CATEGORY_ICON_MAP } from './constants'; let emojiMap = null; let validEmojiNames = null; @@ -155,19 +156,14 @@ export function sortEmoji(items) { return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue)); } +export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP); + let emojiCategoryMap; export function getEmojiCategoryMap() { if (!emojiCategoryMap) { - emojiCategoryMap = { - activity: [], - people: [], - nature: [], - food: [], - travel: [], - objects: [], - symbols: [], - flags: [], - }; + emojiCategoryMap = CATEGORY_NAMES.reduce((acc, category) => { + return { ...acc, [category]: [] }; + }, {}); Object.keys(emojiMap).forEach((name) => { const emoji = emojiMap[name]; if (emojiCategoryMap[emoji.c]) { diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 0b2c7611f8e..ed6701b34e8 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; import Api from '~/api'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; import { deprecatedCreateFlash as flash } from '~/flash'; @@ -8,6 +8,7 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import eventHub from '~/sidebar/event_hub'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { splitCamelCase } from '../../lib/utils/text_utility'; import ReplyButton from './note_actions/reply_button.vue'; @@ -19,11 +20,12 @@ export default { GlButton, GlDropdownItem, UserAccessRoleBadge, + EmojiPicker: () => import('~/emoji/components/picker.vue'), }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [resolvedStatusMixin], + mixins: [resolvedStatusMixin, glFeatureFlagsMixin()], props: { author: { type: Object, @@ -117,6 +119,10 @@ export default { type: Boolean, required: true, }, + awardPath: { + type: String, + required: true, + }, }, computed: { ...mapGetters(['getUserDataByProp', 'getNoteableData']), @@ -185,6 +191,7 @@ export default { }, }, methods: { + ...mapActions(['toggleAwardRequest']), onEdit() { this.$emit('handleEdit'); }, @@ -222,6 +229,13 @@ export default { .catch(() => flash(__('Something went wrong while updating assignees'))); } }, + setAwardEmoji(awardName) { + this.toggleAwardRequest({ + endpoint: this.awardPath, + noteId: this.noteId, + awardName, + }); + }, }, }; </script> @@ -267,28 +281,41 @@ export default { class="line-resolve-btn note-action-button" @click="onResolve" /> - <gl-button - v-if="canAwardEmoji" - v-gl-tooltip - :class="{ 'js-user-authored': isAuthoredByCurrentUser }" - class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji" - category="tertiary" - variant="default" - size="small" - title="Add reaction" - data-position="right" - :aria-label="__('Add reaction')" - > - <span class="reaction-control-icon reaction-control-icon-neutral"> - <gl-icon name="slight-smile" /> - </span> - <span class="reaction-control-icon reaction-control-icon-positive"> - <gl-icon name="smiley" /> - </span> - <span class="reaction-control-icon reaction-control-icon-super-positive"> - <gl-icon name="smile" /> - </span> - </gl-button> + <template v-if="canAwardEmoji"> + <emoji-picker + v-if="glFeatures.improvedEmojiPicker" + toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-2 gl-p-0! gl-shadow-none! gl-bg-transparent!" + @click="setAwardEmoji" + > + <template #button-content> + <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" /> + <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" /> + <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" /> + </template> + </emoji-picker> + <gl-button + v-else + v-gl-tooltip + :class="{ 'js-user-authored': isAuthoredByCurrentUser }" + class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji" + category="tertiary" + variant="default" + size="small" + title="Add reaction" + data-position="right" + :aria-label="__('Add reaction')" + > + <span class="reaction-control-icon reaction-control-icon-neutral"> + <gl-icon name="slight-smile" /> + </span> + <span class="reaction-control-icon reaction-control-icon-positive"> + <gl-icon name="smiley" /> + </span> + <span class="reaction-control-icon reaction-control-icon-super-positive"> + <gl-icon name="smile" /> + </span> + </gl-button> + </template> <reply-button v-if="showReply" ref="replyButton" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index a28c467117a..d74ade15de1 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -201,7 +201,7 @@ export default { changedCommentText() { return sprintf( __( - 'This comment has changed since you started editing, please review the %{startTag}updated comment%{endTag} to ensure information is not lost.', + 'This comment changed after you started editing it. Review the %{startTag}updated comment%{endTag} to ensure information is not lost.', ), { startTag: `<a href="${this.noteHash}" target="_blank" rel="noopener noreferrer">`, diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 4343fac3cfa..9bf1496f479 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -416,6 +416,7 @@ export default { :is-draft="note.isDraft" :resolve-discussion="note.isDraft && note.resolve_discussion" :discussion-id="discussionId" + :award-path="note.toggle_award_path" @handleEdit="editHandler" @handleDelete="deleteHandler" @handleResolve="resolveHandler" diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index ce67d33d4a1..82b3545117f 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -2,7 +2,9 @@ /* eslint-disable vue/no-v-html */ import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { groupBy } from 'lodash'; +import EmojiPicker from '~/emoji/components/picker.vue'; import { __, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { glEmojiTag } from '../../emoji'; // Internal constant, specific to this component, used when no `currentUserId` is given @@ -12,10 +14,12 @@ export default { components: { GlButton, GlIcon, + EmojiPicker, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { awards: { type: Array, @@ -166,7 +170,25 @@ export default { <span class="js-counter">{{ awardList.list.length }}</span> </gl-button> <div v-if="canAwardEmoji" class="award-menu-holder"> + <emoji-picker + v-if="glFeatures.improvedEmojiPicker" + toggle-class="add-reaction-button gl-relative!" + @click="handleAward" + > + <template #button-content> + <span class="reaction-control-icon reaction-control-icon-neutral"> + <gl-icon name="slight-smile" /> + </span> + <span class="reaction-control-icon reaction-control-icon-positive"> + <gl-icon name="smiley" /> + </span> + <span class="reaction-control-icon reaction-control-icon-super-positive"> + <gl-icon name="smile" /> + </span> + </template> + </emoji-picker> <gl-button + v-else v-gl-tooltip.viewport :class="addButtonClass" class="add-reaction-button js-add-award" diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue new file mode 100644 index 00000000000..35f9ac14681 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue @@ -0,0 +1,3 @@ +<template> + <div class="timeline-icon"><slot></slot></div> +</template> diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index e55654e84d8..4a15e0eb458 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -14,7 +14,6 @@ @import './pages/issues'; @import './pages/labels'; @import './pages/login'; -@import './pages/members'; @import './pages/merge_requests'; @import './pages/monitor'; @import './pages/note_form'; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index a7623b65539..662f7f52d61 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -274,7 +274,9 @@ // `position:absolute` &::after { content: '\a0'; + display: block !important; width: 1em; + color: transparent; } .reaction-control-icon { diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index 13c5541da92..c5c660c1014 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -16,3 +16,26 @@ gl-emoji { vertical-align: baseline; } } + +.emoji-picker-category-header { + @include gl-sticky; + background-color: $white-transparent; +} + +.emoji-picker-emoji { + height: 30px; + // Create a width that fits 9 emojis per row + width: 100 / 9 * 1%; +} + +.emoji-picker .gl-new-dropdown .dropdown-menu { + width: 350px; +} + +.emoji-picker-category-tab { + border-bottom-color: transparent; +} + +.emoji-picker .gl-new-dropdown-inner > :last-child { + padding-bottom: 0; +} diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/page_bundles/members.scss index 0ccde57746a..7b4c74b8253 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/page_bundles/members.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .project-members-title { padding-bottom: 10px; border-bottom: 1px solid $border-color; diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index aae9f3ded4f..5b3e2ab4cd0 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -205,6 +205,10 @@ } } + .emoji-picker-category-active { + border-bottom-color: $active-tab-border; + } + .branch-header-title { color: $border-and-box-shadow; } diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 17138da6e2b..c454ae6eaf4 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:tribute_autocomplete, @project) push_frontend_feature_flag(:vue_issuables_list, project) push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true) + push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) end before_action only: :show do diff --git a/app/finders/repositories/changelog_commits_finder.rb b/app/finders/repositories/changelog_commits_finder.rb index 08f1144701a..b80b8e94e59 100644 --- a/app/finders/repositories/changelog_commits_finder.rb +++ b/app/finders/repositories/changelog_commits_finder.rb @@ -93,7 +93,7 @@ module Repositories end def revert_commit_sha(commit) - matches = commit.description.match(REVERT_REGEX) + matches = commit.description&.match(REVERT_REGEX) matches[:sha] if matches end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index d66a2333d11..7ab5dc36e4a 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -13,8 +13,6 @@ class GitlabSchema < GraphQL::Schema use GraphQL::Pagination::Connections use BatchLoader::GraphQL use Gitlab::Graphql::Authorize - use Gitlab::Graphql::Present - use Gitlab::Graphql::CallsGitaly use Gitlab::Graphql::Pagination::Connections use Gitlab::Graphql::GenericTracing use Gitlab::Graphql::Timeout, max_seconds: Gitlab.config.gitlab.graphql_timeout diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 5db618254cb..67bba079512 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -12,8 +12,17 @@ module Resolvers @requires_argument = true end + def self.calls_gitaly! + @calls_gitaly = true + end + def self.field_options - super.merge(requires_argument: @requires_argument) + extra_options = { + requires_argument: @requires_argument, + calls_gitaly: @calls_gitaly + }.compact + + super.merge(extra_options) end def self.singular_type diff --git a/app/graphql/resolvers/last_commit_resolver.rb b/app/graphql/resolvers/last_commit_resolver.rb index dd89c322617..00c43bdfee6 100644 --- a/app/graphql/resolvers/last_commit_resolver.rb +++ b/app/graphql/resolvers/last_commit_resolver.rb @@ -4,6 +4,8 @@ module Resolvers class LastCommitResolver < BaseResolver type Types::CommitType, null: true + calls_gitaly! + alias_method :tree, :object def resolve(**args) diff --git a/app/graphql/resolvers/metrics/dashboard_resolver.rb b/app/graphql/resolvers/metrics/dashboard_resolver.rb index f569cb0b2c3..a82a4a95254 100644 --- a/app/graphql/resolvers/metrics/dashboard_resolver.rb +++ b/app/graphql/resolvers/metrics/dashboard_resolver.rb @@ -3,19 +3,30 @@ module Resolvers module Metrics class DashboardResolver < Resolvers::BaseResolver + type Types::Metrics::DashboardType, null: true + calls_gitaly! + argument :path, GraphQL::STRING_TYPE, required: true, - description: "Path to a file which defines metrics dashboard eg: 'config/prometheus/common_metrics.yml'." - - type Types::Metrics::DashboardType, null: true + description: "Path to a file which defines metrics dashboard " \ + "eg: 'config/prometheus/common_metrics.yml'." alias_method :environment, :object def resolve(**args) return unless environment - ::PerformanceMonitoring::PrometheusDashboard - .find_for(project: environment.project, user: context[:current_user], path: args[:path], options: { environment: environment }) + ::PerformanceMonitoring::PrometheusDashboard.find_for(**args, **service_params) + end + + private + + def service_params + { + project: environment.project, + user: current_user, + options: { environment: environment } + } end end end diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb index 672214df7d5..569b82149d3 100644 --- a/app/graphql/resolvers/snippets/blobs_resolver.rb +++ b/app/graphql/resolvers/snippets/blobs_resolver.rb @@ -8,6 +8,7 @@ module Resolvers type Types::Snippets::BlobType.connection_type, null: true authorize :read_snippet + calls_gitaly! alias_method :snippet, :object diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb index 7a70c35897d..c07d9187d4d 100644 --- a/app/graphql/resolvers/tree_resolver.rb +++ b/app/graphql/resolvers/tree_resolver.rb @@ -4,6 +4,8 @@ module Resolvers class TreeResolver < BaseResolver type Types::Tree::TreeType, null: true + calls_gitaly! + argument :path, GraphQL::STRING_TYPE, required: false, default_value: '', diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index f85675f72f2..78ab6890923 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -9,16 +9,25 @@ module Types DEFAULT_COMPLEXITY = 1 - def initialize(*args, **kwargs, &block) + def initialize(**kwargs, &block) @calls_gitaly = !!kwargs.delete(:calls_gitaly) - @constant_complexity = !!kwargs[:complexity] + @constant_complexity = kwargs[:complexity].is_a?(Integer) && kwargs[:complexity] > 0 @requires_argument = !!kwargs.delete(:requires_argument) kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity]) @feature_flag = kwargs[:feature_flag] kwargs = check_feature_flag(kwargs) kwargs = gitlab_deprecation(kwargs) - super(*args, **kwargs, &block) + super(**kwargs, &block) + + # We want to avoid the overhead of this in prod + extension ::Gitlab::Graphql::CallsGitaly::FieldExtension if Gitlab.dev_or_test_env? + + extension ::Gitlab::Graphql::Present::FieldExtension + end + + def may_call_gitaly? + @constant_complexity || @calls_gitaly end def requires_argument? @@ -54,8 +63,10 @@ module Types end def check_feature_flag(args) - args[:description] = feature_documentation_message(args[:feature_flag], args[:description]) if args[:feature_flag].present? - args.delete(:feature_flag) + ff = args.delete(:feature_flag) + return args unless ff.present? + + args[:description] = feature_documentation_message(ff, args[:description]) args end @@ -78,7 +89,9 @@ module Types # items which can be loaded. proc do |ctx, args, child_complexity| # Resolvers may add extra complexity depending on used arguments - complexity = child_complexity + self.resolver&.try(:resolver_complexity, args, child_complexity: child_complexity).to_i + complexity = child_complexity + resolver&.try( + :resolver_complexity, args, child_complexity: child_complexity + ).to_i complexity += 1 if calls_gitaly? complexity += complexity * connection_complexity_multiplier(ctx, args) @@ -93,7 +106,7 @@ module Types page_size = field_defn.connection_max_page_size || ctx.schema.default_max_page_size limit_value = [args[:first], args[:last], page_size].compact.min - multiplier = self.resolver&.try(:complexity_multiplier, args).to_f + multiplier = resolver&.try(:complexity_multiplier, args).to_f limit_value * multiplier end end diff --git a/app/graphql/types/current_user_todos.rb b/app/graphql/types/current_user_todos.rb index 79a430af1d7..2551db875b0 100644 --- a/app/graphql/types/current_user_todos.rb +++ b/app/graphql/types/current_user_todos.rb @@ -16,9 +16,10 @@ module Types end def current_user_todos(state: nil) - state ||= %i(done pending) # TodosFinder treats a `nil` state param as `pending` + state ||= %i[done pending] # TodosFinder treats a `nil` state param as `pending` + klass = unpresented.class - TodosFinder.new(current_user, state: state, type: object.class.name, target_id: object.id).execute + TodosFinder.new(current_user, state: state, type: klass.name, target_id: object.id).execute end end end diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb index fb0c1d9409b..fb9ee380705 100644 --- a/app/graphql/types/snippets/blob_type.rb +++ b/app/graphql/types/snippets/blob_type.rb @@ -14,7 +14,6 @@ module Types field :plain_data, GraphQL::STRING_TYPE, description: 'Blob plain highlighted data.', - calls_gitaly: true, null: true field :raw_path, GraphQL::STRING_TYPE, diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb index 3823bd94083..d192c8d3c57 100644 --- a/app/graphql/types/tree/blob_type.rb +++ b/app/graphql/types/tree/blob_type.rb @@ -15,6 +15,7 @@ module Types field :web_path, GraphQL::STRING_TYPE, null: true, description: 'Web path of the blob.' field :lfs_oid, GraphQL::STRING_TYPE, null: true, + calls_gitaly: true, description: 'LFS ID of the blob.' field :mode, GraphQL::STRING_TYPE, null: true, description: 'Blob mode in numeric format.' diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index dee91789f50..36c6f2f6c79 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -60,6 +60,10 @@ class NotifyPreview < ActionMailer::Preview end end + def new_mention_in_merge_request_email + Notify.new_mention_in_merge_request_email(user.id, issue.id, user.id).message + end + def closed_issue_email Notify.closed_issue_email(user.id, issue.id, user.id).message end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index aafba9bfcef..4d7d632ee14 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -20,7 +20,6 @@ module MergeRequests merge_request_activity_counter.track_merge_mr_action(user: current_user) notification_service.merge_mr(merge_request, current_user) execute_hooks(merge_request, 'merge') - retarget_chain_merge_requests(merge_request) invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) merge_request.update_project_counter_caches delete_non_latest_diffs(merge_request) @@ -31,34 +30,6 @@ module MergeRequests private - def retarget_chain_merge_requests(merge_request) - return unless Feature.enabled?(:retarget_merge_requests, merge_request.target_project) - - # we can only retarget MRs that are targeting the same project - # and have a remove source branch set - return unless merge_request.for_same_project? && merge_request.remove_source_branch? - - # find another merge requests that - # - as a target have a current source project and branch - other_merge_requests = merge_request.source_project - .merge_requests - .opened - .by_target_branch(merge_request.source_branch) - .preload_source_project - .at_most(MAX_RETARGET_MERGE_REQUESTS) - - other_merge_requests.find_each do |other_merge_request| - # Update only MRs on projects that we have access to - next unless can?(current_user, :update_merge_request, other_merge_request.source_project) - - ::MergeRequests::UpdateService - .new(other_merge_request.source_project, current_user, - target_branch: merge_request.target_branch, - target_branch_was_deleted: true) - .execute(other_merge_request) - end - end - def close_issues(merge_request) return unless merge_request.target_branch == project.default_branch diff --git a/app/services/merge_requests/retarget_chain_service.rb b/app/services/merge_requests/retarget_chain_service.rb new file mode 100644 index 00000000000..f24d67243c9 --- /dev/null +++ b/app/services/merge_requests/retarget_chain_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MergeRequests + class RetargetChainService < MergeRequests::BaseService + MAX_RETARGET_MERGE_REQUESTS = 4 + + def execute(merge_request) + return unless Feature.enabled?(:retarget_merge_requests, merge_request.target_project, default_enabled: :yaml) + + # we can only retarget MRs that are targeting the same project + return unless merge_request.for_same_project? && merge_request.merged? + + # find another merge requests that + # - as a target have a current source project and branch + other_merge_requests = merge_request.source_project + .merge_requests + .opened + .by_target_branch(merge_request.source_branch) + .preload_source_project + .at_most(MAX_RETARGET_MERGE_REQUESTS) + + other_merge_requests.find_each do |other_merge_request| + # Update only MRs on projects that we have access to + next unless can?(current_user, :update_merge_request, other_merge_request.source_project) + + ::MergeRequests::UpdateService + .new(other_merge_request.source_project, current_user, + target_branch: merge_request.target_branch, + target_branch_was_deleted: true) + .execute(other_merge_request) + end + end + end +end diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index d8128d2fa7e..f8c490dd948 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/members' - add_to_breadcrumbs _("Groups"), admin_groups_path - breadcrumb_title @group.name - page_title @group.name, _("Groups") diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 2085515e349..40443fb3406 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/members' - add_to_breadcrumbs _("Projects"), admin_projects_path - breadcrumb_title @project.full_name - page_title @project.full_name, _("Projects") diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index df39e6297f6..da00879ecf9 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/members' - page_title _('Group members') - can_manage_members = can?(current_user, :admin_group_member, @group) - show_invited_members = can_manage_members && @invited_members.exists? diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb index 3c78e257a88..0121006852c 100644 --- a/app/views/notify/new_mention_in_merge_request_email.text.erb +++ b/app/views/notify/new_mention_in_merge_request_email.text.erb @@ -4,6 +4,7 @@ You have been mentioned in Merge Request <%= @merge_request.to_reference %> <%= merge_path_description(@merge_request, 'to') %> Author: <%= sanitize_name(@merge_request.author_name) %> -= assignees_label(@merge_request) +<%= assignees_label(@merge_request) %> +<%= reviewers_label(@merge_request) %> <%= @merge_request.description %> diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 6bc9bcf6b90..c88dae079ae 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,3 +1,4 @@ +- add_page_specific_style 'page_bundles/members' - page_title _("Members") .js-remove-member-modal diff --git a/app/workers/merge_requests/delete_source_branch_worker.rb b/app/workers/merge_requests/delete_source_branch_worker.rb index 99816d5ed0b..eb83d10af33 100644 --- a/app/workers/merge_requests/delete_source_branch_worker.rb +++ b/app/workers/merge_requests/delete_source_branch_worker.rb @@ -16,6 +16,9 @@ class MergeRequests::DeleteSourceBranchWorker ::Branches::DeleteService.new(merge_request.source_project, user) .execute(merge_request.source_branch) + + ::MergeRequests::RetargetChainService.new(merge_request.source_project, user) + .execute(merge_request) rescue ActiveRecord::RecordNotFound end end |