diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-24 15:09:37 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-24 15:09:37 +0300 |
commit | 6f15c2c2723dbe14e907379b75201e2ef70a48d3 (patch) | |
tree | f917af54164003d021a4af518ced7271978c5537 /app | |
parent | 7a8d983c19c9fe14e7c0b8b6256b8cbacbff1959 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
36 files changed, 451 insertions, 408 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index dbc7ff67d9d..e77120fd12a 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -64,6 +64,7 @@ const Api = { issuePath: '/api/:version/projects/:id/issues/:issue_iid', tagsPath: '/api/:version/projects/:id/repository/tags', freezePeriodsPath: '/api/:version/projects/:id/freeze_periods', + usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -686,6 +687,19 @@ const Api = { return axios.post(url, freezePeriod); }, + trackRedisHllUserEvent(event) { + if (!gon.features?.usageDataApi) { + return null; + } + + const url = Api.buildUrl(this.usageDataIncrementUniqueUsersPath); + const headers = { + 'Content-Type': 'application/json', + }; + + return axios.post(url, { event }, { headers }); + }, + buildUrl(url) { return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index d3fb3d82247..cea98d6dcd3 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -2,32 +2,36 @@ import { escape } from 'lodash'; import { mapActions, mapGetters } from 'vuex'; import { - GlDeprecatedButton, GlTooltipDirective, GlSafeHtmlDirective, - GlLoadingIcon, GlIcon, GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; import { diffViewerModes } from '~/ide/constants'; -import EditButton from './edit_button.vue'; import DiffStats from './diff_stats.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; export default { components: { - GlLoadingIcon, - GlDeprecatedButton, ClipboardButton, - EditButton, GlIcon, FileIcon, DiffStats, GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, }, directives: { GlTooltip: GlTooltipDirective, @@ -70,7 +74,7 @@ export default { }, data() { return { - hasDropdownOpen: false, + moreActionsShown: false, }; }, computed: { @@ -155,6 +159,13 @@ export default { } return s__('MRDiff|Show full file'); }, + showEditButton() { + return ( + this.diffFile.blob?.readable_text && + !this.diffFile.deleted_file && + (this.diffFile.edit_path || this.diffFile.ide_edit_path) + ); + }, }, methods: { ...mapActions('diffs', [ @@ -166,8 +177,11 @@ export default { handleToggleFile() { this.$emit('toggleFile'); }, - showForkMessage() { - this.$emit('showForkMessage'); + showForkMessage(e) { + if (this.canCurrentUserFork && !this.diffFile.can_modify_blob) { + e.preventDefault(); + this.$emit('showForkMessage'); + } }, handleFileNameClick(e) { const isLinkToOtherPage = @@ -183,8 +197,8 @@ export default { } } }, - setDropdownOpen(val) { - this.hasDropdownOpen = val; + setMoreActionsShown(val) { + this.moreActionsShown = val; }, }, }; @@ -193,8 +207,8 @@ export default { <template> <div ref="header" + :class="{ 'gl-z-dropdown-menu!': moreActionsShown }" class="js-file-title file-title file-title-flex-parent" - :class="{ 'gl-z-dropdown-menu!': hasDropdownOpen }" @click.self="handleToggleFile" > <div class="file-header-content"> @@ -255,96 +269,95 @@ export default { <div v-if="!diffFile.submodule && addMergeRequestButtons" - class="file-actions d-none d-sm-flex align-items-center flex-wrap" + class="file-actions d-flex align-items-center flex-wrap" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> - <div class="btn-group" role="group"> - <template v-if="diffFile.blob && diffFile.blob.readable_text"> - <span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')"> - <gl-deprecated-button - ref="toggleDiscussionsButton" - :disabled="!diffHasDiscussions(diffFile)" - :class="{ active: diffHasExpandedDiscussions(diffFile) }" - class="js-btn-vue-toggle-comments btn" - data-qa-selector="toggle_comments_button" - data-track-event="click_toggle_comments_button" - data-track-label="diff_toggle_comments_button" - data-track-property="diff_toggle_comments" - type="button" - @click="toggleFileDiscussionWrappers(diffFile)" - > - <gl-icon name="comment" /> - </gl-deprecated-button> - </span> - - <edit-button - v-if="!diffFile.deleted_file" - :can-current-user-fork="canCurrentUserFork" - :edit-path="diffFile.edit_path" - :ide-edit-path="diffFile.ide_edit_path" - :can-modify-blob="diffFile.can_modify_blob" - data-track-event="click_toggle_edit_button" - data-track-label="diff_toggle_edit_button" - data-track-property="diff_toggle_edit" - @showForkMessage="showForkMessage" - @open="setDropdownOpen(true)" - @close="setDropdownOpen(false)" - /> - </template> - - <a - v-if="diffFile.replaced_view_path" - ref="replacedFileButton" - v-safe-html="viewReplacedFileButtonText" - :href="diffFile.replaced_view_path" - class="btn view-file" - > - </a> - <gl-deprecated-button - v-if="!diffFile.is_fully_expanded" - ref="expandDiffToFullFileButton" - v-gl-tooltip.hover - :title="expandDiffToFullFileTitle" - class="expand-file" - data-track-event="click_toggle_view_full_button" - data-track-label="diff_toggle_view_full_button" - data-track-property="diff_toggle_view_full" - @click="toggleFullDiff(diffFile.file_path)" - > - <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline /> - <gl-icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" /> - <gl-icon v-else name="doc-expand" /> - </gl-deprecated-button> - <gl-deprecated-button - ref="viewButton" - v-gl-tooltip.hover - :href="diffFile.view_path" - target="_blank" - class="view-file" - data-track-event="click_toggle_view_sha_button" - data-track-label="diff_toggle_view_sha_button" - data-track-property="diff_toggle_view_sha" - :title="viewFileButtonText" - > - <gl-icon name="doc-text" /> - </gl-deprecated-button> - - <a + <gl-button-group class="gl-pt-0!"> + <gl-button v-if="diffFile.external_url" ref="externalLink" v-gl-tooltip.hover :href="diffFile.external_url" :title="`View on ${diffFile.formatted_external_url}`" target="_blank" - rel="noopener noreferrer" data-track-event="click_toggle_external_button" data-track-label="diff_toggle_external_button" data-track-property="diff_toggle_external" - class="btn btn-file-option" + icon="external-link" + /> + <gl-dropdown + v-gl-tooltip.hover.focus="__('More actions')" + right + toggle-class="btn-icon js-diff-more-actions" + class="gl-pt-0!" + @show="setMoreActionsShown(true)" + @hidden="setMoreActionsShown(false)" > - <gl-icon name="external-link" /> - </a> - </div> + <template #button-content> + <gl-icon name="ellipsis_v" class="mr-0" /> + <span class="sr-only">{{ __('More actions') }}</span> + </template> + <gl-dropdown-section-header> + {{ __('More actions') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-if="diffFile.replaced_view_path" + ref="replacedFileButton" + v-safe-html="viewReplacedFileButtonText" + :href="diffFile.replaced_view_path" + target="_blank" + /> + <gl-dropdown-item ref="viewButton" :href="diffFile.view_path" target="_blank"> + {{ viewFileButtonText }} + </gl-dropdown-item> + <template v-if="showEditButton"> + <gl-dropdown-item + v-if="diffFile.edit_path" + ref="editButton" + :href="diffFile.edit_path" + class="js-edit-blob" + @click="showForkMessage" + > + {{ __('Edit in single-file editor') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="diffFile.edit_path" + ref="ideEditButton" + :href="diffFile.ide_edit_path" + class="js-ide-edit-blob" + > + {{ __('Edit in Web IDE') }} + </gl-dropdown-item> + </template> + + <template v-if="!diffFile.viewer.collapsed"> + <gl-dropdown-divider + v-if="!diffFile.is_fully_expanded || diffHasDiscussions(diffFile)" + /> + + <gl-dropdown-item + v-if="diffHasDiscussions(diffFile)" + ref="toggleDiscussionsButton" + data-qa-selector="toggle_comments_button" + @click="toggleFileDiscussionWrappers(diffFile)" + > + <template v-if="diffHasExpandedDiscussions(diffFile)"> + {{ __('Hide comments on this file') }} + </template> + <template v-else> + {{ __('Show comments on this file') }} + </template> + </gl-dropdown-item> + <gl-dropdown-item + v-if="!diffFile.is_fully_expanded" + ref="expandDiffToFullFileButton" + @click="toggleFullDiff(diffFile.file_path)" + > + {{ expandDiffToFullFileTitle }} + </gl-dropdown-item> + </template> + </gl-dropdown> + </gl-button-group> </div> <div diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index 05fbbd39fae..f229fc4cf60 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -42,7 +42,7 @@ export default { class="diff-stats" :class="{ 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader, - 'd-inline-flex': !isCompareVersionsHeader, + 'd-none d-sm-inline-flex': !isCompareVersionsHeader, }" > <div v-if="hasDiffFiles" class="diff-stats-group"> diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue deleted file mode 100644 index a0d66ae2994..00000000000 --- a/app/assets/javascripts/diffs/components/edit_button.vue +++ /dev/null @@ -1,99 +0,0 @@ -<script> -import { uniqueId } from 'lodash'; -import { - GlTooltipDirective, - GlIcon, - GlDeprecatedDropdown as GlDropdown, - GlDeprecatedDropdownItem as GlDropdownItem, -} from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { - GlDropdown, - GlDropdownItem, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - editPath: { - type: String, - required: false, - default: '', - }, - ideEditPath: { - type: String, - required: false, - default: '', - }, - canCurrentUserFork: { - type: Boolean, - required: true, - }, - canModifyBlob: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { tooltipId: uniqueId('edit_button_tooltip_') }; - }, - computed: { - tooltipTitle() { - if (this.isDisabled) { - return __("Can't edit as source branch was deleted"); - } - - return __('Edit file in...'); - }, - isDisabled() { - return !this.editPath; - }, - }, - methods: { - handleShow(evt) { - // We must hide the tooltip because it is redundant and doesn't close itself - // when dropdown opens because we are still "focused". - this.$root.$emit('bv::hide::tooltip', this.tooltipId); - - if (this.canCurrentUserFork && !this.canModifyBlob) { - evt.preventDefault(); - this.$emit('showForkMessage'); - } else { - this.$emit('open'); - } - }, - handleHide() { - this.$emit('close'); - }, - }, -}; -</script> - -<template> - <div v-gl-tooltip.top="{ title: tooltipTitle, id: tooltipId }" class="gl-display-flex"> - <gl-dropdown - toggle-class="rounded-0" - :disabled="isDisabled" - :class="{ 'cursor-not-allowed': isDisabled }" - right - data-testid="edit_file" - @show="handleShow" - @hide="handleHide" - > - <template #button-content> - <span class="gl-dropdown-toggle-text"><gl-icon name="pencil"/></span> - <gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" /> - </template> - <gl-dropdown-item v-if="editPath" :href="editPath">{{ - __('Edit in single-file editor') - }}</gl-dropdown-item> - <gl-dropdown-item v-if="ideEditPath" :href="ideEditPath">{{ - __('Edit in Web IDE') - }}</gl-dropdown-item> - </gl-dropdown> - </div> -</template> diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 42df5873a41..0a33f978f4f 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -46,15 +46,24 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => diff => { * @param {Object} diff * @returns {Boolean} */ -export const diffHasExpandedDiscussions = (state, getters) => diff => { - const discussions = getters.getDiffFileDiscussions(diff); - - return ( - (discussions && - discussions.length && - discussions.find(discussion => discussion.expanded) !== undefined) || - false - ); +export const diffHasExpandedDiscussions = state => diff => { + const lines = { + [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], + [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { + if (line.left) { + acc.push(line.left); + } + + if (line.right) { + acc.push(line.right); + } + + return acc; + }, []), + }; + return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType] + .filter(l => l.discussions.length >= 1) + .some(l => l.discussionsExpanded); }; /** @@ -62,8 +71,25 @@ export const diffHasExpandedDiscussions = (state, getters) => diff => { * @param {Boolean} diff * @returns {Boolean} */ -export const diffHasDiscussions = (state, getters) => diff => - getters.getDiffFileDiscussions(diff).length > 0; +export const diffHasDiscussions = state => diff => { + const lines = { + [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], + [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { + if (line.left) { + acc.push(line.left); + } + + if (line.right) { + acc.push(line.right); + } + + return acc; + }, []), + }; + return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType].some( + l => l.discussions.length >= 1, + ); +}; /** * Returns an array with the discussions of the given diff diff --git a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue index 780b62c7938..b2aa5265331 100644 --- a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue +++ b/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue @@ -1,11 +1,10 @@ <script> -import { GlLink, GlTooltipDirective, GlSprintf } from '@gitlab/ui'; +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; export default { components: { GlLink, - GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, @@ -26,16 +25,12 @@ export default { <template> <div - class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between" + class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" > <div class="gl-pr-3"> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Original alert:') }}</span> <gl-link v-gl-tooltip :title="alert.title" :href="alert.detailsUrl"> - <gl-sprintf :message="__('Alert #%{alertId}')"> - <template #alertId> - <span>{{ alert.iid }}</span> - </template> - </gl-sprintf> + #{{ alert.iid }} </gl-link> </div> diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue index c6fab271376..2427a3f98ad 100644 --- a/app/assets/javascripts/notes/components/discussion_counter.vue +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -1,6 +1,7 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlButton, GlButtonGroup } from '@gitlab/ui'; +import { __ } from '~/locale'; import discussionNavigation from '../mixins/discussion_navigation'; export default { @@ -9,6 +10,8 @@ export default { }, components: { GlIcon, + GlButton, + GlButtonGroup, }, mixins: [discussionNavigation], computed: { @@ -34,6 +37,12 @@ export default { allExpanded() { return this.toggeableDiscussions.every(discussion => discussion.expanded); }, + lineResolveClass() { + return this.allResolved ? 'line-resolve-btn is-active' : 'line-resolve-text'; + }, + toggleThreadsLabel() { + return this.allExpanded ? __('Collapse all threads') : __('Expand all threads'); + }, }, methods: { ...mapActions(['setExpandDiscussions']), @@ -51,59 +60,49 @@ export default { <div v-if="resolvableDiscussionsCount > 0" ref="discussionCounter" - class="line-resolve-all-container full-width-mobile" + class="line-resolve-all-container full-width-mobile gl-display-flex d-sm-flex" > - <div class="full-width-mobile d-flex d-sm-flex"> - <div class="line-resolve-all"> - <span - :class="{ 'line-resolve-btn is-active': allResolved, 'line-resolve-text': !allResolved }" - > - <template v-if="allResolved"> - <gl-icon name="check-circle-filled" /> - {{ __('All threads resolved') }} - </template> - <template v-else> - {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} - </template> - </span> - </div> - <div - v-if="resolveAllDiscussionsIssuePath && !allResolved" - class="btn-group btn-group-sm" - role="group" - > - <a - v-gl-tooltip - :href="resolveAllDiscussionsIssuePath" - :title="s__('Resolve all threads in new issue')" - class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" - > - <gl-icon name="issue-new" /> - </a> - </div> - <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group"> - <button - v-gl-tooltip - :title="__('Jump to next unresolved thread')" - class="btn btn-default discussion-next-btn" - data-track-event="click_button" - data-track-label="mr_next_unresolved_thread" - data-track-property="click_next_unresolved_thread_top" - @click="jumpToNextDiscussion" - > - <gl-icon name="comment-next" /> - </button> - </div> - <div class="btn-group btn-group-sm" role="group"> - <button - v-gl-tooltip - :title="__('Toggle all threads')" - class="btn btn-default toggle-all-discussions-btn" - @click="handleExpandDiscussions" - > - <gl-icon :name="allExpanded ? 'angle-up' : 'angle-down'" /> - </button> - </div> + <div class="line-resolve-all"> + <span :class="lineResolveClass"> + <template v-if="allResolved"> + <gl-icon name="check-circle-filled" /> + {{ __('All threads resolved') }} + </template> + <template v-else> + {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }} + </template> + </span> </div> + <gl-button-group> + <gl-button + v-if="resolveAllDiscussionsIssuePath && !allResolved" + v-gl-tooltip + :href="resolveAllDiscussionsIssuePath" + :title="s__('Resolve all threads in new issue')" + :aria-label="s__('Resolve all threads in new issue')" + class="new-issue-for-discussion discussion-create-issue-btn" + icon="issue-new" + /> + <gl-button + v-if="isLoggedIn && !allResolved" + v-gl-tooltip + :title="__('Jump to next unresolved thread')" + :aria-label="__('Jump to next unresolved thread')" + class="discussion-next-btn" + data-track-event="click_button" + data-track-label="mr_next_unresolved_thread" + data-track-property="click_next_unresolved_thread_top" + icon="comment-next" + @click="jumpToNextDiscussion" + /> + <gl-button + v-gl-tooltip + :title="toggleThreadsLabel" + :aria-label="toggleThreadsLabel" + class="toggle-all-discussions-btn" + :icon="allExpanded ? 'angle-up' : 'angle-down'" + @click="handleExpandDiscussions" + /> + </gl-button-group> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 0143770ed25..c2f40b2d21a 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -160,7 +160,7 @@ export default { }); }, displayMemberBadgeText() { - return sprintf(__('This user is a %{access} of the %{name} project.'), { + return sprintf(__('This user has the %{access} role in the %{name} project.'), { access: this.accessLevel.toLowerCase(), name: this.projectName, }); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 1a539aa0876..e15aa10bd81 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -151,7 +151,7 @@ export default { this.newSnippet = false; }, onSnippetFetch(snippetRes) { - if (snippetRes.data.snippets.edges.length === 0) { + if (snippetRes.data.snippets.nodes.length === 0) { this.onNewSnippetFetched(); } else { this.onExistingSnippetFetched(); diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index b38be5bb9a4..e88126ea56a 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -23,6 +23,7 @@ export default { return { ids: this.snippet.id, rich: this.activeViewerType === RICH_BLOB_VIEWER, + paths: [this.blob.path], }; }, update(data) { @@ -79,8 +80,10 @@ export default { }, onContentUpdate(data) { const { path: blobPath } = this.blob; - const { blobs } = data.snippets.edges[0].node; - const updatedBlobData = blobs.find(blob => blob.path === blobPath); + const { + blobs: { nodes: dataBlobs }, + } = data.snippets.nodes[0]; + const updatedBlobData = dataBlobs.find(blob => blob.path === blobPath); return updatedBlobData.richData || updatedBlobData.plainData; }, }, diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 0ca69f3161a..30de5a9d0e0 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -18,6 +18,7 @@ import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql'; import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql'; import { joinPaths } from '~/lib/utils/url_utility'; +import { fetchPolicies } from '~/lib/graphql'; export default { components: { @@ -37,6 +38,7 @@ export default { }, apollo: { canCreateSnippet: { + fetchPolicy: fetchPolicies.NO_CACHE, query() { return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet; }, diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql index 2cca71708ca..d75b4011d1c 100644 --- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql @@ -12,18 +12,20 @@ fragment SnippetBase on Snippet { httpUrlToRepo sshUrlToRepo blobs { - binary - name - path - rawPath - size - externalStorage - renderedAsText - simpleViewer { - ...BlobViewer - } - richViewer { - ...BlobViewer + nodes { + binary + name + path + rawPath + size + externalStorage + renderedAsText + simpleViewer { + ...BlobViewer + } + richViewer { + ...BlobViewer + } } } userPermissions { diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index c70ad9b95f8..16635a081a2 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -16,7 +16,7 @@ function appFactory(el, Component) { } const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { batchMax: 1 }), }); const { diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js index 15daaa8d84a..d5e69e2a889 100644 --- a/app/assets/javascripts/snippets/mixins/snippets.js +++ b/app/assets/javascripts/snippets/mixins/snippets.js @@ -11,9 +11,16 @@ export const getSnippetMixin = { ids: this.snippetGid, }; }, - update: data => data.snippets.edges[0]?.node, + update: data => { + const res = data.snippets.nodes[0]; + if (res) { + res.blobs = res.blobs.nodes; + } + + return res; + }, result(res) { - this.blobs = res.data.snippets.edges[0]?.node?.blobs || blobsDefault; + this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault; if (this.onSnippetFetch) { this.onSnippetFetch(res); } diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql index 8f1f16b76c2..0e04ee9b7b8 100644 --- a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql @@ -1,9 +1,9 @@ -query SnippetBlobContent($ids: [ID!], $rich: Boolean!) { +query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) { snippets(ids: $ids) { - edges { - node { - id - blobs { + nodes { + id + blobs(paths: $paths) { + nodes { path richData @include(if: $rich) plainData @skip(if: $rich) diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql index b23ab862439..2f385050d89 100644 --- a/app/assets/javascripts/snippets/queries/snippet.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql @@ -4,13 +4,11 @@ query GetSnippetQuery($ids: [ID!]) { snippets(ids: $ids) { - edges { - node { - ...SnippetBase - ...SnippetProject - author { - ...Author - } + nodes { + ...SnippetBase + ...SnippetProject + author { + ...Author } } } diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index 7e89fa2afe1..b90cc24387c 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -1,5 +1,5 @@ <script> -import { escape } from 'lodash'; +import { escape, last } from 'lodash'; import Tribute from 'tributejs'; import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; @@ -12,6 +12,8 @@ const AutoComplete = { MergeRequests: 'mergeRequests', }; +const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings + function doesCurrentLineStartWith(searchString, fullText, selectionStart) { const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; const currentLine = fullText.split('\n')[currentLineNumber - 1]; @@ -79,30 +81,40 @@ const autoCompleteMap = { return this.members; }, menuItemTemplate({ original }) { - const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; - - const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass} - gl-display-inline-flex! gl-align-items-center gl-justify-content-center`; - - const avatarTag = original.avatar_url - ? `<img - src="${original.avatar_url}" - alt="${original.username}'s avatar" - class="${avatarClasses}"/>` - : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`; - - const name = escape(original.name); + const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; + const noAvatarClasses = `${commonClasses} gl-rounded-small + gl-display-flex gl-align-items-center gl-justify-content-center`; + + const avatar = original.avatar_url + ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />` + : `<div class="${noAvatarClasses}" aria-hidden="true"> + ${original.username.charAt(0).toUpperCase()}</div>`; + + let displayName = original.name; + let parentGroupOrUsername = `@${original.username}`; + + if (original.type === groupType) { + const splitName = original.name.split(' / '); + displayName = splitName.pop(); + parentGroupOrUsername = splitName.pop(); + } const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; - const icon = original.mentionsDisabled - ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3') + const disabledMentionsIcon = original.mentionsDisabled + ? spriteIcon('notifications-off', 's16 gl-ml-3') : ''; - return `${avatarTag} - ${original.username} - <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small> - ${icon}`; + return ` + <div class="gl-display-flex gl-align-items-center"> + ${avatar} + <div class="gl-font-sm gl-line-height-normal gl-ml-3"> + <div>${escape(displayName)}${count}</div> + <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> + </div> + ${disabledMentionsIcon} + </div> + `; }, }, [AutoComplete.MergeRequests]: { @@ -139,7 +151,8 @@ export default { { trigger: '@', fillAttr: 'username', - lookup: value => value.name + value.username, + lookup: value => + value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username, menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate, values: this.getValues(AutoComplete.Members), }, diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 76c6e03377c..f8710cc1346 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -50,7 +50,7 @@ right: 15px; margin-left: auto; - .btn { + .btn:not(.btn-icon) { padding: 0 10px; font-size: 13px; line-height: 28px; @@ -372,7 +372,7 @@ span.idiff { color: $gl-text-color; } - .file-actions .btn { + .file-actions .btn:not(.btn-icon) { padding: 0 10px; font-size: 13px; line-height: 28px; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 62af7103b39..fb13e776e13 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -13,6 +13,21 @@ box-shadow: 0 -2px 0 0 var(--white); cursor: pointer; + .dropdown-menu { + cursor: auto; + } + + @media (max-width: map-get($grid-breakpoints, sm)-1) { + .file-header-content { + width: 0; + flex: 1; + } + + .file-actions { + margin-left: $gl-spacing-scale-2; + } + } + @media (min-width: map-get($grid-breakpoints, md)) { // The `+11` is to ensure the file header border shows when scrolled - // the bottom of the compare-versions header and the top of the file header @@ -420,6 +435,10 @@ margin-left: 0; border-left: 0; } + + .file-actions .dropdown { + height: 28px; + } } table.code { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index c144fb13322..b510822a20a 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -858,68 +858,28 @@ $note-form-margin-left: 72px; } .line-resolve-all-container { - margin: $gl-padding-4; - > div { white-space: nowrap; } - .discussion-next-btn { - border-radius: 0; - } - - .toggle-all-discussions-btn { + .btn-group .btn:first-child { border-top-left-radius: 0; border-bottom-left-radius: 0; } - - .btn { - line-height: $gl-line-height; - - svg { - fill: $gray-500; - } - - &.discussion-create-issue-btn { - border-radius: 0; - border-right: 0; - - a { - padding: 0; - line-height: 0; - - &:hover { - text-decoration: none; - border: 0; - } - } - } - - &.discussion-next-btn { - border-right: 0; - } - } } .line-resolve-all { vertical-align: middle; display: inline-block; - padding: $gl-padding-4 10px; + padding: $gl-padding-8 $gl-padding-12; background-color: $gray-light; border: 1px solid $border-color; + border-right: 0; border-radius: $border-radius-default; - font-size: $gl-btn-small-font-size; border-top-right-radius: 0; border-bottom-right-radius: 0; - border-right: 0; - - .line-resolve-btn { - color: $gray-500; - - svg { - vertical-align: text-top; - } - } + font-size: $gl-font-size; + line-height: 1rem; @include media-breakpoint-down(xs) { flex: 1; diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb index 0b8469e8290..4eb436e4ce7 100644 --- a/app/controllers/boards/lists_controller.rb +++ b/app/controllers/boards/lists_controller.rb @@ -42,7 +42,7 @@ module Boards list = board.lists.destroyable.find(params[:id]) service = Boards::Lists::DestroyService.new(board_parent, current_user) - if service.execute(list) + if service.execute(list).success? head :ok else head :unprocessable_entity diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 5a5b634da40..4d1684ec3a2 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -44,7 +44,7 @@ module WikiActions wiki.list_pages(sort: params[:sort], direction: params[:direction]) ).page(params[:page]) - @wiki_entries = WikiPage.group_by_directory(@wiki_pages) + @wiki_entries = WikiDirectory.group_pages(@wiki_pages) render 'shared/wikis/pages' end diff --git a/app/graphql/mutations/boards/lists/destroy.rb b/app/graphql/mutations/boards/lists/destroy.rb new file mode 100644 index 00000000000..61ffae7c047 --- /dev/null +++ b/app/graphql/mutations/boards/lists/destroy.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module Lists + class Destroy < ::Mutations::BaseMutation + graphql_name 'DestroyBoardList' + + field :list, + Types::BoardListType, + null: true, + description: 'The list after mutation.' + + argument :list_id, ::Types::GlobalIDType[::List], + required: true, + loads: Types::BoardListType, + description: 'Global ID of the list to destroy. Only label lists are accepted.' + + def resolve(list:) + raise_resource_not_available_error! unless can_admin_list?(list) + + response = ::Boards::Lists::DestroyService.new(list.board.resource_parent, current_user) + .execute(list) + + { + list: response.success? ? nil : list, + errors: response.errors + } + end + + private + + def can_admin_list?(list) + return false unless list.present? + + Ability.allowed?(current_user, :admin_list, list.board) + end + end + end + end +end diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb new file mode 100644 index 00000000000..dc28358cab6 --- /dev/null +++ b/app/graphql/resolvers/snippets/blobs_resolver.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Resolvers + module Snippets + class BlobsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + alias_method :snippet, :object + + argument :paths, [GraphQL::STRING_TYPE], + required: false, + description: 'Paths of the blobs' + + def resolve(**args) + authorize!(snippet) + + return [snippet.blob] if snippet.empty_repo? + + paths = Array(args.fetch(:paths, [])) + + if paths.empty? + snippet.blobs + else + snippet.repository.blobs_at(transformed_blob_paths(paths)) + end + end + + def authorized_resource?(snippet) + Ability.allowed?(context[:current_user], :read_snippet, snippet) + end + + private + + def transformed_blob_paths(paths) + ref = snippet.default_branch + paths.map { |path| [ref, path] } + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 3d41218a29e..3a9d5529266 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -18,6 +18,7 @@ module Types mount_mutation Mutations::Boards::Issues::IssueMoveList mount_mutation Mutations::Boards::Lists::Create mount_mutation Mutations::Boards::Lists::Update + mount_mutation Mutations::Boards::Lists::Destroy mount_mutation Mutations::Branches::Create, calls_gitaly: true mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::Discussions::ToggleResolve diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index db98e62c10a..f29b513c26c 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -69,10 +69,11 @@ module Types null: false, deprecated: { reason: 'Use `blobs`', milestone: '13.3' } - field :blobs, type: [Types::Snippets::BlobType], + field :blobs, type: Types::Snippets::BlobType.connection_type, description: 'Snippet blobs', calls_gitaly: true, - null: false + null: true, + resolver: Resolvers::Snippets::BlobsResolver field :ssh_url_to_repo, type: GraphQL::STRING_TYPE, description: 'SSH URL to the snippet repository', diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index b255597b18d..f1030061690 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -206,7 +206,7 @@ module IssuablesHelper end if access = project.team.human_max_access(issuable.author_id) - output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: project.name }) + output << content_tag(:span, access, class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3 ", title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: project.name }) elsif project.team.contributor?(issuable.author_id) output << content_tag(:span, _("Contributor"), class: "user-access-role has-tooltip d-none d-xl-inline-block gl-ml-3", title: _("This user has previously committed to the %{name} project.") % { name: project.name }) end diff --git a/app/models/wiki.rb b/app/models/wiki.rb index 9462f7401c4..ac973f7f655 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -103,7 +103,7 @@ class Wiki limited = pages.size > limit pages = pages.first(limit) if limited - [WikiPage.group_by_directory(pages), limited] + [WikiDirectory.group_pages(pages), limited] end # Finds a page within the repository based on a tile diff --git a/app/models/wiki_directory.rb b/app/models/wiki_directory.rb index df2fe25b08b..3a2613e15d9 100644 --- a/app/models/wiki_directory.rb +++ b/app/models/wiki_directory.rb @@ -3,13 +3,46 @@ class WikiDirectory include ActiveModel::Validations - attr_accessor :slug, :pages + attr_accessor :slug, :entries validates :slug, presence: true - def initialize(slug, pages = []) + # Groups a list of wiki pages into a nested collection of WikiPage and WikiDirectory objects, + # preserving the order of the passed pages. + # + # Returns an array with all entries for the toplevel directory. + # + # @param [Array<WikiPage>] pages + # @return [Array<WikiPage, WikiDirectory>] + # + def self.group_pages(pages) + # Build a hash to map paths to created WikiDirectory objects, + # and recursively create them for each level of the path. + # For the toplevel directory we use '' as path, as that's what WikiPage#directory returns. + directories = Hash.new do |_, path| + directories[path] = new(path).tap do |directory| + if path.present? + parent = File.dirname(path) + parent = '' if parent == '.' + directories[parent].entries << directory + end + end + end + + pages.each do |page| + directories[page.directory].entries << page + end + + directories[''].entries + end + + def initialize(slug, entries = []) @slug = slug - @pages = pages + @entries = entries + end + + def title + WikiPage.unhyphenize(File.basename(slug)) end # Relative path to the partial to be used when rendering collections diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index faf3d19d936..989128987d5 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -31,29 +31,6 @@ class WikiPage alias_method :==, :eql? - # Sorts and groups pages by directory. - # - # pages - an array of WikiPage objects. - # - # Returns an array of WikiPage and WikiDirectory objects. The entries are - # sorted by alphabetical order (directories and pages inside each directory). - # Pages at the root level come before everything. - def self.group_by_directory(pages) - return [] if pages.blank? - - pages.each_with_object([]) do |page, grouped_pages| - next grouped_pages << page unless page.directory.present? - - directory = grouped_pages.find do |obj| - obj.is_a?(WikiDirectory) && obj.slug == page.directory - end - - next directory.pages << page if directory - - grouped_pages << WikiDirectory.new(page.directory, [page]) - end - end - def self.unhyphenize(name) name.gsub(/-+/, ' ') end diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb index d814c4404b6..695aa266e2c 100644 --- a/app/presenters/snippet_presenter.rb +++ b/app/presenters/snippet_presenter.rb @@ -32,15 +32,9 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated end def blob - blobs.first - end + return snippet.blob if snippet.empty_repo? - def blobs - if snippet.empty_repo? - [snippet.blob] - else - snippet.blobs - end + blobs.first end private diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb index e20805d0405..ebac0f07fe1 100644 --- a/app/services/boards/lists/destroy_service.rb +++ b/app/services/boards/lists/destroy_service.rb @@ -4,7 +4,9 @@ module Boards module Lists class DestroyService < Boards::BaseService def execute(list) - return false unless list.destroyable? + unless list.destroyable? + return ServiceResponse.error(message: "The list cannot be destroyed. Only label lists can be destroyed.") + end @board = list.board @@ -12,6 +14,8 @@ module Boards decrement_higher_lists(list) remove_list(list) end + + ServiceResponse.success end private @@ -26,7 +30,7 @@ module Boards # rubocop: enable CodeReuse/ActiveRecord def remove_list(list) - list.destroy + list.destroy! end end end diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index b745b67f566..ab83fc401e9 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -45,7 +45,7 @@ module Spam attr_reader :user, :context def allowlisted?(user) - user.respond_to?(:gitlab_employee) && user.gitlab_employee? + user.try(:gitlab_employee?) || user.try(:gitlab_bot?) end def perform_spam_service_check(api) diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 058366eb75d..a785e36fad5 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -2,7 +2,7 @@ - if note.noteable_author?(@noteable) %span{ class: 'note-role user-access-role has-tooltip d-none d-md-inline-block', title: _("This user is the author of this %{noteable}.") % { noteable: @noteable.human_class_name } }= _("Author") - if access - %span{ class: 'note-role user-access-role has-tooltip', title: _("This user is a %{access} of the %{name} project.") % { access: access.downcase, name: note.project_name } }= access + %span{ class: 'note-role user-access-role has-tooltip', title: _("This user has the %{access} role in the %{name} project.") % { access: access.downcase, name: note.project_name } }= access - elsif note.contributor? %span{ class: 'note-role user-access-role has-tooltip', title: _("This user has previously committed to the %{name} project.") % { name: note.project_name } }= _("Contributor") diff --git a/app/views/shared/wikis/_pages_wiki_page.html.haml b/app/views/shared/wikis/_pages_wiki_page.html.haml index b56ae2bf9b1..fb6f58d044d 100644 --- a/app/views/shared/wikis/_pages_wiki_page.html.haml +++ b/app/views/shared/wikis/_pages_wiki_page.html.haml @@ -1,5 +1,5 @@ %li - = link_to wiki_page.title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug } + = link_to wiki_page.human_title, wiki_page_path(@wiki, wiki_page), data: { qa_selector: 'wiki_page_link', qa_page_name: wiki_page.slug } %small (#{wiki_page.format}) .float-right - if wiki_page.last_version diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml index 21e829d86a6..a492d1e5aa0 100644 --- a/app/views/shared/wikis/_wiki_directory.html.haml +++ b/app/views/shared/wikis/_wiki_directory.html.haml @@ -1,4 +1,4 @@ %li{ data: { qa_selector: 'wiki_directory_content' } } - = wiki_directory.slug + = wiki_directory.title %ul - = render wiki_directory.pages, context: context + = render wiki_directory.entries, context: context |