diff options
Diffstat (limited to 'app')
119 files changed, 1708 insertions, 1071 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/highlight_current_user.js b/app/assets/javascripts/behaviors/markdown/highlight_current_user.js new file mode 100644 index 00000000000..6208b3f0032 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/highlight_current_user.js @@ -0,0 +1,17 @@ +/** + * Highlights the current user in existing elements with a user ID data attribute. + * + * @param elements DOM elements that represent user mentions + */ +export default function highlightCurrentUser(elements) { + const currentUserId = gon && gon.current_user_id; + if (!currentUserId) { + return; + } + + elements.forEach(element => { + if (parseInt(element.dataset.user, 10) === currentUserId) { + element.classList.add('current-user'); + } + }); +} diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 429455f97ec..a2d4331b6d1 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import syntaxHighlight from '~/syntax_highlight'; import renderMath from './render_math'; import renderMermaid from './render_mermaid'; +import highlightCurrentUser from './highlight_current_user'; // Render GitLab flavoured Markdown // @@ -11,6 +12,7 @@ $.fn.renderGFM = function renderGFM() { syntaxHighlight(this.find('.js-syntax-highlight')); renderMath(this.find('.js-render-math')); renderMermaid(this.find('.js-render-mermaid')); + highlightCurrentUser(this.find('.gfm-project_member').get()); return this; }; diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 662363a6f26..caa6ce84335 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -25,6 +25,7 @@ import './components/board_sidebar'; import './components/new_list_dropdown'; import BoardAddIssuesModal from './components/modal/index.vue'; import '~/vue_shared/vue_resource_interceptor'; +import { NavigationType } from '~/lib/utils/common_utils'; export default () => { const $boardApp = document.getElementById('board-app'); @@ -32,6 +33,16 @@ export default () => { window.gl = window.gl || {}; + // check for browser back and trigger a hard reload to circumvent browser caching. + window.addEventListener('pageshow', (event) => { + const isNavTypeBackForward = window.performance && + window.performance.navigation.type === NavigationType.TYPE_BACK_FORWARD; + + if (event.persisted || isNavTypeBackForward) { + window.location.reload(); + } + }); + if (gl.IssueBoardsApp) { gl.IssueBoardsApp.$destroy(true); } diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index fc41ee4b777..e60c53338fe 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -5,22 +5,22 @@ import { __ } from '~/locale'; import createFlash from '~/flash'; import eventHub from '../../notes/event_hub'; import CompareVersions from './compare_versions.vue'; -import ChangedFiles from './changed_files.vue'; import DiffFile from './diff_file.vue'; import NoChanges from './no_changes.vue'; import HiddenFilesWarning from './hidden_files_warning.vue'; import CommitWidget from './commit_widget.vue'; +import TreeList from './tree_list.vue'; export default { name: 'DiffsApp', components: { Icon, CompareVersions, - ChangedFiles, DiffFile, NoChanges, HiddenFilesWarning, CommitWidget, + TreeList, }, props: { endpoint: { @@ -58,6 +58,7 @@ export default { plainDiffPath: state => state.diffs.plainDiffPath, emailPatchPath: state => state.diffs.emailPatchPath, }), + ...mapState('diffs', ['showTreeList']), ...mapGetters('diffs', ['isParallelView']), ...mapGetters(['isNotesFetched', 'discussionsStructuredByLineCode']), targetBranch() { @@ -88,6 +89,9 @@ export default { canCurrentUserFork() { return this.currentUser.canFork === true && this.currentUser.canCreateMergeRequest; }, + showCompareVersions() { + return this.mergeRequestDiffs && this.mergeRequestDiff; + }, }, watch: { diffViewType() { @@ -102,6 +106,8 @@ export default { this.adjustView(); }, + isLoading: 'adjustView', + showTreeList: 'adjustView', }, mounted() { this.setBaseConfig({ endpoint: this.endpoint, projectPath: this.projectPath }); @@ -152,10 +158,11 @@ export default { } }, adjustView() { - if (this.shouldShow && this.isParallelView) { - window.mrTabs.expandViewContainer(); - } else { - window.mrTabs.resetViewContainer(); + if (this.shouldShow) { + this.$nextTick(() => { + window.mrTabs.resetViewContainer(); + window.mrTabs.expandViewContainer(this.showTreeList); + }); } }, }, @@ -177,7 +184,7 @@ export default { class="diffs tab-pane" > <compare-versions - v-if="!commit && mergeRequestDiffs.length > 1" + v-if="showCompareVersions" :merge-request-diffs="mergeRequestDiffs" :merge-request-diff="mergeRequestDiff" :start-version="startVersion" @@ -215,22 +222,26 @@ export default { :commit="commit" /> - <changed-files - :diff-files="diffFiles" - /> - - <div - v-if="diffFiles.length > 0" - class="files" - > - <diff-file - v-for="file in diffFiles" - :key="file.newPath" - :file="file" - :can-current-user-fork="canCurrentUserFork" - /> + <div class="files d-flex prepend-top-default"> + <div + v-show="showTreeList" + class="diff-tree-list" + > + <tree-list /> + </div> + <div + v-if="diffFiles.length > 0" + class="diff-files-holder" + > + <diff-file + v-for="file in diffFiles" + :key="file.newPath" + :file="file" + :can-current-user-fork="canCurrentUserFork" + /> + </div> + <no-changes v-else /> </div> - <no-changes v-else /> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/changed_files.vue b/app/assets/javascripts/diffs/components/changed_files.vue deleted file mode 100644 index 97751db1254..00000000000 --- a/app/assets/javascripts/diffs/components/changed_files.vue +++ /dev/null @@ -1,171 +0,0 @@ -<script> -import { mapGetters, mapActions } from 'vuex'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import Icon from '~/vue_shared/components/icon.vue'; -import { pluralize } from '~/lib/utils/text_utility'; -import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; -import { contentTop } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; -import ChangedFilesDropdown from './changed_files_dropdown.vue'; -import changedFilesMixin from '../mixins/changed_files'; - -export default { - components: { - Icon, - ChangedFilesDropdown, - ClipboardButton, - }, - mixins: [changedFilesMixin], - data() { - return { - isStuck: false, - maxWidth: 'auto', - offsetTop: 0, - }; - }, - computed: { - ...mapGetters('diffs', ['isInlineView', 'isParallelView', 'areAllFilesCollapsed']), - sumAddedLines() { - return this.sumValues('addedLines'); - }, - sumRemovedLines() { - return this.sumValues('removedLines'); - }, - whitespaceVisible() { - return !getParameterValues('w')[0]; - }, - toggleWhitespaceText() { - if (this.whitespaceVisible) { - return __('Hide whitespace changes'); - } - return __('Show whitespace changes'); - }, - toggleWhitespacePath() { - if (this.whitespaceVisible) { - return mergeUrlParams({ w: 1 }, window.location.href); - } - - return mergeUrlParams({ w: 0 }, window.location.href); - }, - top() { - return `${this.offsetTop}px`; - }, - }, - created() { - document.addEventListener('scroll', this.handleScroll); - this.offsetTop = contentTop(); - }, - beforeDestroy() { - document.removeEventListener('scroll', this.handleScroll); - }, - methods: { - ...mapActions('diffs', ['setInlineDiffViewType', 'setParallelDiffViewType', 'expandAllFiles']), - pluralize, - handleScroll() { - if (!this.updating) { - this.$nextTick(this.updateIsStuck); - this.updating = true; - } - }, - updateIsStuck() { - if (!this.$refs.wrapper) { - return; - } - - const scrollPosition = window.scrollY; - - this.isStuck = scrollPosition + this.offsetTop >= this.$refs.placeholder.offsetTop; - this.updating = false; - }, - sumValues(key) { - return this.diffFiles.reduce((total, file) => total + file[key], 0); - }, - }, -}; -</script> - -<template> - <span> - <div ref="placeholder"></div> - <div - ref="wrapper" - :style="{ top }" - :class="{'is-stuck': isStuck}" - class="content-block oneline-block diff-files-changed diff-files-changed-merge-request - files-changed js-diff-files-changed" - > - <div class="files-changed-inner"> - <div - class="inline-parallel-buttons d-none d-md-block" - > - <a - v-if="areAllFilesCollapsed" - class="btn btn-default" - @click="expandAllFiles" - > - {{ __('Expand all') }} - </a> - <a - :href="toggleWhitespacePath" - class="btn btn-default" - > - {{ toggleWhitespaceText }} - </a> - <div class="btn-group"> - <button - id="inline-diff-btn" - :class="{ active: isInlineView }" - type="button" - class="btn js-inline-diff-button" - data-view-type="inline" - @click="setInlineDiffViewType" - > - {{ __('Inline') }} - </button> - <button - id="parallel-diff-btn" - :class="{ active: isParallelView }" - type="button" - class="btn js-parallel-diff-button" - data-view-type="parallel" - @click="setParallelDiffViewType" - > - {{ __('Side-by-side') }} - </button> - </div> - </div> - - <div class="commit-stat-summary dropdown"> - <changed-files-dropdown - :diff-files="diffFiles" - /> - - <span - class="js-diff-stats-additions-deletions-expanded - diff-stats-additions-deletions-expanded" - > - with - <strong class="cgreen"> - {{ pluralize(`${sumAddedLines} addition`, sumAddedLines) }} - </strong> - and - <strong class="cred"> - {{ pluralize(`${sumRemovedLines} deletion`, sumRemovedLines) }} - </strong> - </span> - <div - class="js-diff-stats-additions-deletions-collapsed - diff-stats-additions-deletions-collapsed float-right d-sm-none" - > - <strong class="cgreen"> - +{{ sumAddedLines }} - </strong> - <strong class="cred"> - -{{ sumRemovedLines }} - </strong> - </div> - </div> - </div> - </div> - </span> -</template> diff --git a/app/assets/javascripts/diffs/components/changed_files_dropdown.vue b/app/assets/javascripts/diffs/components/changed_files_dropdown.vue deleted file mode 100644 index 0ec6b8b7f21..00000000000 --- a/app/assets/javascripts/diffs/components/changed_files_dropdown.vue +++ /dev/null @@ -1,126 +0,0 @@ -<script> -import Icon from '~/vue_shared/components/icon.vue'; -import changedFilesMixin from '../mixins/changed_files'; - -export default { - components: { - Icon, - }, - mixins: [changedFilesMixin], - data() { - return { - searchText: '', - }; - }, - computed: { - filteredDiffFiles() { - return this.diffFiles.filter(file => - file.filePath.toLowerCase().includes(this.searchText.toLowerCase()), - ); - }, - }, - methods: { - clearSearch() { - this.searchText = ''; - }, - }, -}; -</script> - -<template> - <span> - Showing - <button - class="diff-stats-summary-toggler" - data-toggle="dropdown" - type="button" - aria-expanded="false" - > - <span> - {{ n__('%d changed file', '%d changed files', diffFiles.length) }} - </span> - <icon - class="caret-icon" - name="chevron-down" - /> - </button> - <div class="dropdown-menu diff-file-changes"> - <div class="dropdown-input"> - <input - v-model="searchText" - type="search" - class="dropdown-input-field" - placeholder="Search files" - autocomplete="off" - /> - <i - v-if="searchText.length === 0" - aria-hidden="true" - data-hidden="true" - class="fa fa-search dropdown-input-search"> - </i> - <i - v-else - role="button" - class="fa fa-times dropdown-input-search" - @click.stop.prevent="clearSearch" - ></i> - </div> - <div class="dropdown-content"> - <ul> - <li - v-for="diffFile in filteredDiffFiles" - :key="diffFile.name" - > - <a - :href="`#${diffFile.fileHash}`" - :title="diffFile.newPath" - class="diff-changed-file" - > - <icon - :name="fileChangedIcon(diffFile)" - :size="16" - :class="fileChangedClass(diffFile)" - class="diff-file-changed-icon append-right-8" - /> - <span class="diff-changed-file-content append-right-8"> - <strong - v-if="diffFile.blob && diffFile.blob.name" - class="diff-changed-file-name" - > - {{ diffFile.blob.name }} - </strong> - <strong - v-else - class="diff-changed-blank-file-name" - > - {{ s__('Diffs|No file name available') }} - </strong> - <span class="diff-changed-file-path prepend-top-5"> - {{ truncatedDiffPath(diffFile.blob.path) }} - </span> - </span> - <span class="diff-changed-stats"> - <span class="cgreen"> - +{{ diffFile.addedLines }} - </span> - <span class="cred"> - -{{ diffFile.removedLines }} - </span> - </span> - </a> - </li> - - <li - v-show="filteredDiffFiles.length === 0" - class="dropdown-menu-empty-item" - > - <a> - {{ __('No files found') }} - </a> - </li> - </ul> - </div> - </div> - </span> -</template> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 1c9ad8e77f1..9bbf62c0eb6 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -1,9 +1,18 @@ <script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import Tooltip from '@gitlab-org/gitlab-ui/dist/directives/tooltip'; +import { __ } from '~/locale'; +import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; +import Icon from '~/vue_shared/components/icon.vue'; import CompareVersionsDropdown from './compare_versions_dropdown.vue'; export default { components: { CompareVersionsDropdown, + Icon, + }, + directives: { + Tooltip, }, props: { mergeRequestDiffs: { @@ -26,30 +35,119 @@ export default { }, }, computed: { + ...mapState('diffs', ['commit', 'showTreeList']), + ...mapGetters('diffs', ['isInlineView', 'isParallelView', 'areAllFilesCollapsed']), comparableDiffs() { return this.mergeRequestDiffs.slice(1); }, + isWhitespaceVisible() { + return !getParameterValues('w')[0]; + }, + toggleWhitespaceText() { + if (this.isWhitespaceVisible) { + return __('Hide whitespace changes'); + } + return __('Show whitespace changes'); + }, + toggleWhitespacePath() { + if (this.isWhitespaceVisible) { + return mergeUrlParams({ w: 1 }, window.location.href); + } + + return mergeUrlParams({ w: 0 }, window.location.href); + }, + showDropdowns() { + return !this.commit && this.mergeRequestDiffs.length; + }, + }, + methods: { + ...mapActions('diffs', [ + 'setInlineDiffViewType', + 'setParallelDiffViewType', + 'expandAllFiles', + 'toggleShowTreeList', + ]), }, }; </script> <template> <div class="mr-version-controls"> - <div class="mr-version-menus-container content-block"> - Changes between - <compare-versions-dropdown - :other-versions="mergeRequestDiffs" - :merge-request-version="mergeRequestDiff" - :show-commit-count="true" - class="mr-version-dropdown" - /> - and - <compare-versions-dropdown - :other-versions="comparableDiffs" - :start-version="startVersion" - :target-branch="targetBranch" - class="mr-version-compare-dropdown" - /> + <div + class="mr-version-menus-container content-block" + > + <button + v-tooltip.hover + type="button" + class="btn btn-default append-right-8 js-toggle-tree-list" + :class="{ + active: showTreeList + }" + :title="__('Toggle file browser')" + @click="toggleShowTreeList" + > + <icon + name="hamburger" + /> + </button> + <div + v-if="showDropdowns" + class="d-flex align-items-center compare-versions-container" + > + Changes between + <compare-versions-dropdown + :other-versions="mergeRequestDiffs" + :merge-request-version="mergeRequestDiff" + :show-commit-count="true" + class="mr-version-dropdown" + /> + and + <compare-versions-dropdown + :other-versions="comparableDiffs" + :start-version="startVersion" + :target-branch="targetBranch" + class="mr-version-compare-dropdown" + /> + </div> + <div + class="inline-parallel-buttons d-none d-md-flex ml-auto" + > + <a + v-if="areAllFilesCollapsed" + class="btn btn-default" + @click="expandAllFiles" + > + {{ __('Expand all') }} + </a> + <a + :href="toggleWhitespacePath" + class="btn btn-default" + > + {{ toggleWhitespaceText }} + </a> + <div class="btn-group prepend-left-8"> + <button + id="inline-diff-btn" + :class="{ active: isInlineView }" + type="button" + class="btn js-inline-diff-button" + data-view-type="inline" + @click="setInlineDiffViewType" + > + {{ __('Inline') }} + </button> + <button + id="parallel-diff-btn" + :class="{ active: isParallelView }" + type="button" + class="btn js-parallel-diff-button" + data-view-type="parallel" + @click="setParallelDiffViewType" + > + {{ __('Side-by-side') }} + </button> + </div> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue index 96cccb49378..c3acc352d5e 100644 --- a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue +++ b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue @@ -108,7 +108,7 @@ export default { <template> <span class="dropdown inline"> <a - class="dropdown-toggle btn btn-default" + class="dropdown-menu-toggle btn btn-default w-100" data-toggle="dropdown" aria-expanded="false" > @@ -118,6 +118,7 @@ export default { <Icon :size="12" name="angle-down" + class="position-absolute" /> </a> <div class="dropdown-menu dropdown-select dropdown-menu-selectable"> @@ -163,3 +164,10 @@ export default { </div> </span> </template> + +<style> +.dropdown { + min-width: 0; + max-height: 170px; +} +</style> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index bcbe374a90c..4e04e50c52a 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,5 +1,5 @@ <script> -import { mapActions, mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import _ from 'underscore'; import { __, sprintf } from '~/locale'; import createFlash from '~/flash'; @@ -28,6 +28,7 @@ export default { }; }, computed: { + ...mapState('diffs', ['currentDiffFileId']), ...mapGetters(['isNotesFetched', 'discussionsStructuredByLineCode']), isCollapsed() { return this.file.collapsed || false; @@ -101,6 +102,9 @@ export default { <template> <div :id="file.fileHash" + :class="{ + 'is-active': currentDiffFileId === file.fileHash + }" class="diff-file file-holder" > <diff-file-header @@ -168,3 +172,20 @@ export default { </div> </div> </template> + +<style> +@keyframes shadow-fade { + from { + box-shadow: 0 0 4px #919191; + } + + to { + box-shadow: 0 0 0 #dfdfdf; + } +} + +.diff-file.is-active { + box-shadow: 0 0 0 #dfdfdf; + animation: shadow-fade 1.2s 0.1s 1; +} +</style> diff --git a/app/assets/javascripts/diffs/components/file_row_stats.vue b/app/assets/javascripts/diffs/components/file_row_stats.vue new file mode 100644 index 00000000000..105f7ebdbed --- /dev/null +++ b/app/assets/javascripts/diffs/components/file_row_stats.vue @@ -0,0 +1,30 @@ +<script> +export default { + props: { + file: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <span + v-once + class="file-row-stats" + > + <span class="cgreen"> + +{{ file.addedLines }} + </span> + <span class="cred"> + -{{ file.removedLines }} + </span> + </span> +</template> + +<style> +.file-row-stats { + font-size: 12px; +} +</style> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue new file mode 100644 index 00000000000..cfe4273742f --- /dev/null +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -0,0 +1,101 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import FileRow from '~/vue_shared/components/file_row.vue'; +import FileRowStats from './file_row_stats.vue'; + +export default { + components: { + Icon, + FileRow, + }, + data() { + return { + search: '', + }; + }, + computed: { + ...mapState('diffs', ['tree', 'addedLines', 'removedLines']), + ...mapGetters('diffs', ['allBlobs', 'diffFilesLength']), + filteredTreeList() { + const search = this.search.toLowerCase().trim(); + + if (search === '') return this.tree; + + return this.allBlobs.filter(f => f.name.toLowerCase().indexOf(search) >= 0); + }, + }, + methods: { + ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), + clearSearch() { + this.search = ''; + }, + }, + FileRowStats, +}; +</script> + +<template> + <div class="tree-list-holder d-flex flex-column"> + <div class="append-bottom-8 position-relative tree-list-search"> + <icon + name="search" + class="position-absolute tree-list-icon" + /> + <input + v-model="search" + :placeholder="s__('MergeRequest|Filter files')" + type="search" + class="form-control" + /> + <button + v-show="search" + :aria-label="__('Clear search')" + type="button" + class="position-absolute tree-list-icon tree-list-clear-icon border-0 p-0" + @click="clearSearch" + > + <icon + name="close" + /> + </button> + </div> + <div + class="tree-list-scroll" + > + <template v-if="filteredTreeList.length"> + <file-row + v-for="file in filteredTreeList" + :key="file.key" + :file="file" + :level="0" + :hide-extra-on-tree="true" + :extra-component="$options.FileRowStats" + :show-changed-icon="true" + @toggleTreeOpen="toggleTreeOpen" + @clickFile="scrollToFile" + /> + </template> + <p + v-else + class="prepend-top-20 append-bottom-20 text-center" + > + {{ s__('MergeRequest|No files found') }} + </p> + </div> + <div + v-once + class="pt-3 pb-3 text-center" + > + {{ n__('%d changed file', '%d changed files', diffFilesLength) }} + <div> + <span class="cgreen"> + {{ n__('%d addition', '%d additions', addedLines) }} + </span> + <span class="cred"> + {{ n__('%d deleted', '%d deletions', removedLines) }} + </span> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 2795dddfc48..6a50d2c1426 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -29,3 +29,5 @@ export const LENGTH_OF_AVATAR_TOOLTIP = 17; export const LINES_TO_BE_RENDERED_DIRECTLY = 100; export const MAX_LINES_TO_BE_RENDERED = 2000; + +export const MR_TREE_SHOW_KEY = 'mr_tree_show'; diff --git a/app/assets/javascripts/diffs/mixins/changed_files.js b/app/assets/javascripts/diffs/mixins/changed_files.js deleted file mode 100644 index da1339f0ffa..00000000000 --- a/app/assets/javascripts/diffs/mixins/changed_files.js +++ /dev/null @@ -1,38 +0,0 @@ -export default { - props: { - diffFiles: { - type: Array, - required: true, - }, - }, - methods: { - fileChangedIcon(diffFile) { - if (diffFile.deletedFile) { - return 'file-deletion'; - } else if (diffFile.newFile) { - return 'file-addition'; - } - return 'file-modified'; - }, - fileChangedClass(diffFile) { - if (diffFile.deletedFile) { - return 'cred'; - } else if (diffFile.newFile) { - return 'cgreen'; - } - - return ''; - }, - truncatedDiffPath(path) { - const maxLength = 60; - - if (path.length > maxLength) { - const start = path.length - maxLength; - const end = start + maxLength; - return `...${path.slice(start, end)}`; - } - - return path; - }, - }, -}; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 98d8d5943f9..1e0b27b538d 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -12,6 +12,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME, + MR_TREE_SHOW_KEY, } from '../constants'; export const setBaseConfig = ({ commit }, options) => { @@ -195,5 +196,23 @@ export const saveDiffDiscussion = ({ dispatch }, { note, formData }) => { .catch(() => createFlash(s__('MergeRequests|Saving the comment failed'))); }; +export const toggleTreeOpen = ({ commit }, path) => { + commit(types.TOGGLE_FOLDER_OPEN, path); +}; + +export const scrollToFile = ({ state, commit }, path) => { + const { fileHash } = state.treeEntries[path]; + document.location.hash = fileHash; + + commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); + + setTimeout(() => commit(types.UPDATE_CURRENT_DIFF_FILE_ID, ''), 1000); +}; + +export const toggleShowTreeList = ({ commit, state }) => { + commit(types.TOGGLE_SHOW_TREE_LIST); + localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 968ba3c5e13..d4c205882ff 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -110,5 +110,9 @@ export const shouldRenderInlineCommentRow = state => line => { export const getDiffFileByHash = state => fileHash => state.diffFiles.find(file => file.fileHash === fileHash); +export const allBlobs = state => Object.values(state.treeEntries).filter(f => f.type === 'blob'); + +export const diffFilesLength = state => state.diffFiles.length; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index eb596b251c1..ae8930c8968 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -1,10 +1,11 @@ import Cookies from 'js-cookie'; import { getParameterValues } from '~/lib/utils/url_utility'; -import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants'; +import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME, MR_TREE_SHOW_KEY } from '../../constants'; const viewTypeFromQueryString = getParameterValues('view')[0]; const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); const defaultViewType = INLINE_DIFF_VIEW_TYPE; +const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY); export default () => ({ isLoading: true, @@ -17,4 +18,8 @@ export default () => ({ mergeRequestDiff: null, diffLineCommentForms: {}, diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, + tree: [], + treeEntries: {}, + showTreeList: storedTreeShow === null ? true : storedTreeShow === 'true', + currentDiffFileId: '', }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index f61efbe6e1e..6474ee628e2 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -11,3 +11,6 @@ export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES'; export const RENDER_FILE = 'RENDER_FILE'; export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE'; export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE'; +export const TOGGLE_FOLDER_OPEN = 'TOGGLE_FOLDER_OPEN'; +export const TOGGLE_SHOW_TREE_LIST = 'TOGGLE_SHOW_TREE_LIST'; +export const UPDATE_CURRENT_DIFF_FILE_ID = 'UPDATE_CURRENT_DIFF_FILE_ID'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 59a2c09e54f..0b4485ecdb5 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { sortTree } from '~/ide/stores/utils'; import { findDiffFile, addLineReferences, @@ -7,6 +8,7 @@ import { addContextLines, prepareDiffData, isDiscussionApplicableToLine, + generateTreeList, } from './utils'; import * as types from './mutation_types'; @@ -23,9 +25,12 @@ export default { [types.SET_DIFF_DATA](state, data) { const diffData = convertObjectPropsToCamelCase(data, { deep: true }); prepareDiffData(diffData); + const { tree, treeEntries } = generateTreeList(diffData.diffFiles); Object.assign(state, { ...diffData, + tree: sortTree(tree), + treeEntries, }); }, @@ -163,4 +168,13 @@ export default { } } }, + [types.TOGGLE_FOLDER_OPEN](state, path) { + state.treeEntries[path].opened = !state.treeEntries[path].opened; + }, + [types.TOGGLE_SHOW_TREE_LIST](state) { + state.showTreeList = !state.showTreeList; + }, + [types.UPDATE_CURRENT_DIFF_FILE_ID](state, fileId) { + state.currentDiffFileId = fileId; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 631e3de311e..4ae588042e4 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -267,3 +267,49 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD return latestDiff && discussion.active && lineCode === discussion.line_code; } + +export const generateTreeList = files => + files.reduce( + (acc, file) => { + const { fileHash, addedLines, removedLines, newFile, deletedFile, newPath } = file; + const split = newPath.split('/'); + + split.forEach((name, i) => { + const parent = acc.treeEntries[split.slice(0, i).join('/')]; + const path = `${parent ? `${parent.path}/` : ''}${name}`; + + if (!acc.treeEntries[path]) { + const type = path === newPath ? 'blob' : 'tree'; + acc.treeEntries[path] = { + key: path, + path, + name, + type, + tree: [], + }; + + const entry = acc.treeEntries[path]; + + if (type === 'blob') { + Object.assign(entry, { + changed: true, + tempFile: newFile, + deleted: deletedFile, + fileHash, + addedLines, + removedLines, + }); + } else { + Object.assign(entry, { + opened: true, + }); + } + + (parent ? parent.tree : acc.tree).push(entry); + } + }); + + return acc; + }, + { treeEntries: {}, tree: [] }, + ); diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 8aecf9725e6..c568f4e4ebf 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -51,7 +51,11 @@ export default class DropdownHint extends FilteredSearchDropdown { FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' ')); } - FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container); + const key = token.replace(':', ''); + const { uppercaseTokenName } = this.tokenKeys.searchByKey(key); + FilteredSearchDropdownManager.addWordToInput(key, '', false, { + uppercaseTokenName, + }); } this.dismissDropdown(); this.dispatchInputEvent(); diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 27fff488603..6da6ca10008 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -143,7 +143,9 @@ export default class DropdownUtils { const dataValue = selected.getAttribute('data-value'); if (dataValue) { - FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true); + FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, { + capitalizeTokenValue: selected.hasAttribute('data-capitalize'), + }); } // Return boolean based on whether it was set diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 207616b9de2..cd3d532c958 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -91,6 +91,11 @@ export default class FilteredSearchDropdownManager { gl: DropdownEmoji, element: this.container.querySelector('#js-dropdown-my-reaction'), }, + wip: { + reference: null, + gl: DropdownNonUser, + element: this.container.querySelector('#js-dropdown-wip'), + }, status: { reference: null, gl: NullDropdown, @@ -136,10 +141,16 @@ export default class FilteredSearchDropdownManager { return endpoint; } - static addWordToInput(tokenName, tokenValue = '', clicked = false) { + static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) { + const { + uppercaseTokenName = false, + capitalizeTokenValue = false, + } = options; const input = FilteredSearchContainer.container.querySelector('.filtered-search'); - - FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue); + FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, { + uppercaseTokenName, + capitalizeTokenValue, + }); input.value = ''; if (clicked) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index d25f6f95b22..54533ebb70d 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -405,7 +405,10 @@ export default class FilteredSearchManager { if (isLastVisualTokenValid) { tokens.forEach((t) => { input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, ''); - FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`); + FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`, { + uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key), + capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key), + }); }); const fragments = searchToken.split(':'); @@ -421,7 +424,10 @@ export default class FilteredSearchManager { FilteredSearchVisualTokens.addSearchVisualToken(searchTerms); } - FilteredSearchVisualTokens.addFilterVisualToken(tokenKey); + FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, { + uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey), + capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), + }); input.value = input.value.replace(`${tokenKey}:`, ''); } } else { @@ -429,7 +435,10 @@ export default class FilteredSearchManager { const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g; if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') { - FilteredSearchVisualTokens.addFilterVisualToken(searchToken); + const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial(); + FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, { + capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), + }); // Trim the last space as seen in the if statement above input.value = input.value.replace(searchToken, '').trim(); @@ -480,7 +489,7 @@ export default class FilteredSearchManager { FilteredSearchVisualTokens.addFilterVisualToken( condition.tokenKey, condition.value, - canEdit, + { canEdit }, ); } else { // Sanitize value since URL converts spaces into + @@ -506,10 +515,15 @@ export default class FilteredSearchManager { hasFilteredSearch = true; const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue); + const { uppercaseTokenName, capitalizeTokenValue } = match; FilteredSearchVisualTokens.addFilterVisualToken( sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, - canEdit, + { + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }, ); } else if (!match && keyParam === 'assignee_id') { const id = parseInt(value, 10); @@ -517,7 +531,7 @@ export default class FilteredSearchManager { hasFilteredSearch = true; const tokenName = 'assignee'; const canEdit = this.canEdit && this.canEdit(tokenName); - FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); + FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { canEdit }); } } else if (!match && keyParam === 'author_id') { const id = parseInt(value, 10); @@ -525,7 +539,7 @@ export default class FilteredSearchManager { hasFilteredSearch = true; const tokenName = 'author'; const canEdit = this.canEdit && this.canEdit(tokenName); - FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); + FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { canEdit }); } } else if (!match && keyParam === 'search') { hasFilteredSearch = true; @@ -561,15 +575,17 @@ export default class FilteredSearchManager { this.saveCurrentSearchQuery(); - const { tokens, searchToken } - = this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys()); + const tokenKeys = this.filteredSearchTokenKeys.getKeys(); + const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, tokenKeys); const currentState = state || getParameterByName('state') || 'opened'; paths.push(`state=${currentState}`); tokens.forEach((token) => { const condition = this.filteredSearchTokenKeys .searchByConditionKeyValue(token.key, token.value.toLowerCase()); - const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; + const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; + const { param } = tokenConfig; + // Replace hyphen with underscore to use as request parameter // e.g. 'my-reaction' => 'my_reaction' const underscoredKey = token.key.replace('-', '_'); @@ -581,6 +597,10 @@ export default class FilteredSearchManager { } else { let tokenValue = token.value; + if (tokenConfig.lowercaseValueOnSubmit) { + tokenValue = tokenValue.toLowerCase(); + } + if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') || (tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) { tokenValue = tokenValue.slice(1, tokenValue.length - 1); diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 5d131b396a0..a09ad3e4758 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -23,6 +23,16 @@ export default class FilteredSearchTokenKeys { return this.conditions; } + shouldUppercaseTokenName(tokenKey) { + const token = this.searchByKey(tokenKey.toLowerCase()); + return token && token.uppercaseTokenName; + } + + shouldCapitalizeTokenValue(tokenKey) { + const token = this.searchByKey(tokenKey.toLowerCase()); + return token && token.capitalizeTokenValue; + } + searchByKey(key) { return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null; } @@ -55,4 +65,21 @@ export default class FilteredSearchTokenKeys { return this.conditions .find(condition => condition.tokenKey === key && condition.value === value) || null; } + + addExtraTokensForMergeRequests() { + const wipToken = { + key: 'wip', + type: 'string', + param: '', + symbol: '', + icon: 'admin', + tag: 'Yes or No', + lowercaseValueOnSubmit: true, + uppercaseTokenName: true, + capitalizeTokenValue: true, + }; + + this.tokenKeys.push(wipToken); + this.tokenKeysWithAlternative.push(wipToken); + } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 56fe1ab4e90..0854c1822fb 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -55,12 +55,18 @@ export default class FilteredSearchVisualTokens { } } - static createVisualTokenElementHTML(canEdit = true) { + static createVisualTokenElementHTML(options = {}) { + const { + canEdit = true, + uppercaseTokenName = false, + capitalizeTokenValue = false, + } = options; + return ` <div class="${canEdit ? 'selectable' : 'hidden'}" role="button"> - <div class="name"></div> + <div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div> <div class="value-container"> - <div class="value"></div> + <div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div> <div class="remove-token" role="button"> <i class="fa fa-close"></i> </div> @@ -182,16 +188,26 @@ export default class FilteredSearchVisualTokens { } } - static addVisualTokenElement(name, value, isSearchTerm, canEdit) { + static addVisualTokenElement(name, value, options = {}) { + const { + isSearchTerm = false, + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + } = options; const li = document.createElement('li'); li.classList.add('js-visual-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); if (value) { - li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit); + li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }); FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); } else { - li.innerHTML = '<div class="name"></div>'; + li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`; } li.querySelector('.name').innerText = name; @@ -212,20 +228,32 @@ export default class FilteredSearchVisualTokens { } } - static addFilterVisualToken(tokenName, tokenValue, canEdit) { + static addFilterVisualToken(tokenName, tokenValue, { + canEdit, + uppercaseTokenName = false, + capitalizeTokenValue = false, + } = {}) { const { lastVisualToken, isLastVisualTokenValid } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const { addVisualTokenElement } = FilteredSearchVisualTokens; if (isLastVisualTokenValid) { - addVisualTokenElement(tokenName, tokenValue, false, canEdit); + addVisualTokenElement(tokenName, tokenValue, { + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }); } else { const previousTokenName = lastVisualToken.querySelector('.name').innerText; const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); tokensContainer.removeChild(lastVisualToken); const value = tokenValue || tokenName; - addVisualTokenElement(previousTokenName, value, false, canEdit); + addVisualTokenElement(previousTokenName, value, { + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }); } } @@ -235,7 +263,9 @@ export default class FilteredSearchVisualTokens { if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) { lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`; } else { - FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, true); + FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, { + isSearchTerm: true, + }); } } @@ -306,7 +336,9 @@ export default class FilteredSearchVisualTokens { let value; if (token.classList.contains('filtered-search-token')) { - FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText); + FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText, null, { + uppercaseTokenName: nameElement.classList.contains('text-uppercase'), + }); const valueContainerElement = token.querySelector('.value-container'); value = valueContainerElement.dataset.originalValue; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 3aca38399fb..b0e60edcbe5 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -3,7 +3,7 @@ import $ from 'jquery'; import { mapActions } from 'vuex'; import { __ } from '~/locale'; import FileIcon from '~/vue_shared/components/file_icon.vue'; -import ChangedFileIcon from '../changed_file_icon.vue'; +import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/ide/components/file_finder/item.vue index a612739d641..72ce37be63a 100644 --- a/app/assets/javascripts/ide/components/file_finder/item.vue +++ b/app/assets/javascripts/ide/components/file_finder/item.vue @@ -1,7 +1,7 @@ <script> import fuzzaldrinPlus from 'fuzzaldrin-plus'; import FileIcon from '../../../vue_shared/components/file_icon.vue'; -import ChangedFileIcon from '../changed_file_icon.vue'; +import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue'; const MAX_PATH_LENGTH = 60; diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 44a360ab909..2ad14b88410 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -3,8 +3,8 @@ import { mapGetters } from 'vuex'; import { n__, __, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; +import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; import NewDropdown from './new_dropdown/index.vue'; -import ChangedFileIcon from './changed_file_icon.vue'; import MrFileIcon from './mr_file_icon.vue'; export default { diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index db47b75ec5c..d621653d6fd 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -3,8 +3,8 @@ import { mapActions } from 'vuex'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; import FileStatusIcon from './repo_file_status_icon.vue'; -import ChangedFileIcon from './changed_file_icon.vue'; export default { components: { diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 75dfdedcf1b..d08e8ba0c4b 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,10 +1,11 @@ import Vue from 'vue'; +import sanitize from 'sanitize-html'; import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; -document.addEventListener('DOMContentLoaded', () => { +export default function initIssueableApp() { const initialDataEl = document.getElementById('js-issuable-app-initial-data'); - const props = JSON.parse(initialDataEl.innerHTML.replace(/"/g, '"')); + const props = JSON.parse(sanitize(initialDataEl.textContent).replace(/"/g, '"')); return new Vue({ el: document.getElementById('js-issuable-app'), @@ -17,4 +18,4 @@ document.addEventListener('DOMContentLoaded', () => { }); }, }); -}); +} diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js index 0e71e705c13..854445bd2a4 100644 --- a/app/assets/javascripts/job.js +++ b/app/assets/javascripts/job.js @@ -24,7 +24,6 @@ export default class Job extends LogOutputBehaviours { this.$document = $(document); this.$window = $(window); this.logBytes = 0; - this.updateDropdown = this.updateDropdown.bind(this); this.$buildTrace = $('#build-trace'); this.$buildRefreshAnimation = $('.js-build-refresh'); @@ -35,18 +34,12 @@ export default class Job extends LogOutputBehaviours { clearTimeout(this.timeout); this.initSidebar(); - this.populateJobs(this.buildStage); - this.updateStageDropdownText(this.buildStage); this.sidebarOnResize(); this.$document .off('click', '.js-sidebar-build-toggle') .on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this)); - this.$document - .off('click', '.stage-item') - .on('click', '.stage-item', this.updateDropdown); - this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100); this.$window @@ -194,20 +187,4 @@ export default class Job extends LogOutputBehaviours { if (this.shouldHideSidebarForViewport()) this.toggleSidebar(); } - // eslint-disable-next-line class-methods-use-this - populateJobs(stage) { - $('.build-job').hide(); - $(`.build-job[data-stage="${stage}"]`).show(); - } - // eslint-disable-next-line class-methods-use-this - updateStageDropdownText(stage) { - $('.stage-selection').text(stage); - } - - updateDropdown(e) { - e.preventDefault(); - const stage = e.currentTarget.text; - this.updateStageDropdownText(stage); - this.populateJobs(stage); - } } diff --git a/app/assets/javascripts/jobs/components/jobs_container.vue b/app/assets/javascripts/jobs/components/jobs_container.vue index 93e2292ff84..271b7790d75 100644 --- a/app/assets/javascripts/jobs/components/jobs_container.vue +++ b/app/assets/javascripts/jobs/components/jobs_container.vue @@ -1,4 +1,5 @@ <script> + import _ from 'underscore'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; @@ -16,26 +17,39 @@ type: Array, required: true, }, + jobId: { + type: Number, + required: true, + }, + }, + methods: { + isJobActive(currentJobId) { + return this.jobId === currentJobId; + }, + tooltipText(job) { + return `${_.escape(job.name)} - ${job.status.tooltip}`; + }, }, }; </script> <template> - <div class="builds-container"> + <div class="js-jobs-container builds-container"> <div + v-for="job in jobs" + :key="job.id" class="build-job" + :class="{ retried: job.retried, active: isJobActive(job.id) }" > <a - v-for="job in jobs" - :key="job.id" v-tooltip - :href="job.path" - :title="job.tooltip" - :class="{ active: job.active, retried: job.retried }" + :href="job.status.details_path" + :title="tooltipText(job)" + data-container="body" > <icon - v-if="job.active" + v-if="isJobActive(job.id)" name="arrow-right" - class="js-arrow-right" + class="js-arrow-right icon-arrow-right" /> <ci-icon :status="job.status" /> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue new file mode 100644 index 00000000000..22bcd402e72 --- /dev/null +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -0,0 +1,297 @@ +<script> + import _ from 'underscore'; + import { mapActions, mapState } from 'vuex'; + import timeagoMixin from '~/vue_shared/mixins/timeago'; + import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; + import Icon from '~/vue_shared/components/icon.vue'; + import DetailRow from './sidebar_detail_row.vue'; + import ArtifactsBlock from './artifacts_block.vue'; + import TriggerBlock from './trigger_block.vue'; + import CommitBlock from './commit_block.vue'; + import StagesDropdown from './stages_dropdown.vue'; + import JobsContainer from './jobs_container.vue'; + + export default { + name: 'JobSidebar', + components: { + ArtifactsBlock, + CommitBlock, + DetailRow, + Icon, + TriggerBlock, + StagesDropdown, + JobsContainer, + }, + mixins: [timeagoMixin], + props: { + runnerHelpUrl: { + type: String, + required: false, + default: '', + }, + terminalPath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + ...mapState(['job', 'isLoading', 'stages', 'jobs']), + coverage() { + return `${this.job.coverage}%`; + }, + duration() { + return timeIntervalInWords(this.job.duration); + }, + queued() { + return timeIntervalInWords(this.job.queued); + }, + runnerId() { + return `${this.job.runner.description} (#${this.job.runner.id})`; + }, + retryButtonClass() { + let className = + 'js-retry-button float-right btn btn-retry d-none d-md-block d-lg-block d-xl-block'; + className += + this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary'; + return className; + }, + hasTimeout() { + return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; + }, + timeout() { + if (this.job.metadata == null) { + return ''; + } + + let t = this.job.metadata.timeout_human_readable; + if (this.job.metadata.timeout_source !== '') { + t += ` (from ${this.job.metadata.timeout_source})`; + } + + return t; + }, + renderBlock() { + return ( + this.job.merge_request || + this.job.duration || + this.job.finished_data || + this.job.erased_at || + this.job.queued || + this.job.runner || + this.job.coverage || + this.job.tags.length || + this.job.cancel_path + ); + }, + hasArtifact() { + return !_.isEmpty(this.job.artifact); + }, + hasTriggers() { + return !_.isEmpty(this.job.trigger); + }, + hasStages() { + return ( + (this.job && + this.job.pipeline && + this.job.pipeline.stages && + this.job.pipeline.stages.length > 0) || + false + ); + }, + commit() { + return this.job.pipeline.commit || {}; + }, + }, + methods: { + ...mapActions(['fetchJobsForStage']), + }, + }; +</script> +<template> + <aside + class="right-sidebar right-sidebar-expanded build-sidebar" + data-offset-top="101" + data-spy="affix" + > + <div class="sidebar-container"> + <div class="blocks-container"> + <template v-if="!isLoading"> + <div class="block"> + <strong class="inline prepend-top-8"> + {{ job.name }} + </strong> + <a + v-if="job.retry_path" + :class="retryButtonClass" + :href="job.retry_path" + data-method="post" + rel="nofollow" + > + {{ __('Retry') }} + </a> + <a + v-if="terminalPath" + :href="terminalPath" + class="js-terminal-link pull-right btn btn-primary + btn-inverted visible-md-block visible-lg-block" + target="_blank" + > + {{ __('Debug') }} + <icon name="external-link" /> + </a> + <button + :aria-label="__('Toggle Sidebar')" + type="button" + class="btn btn-blank gutter-toggle + float-right d-block d-md-none js-sidebar-build-toggle" + > + <i + aria-hidden="true" + data-hidden="true" + class="fa fa-angle-double-right" + ></i> + </button> + </div> + <div + v-if="job.retry_path || job.new_issue_path" + class="block retry-link" + > + <a + v-if="job.new_issue_path" + :href="job.new_issue_path" + class="js-new-issue btn btn-success btn-inverted" + > + {{ __('New issue') }} + </a> + <a + v-if="job.retry_path" + :href="job.retry_path" + class="js-retry-job btn btn-inverted-secondary" + data-method="post" + rel="nofollow" + > + {{ __('Retry') }} + </a> + </div> + <div :class="{ block : renderBlock }"> + <p + v-if="job.merge_request" + class="build-detail-row js-job-mr" + > + <span class="build-light-text"> + {{ __('Merge Request:') }} + </span> + <a :href="job.merge_request.path"> + !{{ job.merge_request.iid }} + </a> + </p> + + <detail-row + v-if="job.duration" + :value="duration" + class="js-job-duration" + title="Duration" + /> + <detail-row + v-if="job.finished_at" + :value="timeFormated(job.finished_at)" + class="js-job-finished" + title="Finished" + /> + <detail-row + v-if="job.erased_at" + :value="timeFormated(job.erased_at)" + class="js-job-erased" + title="Erased" + /> + <detail-row + v-if="job.queued" + :value="queued" + class="js-job-queued" + title="Queued" + /> + <detail-row + v-if="hasTimeout" + :help-url="runnerHelpUrl" + :value="timeout" + class="js-job-timeout" + title="Timeout" + /> + <detail-row + v-if="job.runner" + :value="runnerId" + class="js-job-runner" + title="Runner" + /> + <detail-row + v-if="job.coverage" + :value="coverage" + class="js-job-coverage" + title="Coverage" + /> + <p + v-if="job.tags.length" + class="build-detail-row js-job-tags" + > + <span class="build-light-text"> + {{ __('Tags:') }} + </span> + <span + v-for="(tag, i) in job.tags" + :key="i" + class="label label-primary"> + {{ tag }} + </span> + </p> + + <div + v-if="job.cancel_path" + class="btn-group prepend-top-5" + role="group"> + <a + :href="job.cancel_path" + class="js-cancel-job btn btn-sm btn-default" + data-method="post" + rel="nofollow" + > + {{ __('Cancel') }} + </a> + </div> + </div> + <artifacts-block + v-if="hasArtifact" + :artifact="job.artifact" + /> + <trigger-block + v-if="hasTriggers" + :trigger="job.trigger" + /> + <commit-block + :is-last-block="hasStages" + :commit="commit" + :merge-request="job.merge_request" + /> + + <stages-dropdown + :stages="stages" + :pipeline="job.pipeline" + @requestSidebarStageDropdown="fetchJobsForStage" + /> + + </template> + <gl-loading-icon + v-else + :size="2" + class="prepend-top-10" + /> + </div> + + <jobs-container + v-if="!isLoading && jobs.length" + :jobs="jobs" + :job-id="job.id" + /> + </div> + </aside> +</template> diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue deleted file mode 100644 index a591fcfb482..00000000000 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ /dev/null @@ -1,276 +0,0 @@ -<script> - import _ from 'underscore'; - import timeagoMixin from '~/vue_shared/mixins/timeago'; - import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; - import Icon from '~/vue_shared/components/icon.vue'; - import DetailRow from './sidebar_detail_row.vue'; - import ArtifactsBlock from './artifacts_block.vue'; - import TriggerBlock from './trigger_block.vue'; - import CommitBlock from './commit_block.vue'; - - export default { - name: 'SidebarDetailsBlock', - components: { - ArtifactsBlock, - CommitBlock, - DetailRow, - Icon, - TriggerBlock, - }, - mixins: [timeagoMixin], - props: { - job: { - type: Object, - required: true, - }, - isLoading: { - type: Boolean, - required: true, - }, - runnerHelpUrl: { - type: String, - required: false, - default: '', - }, - terminalPath: { - type: String, - required: false, - default: null, - }, - }, - computed: { - shouldRenderContent() { - return !this.isLoading && Object.keys(this.job).length > 0; - }, - coverage() { - return `${this.job.coverage}%`; - }, - duration() { - return timeIntervalInWords(this.job.duration); - }, - queued() { - return timeIntervalInWords(this.job.queued); - }, - runnerId() { - return `${this.job.runner.description} (#${this.job.runner.id})`; - }, - retryButtonClass() { - let className = - 'js-retry-button float-right btn btn-retry d-none d-md-block d-lg-block d-xl-block'; - className += - this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary'; - return className; - }, - hasTimeout() { - return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; - }, - timeout() { - if (this.job.metadata == null) { - return ''; - } - - let t = this.job.metadata.timeout_human_readable; - if (this.job.metadata.timeout_source !== '') { - t += ` (from ${this.job.metadata.timeout_source})`; - } - - return t; - }, - renderBlock() { - return ( - this.job.merge_request || - this.job.duration || - this.job.finished_data || - this.job.erased_at || - this.job.queued || - this.job.runner || - this.job.coverage || - this.job.tags.length || - this.job.cancel_path - ); - }, - hasArtifact() { - return !_.isEmpty(this.job.artifact); - }, - hasTriggers() { - return !_.isEmpty(this.job.trigger); - }, - hasStages() { - return ( - this.job && - this.job.pipeline && - this.job.pipeline.stages && - this.job.pipeline.stages.length > 0 - ) || false; - }, - commit() { - return this.job.pipeline.commit || {}; - }, - }, - }; -</script> -<template> - <div> - <div class="block"> - <strong class="inline prepend-top-8"> - {{ job.name }} - </strong> - <a - v-if="job.retry_path" - :class="retryButtonClass" - :href="job.retry_path" - data-method="post" - rel="nofollow" - > - {{ __('Retry') }} - </a> - <a - v-if="terminalPath" - :href="terminalPath" - class="js-terminal-link pull-right btn btn-primary - btn-inverted visible-md-block visible-lg-block" - target="_blank" - > - {{ __('Debug') }} - <icon name="external-link" /> - </a> - <button - :aria-label="__('Toggle Sidebar')" - type="button" - class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle" - > - <i - aria-hidden="true" - data-hidden="true" - class="fa fa-angle-double-right" - ></i> - </button> - </div> - <template v-if="shouldRenderContent"> - <div - v-if="job.retry_path || job.new_issue_path" - class="block retry-link" - > - <a - v-if="job.new_issue_path" - :href="job.new_issue_path" - class="js-new-issue btn btn-success btn-inverted" - > - {{ __('New issue') }} - </a> - <a - v-if="job.retry_path" - :href="job.retry_path" - class="js-retry-job btn btn-inverted-secondary" - data-method="post" - rel="nofollow" - > - {{ __('Retry') }} - </a> - </div> - <div :class="{block : renderBlock }"> - <p - v-if="job.merge_request" - class="build-detail-row js-job-mr" - > - <span class="build-light-text"> - {{ __('Merge Request:') }} - </span> - <a :href="job.merge_request.path"> - !{{ job.merge_request.iid }} - </a> - </p> - - <detail-row - v-if="job.duration" - :value="duration" - class="js-job-duration" - title="Duration" - /> - <detail-row - v-if="job.finished_at" - :value="timeFormated(job.finished_at)" - class="js-job-finished" - title="Finished" - /> - <detail-row - v-if="job.erased_at" - :value="timeFormated(job.erased_at)" - class="js-job-erased" - title="Erased" - /> - <detail-row - v-if="job.queued" - :value="queued" - class="js-job-queued" - title="Queued" - /> - <detail-row - v-if="hasTimeout" - :help-url="runnerHelpUrl" - :value="timeout" - class="js-job-timeout" - title="Timeout" - /> - <detail-row - v-if="job.runner" - :value="runnerId" - class="js-job-runner" - title="Runner" - /> - <detail-row - v-if="job.coverage" - :value="coverage" - class="js-job-coverage" - title="Coverage" - /> - <p - v-if="job.tags.length" - class="build-detail-row js-job-tags" - > - <span class="build-light-text"> - {{ __('Tags:') }} - </span> - <span - v-for="(tag, i) in job.tags" - :key="i" - class="label label-primary"> - {{ tag }} - </span> - </p> - - <div - v-if="job.cancel_path" - class="btn-group prepend-top-5" - role="group"> - <a - :href="job.cancel_path" - class="js-cancel-job btn btn-sm btn-default" - data-method="post" - rel="nofollow" - > - {{ __('Cancel') }} - </a> - </div> - </div> - <artifacts-block - v-if="hasArtifact" - :artifact="job.artifact" - /> - <trigger-block - v-if="hasTriggers" - :trigger="job.trigger" - /> - <commit-block - :is-last-block="hasStages" - :commit="commit" - :merge-request="job.merge_request" - /> - </template> - <gl-loading-icon - v-if="isLoading" - :size="2" - class="prepend-top-10" - /> - </div> -</template> diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index d6d64fa32f7..1c15af55a8b 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -1,8 +1,8 @@ <script> + import _ from 'underscore'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; - - import { sprintf, __ } from '~/locale'; + import { __ } from '~/locale'; export default { components: { @@ -10,30 +10,14 @@ Icon, }, props: { - pipelineId: { - type: Number, - required: true, - }, - pipelinePath: { - type: String, - required: true, - }, - pipelineRef: { - type: String, - required: true, - }, - pipelineRefPath: { - type: String, + pipeline: { + type: Object, required: true, }, stages: { type: Array, required: true, }, - pipelineStatus: { - type: Object, - required: true, - }, }, data() { return { @@ -41,57 +25,73 @@ }; }, computed: { - pipelineLink() { - return sprintf(__('Pipeline %{pipelineLinkStart} #%{pipelineId} %{pipelineLinkEnd} from %{pipelineLinkRefStart} %{pipelineRef} %{pipelineLinkRefEnd}'), { - pipelineLinkStart: `<a href=${this.pipelinePath} class="js-pipeline-path link-commit">`, - pipelineId: this.pipelineId, - pipelineLinkEnd: '</a>', - pipelineLinkRefStart: `<a href=${this.pipelineRefPath} class="link-commit ref-name">`, - pipelineRef: this.pipelineRef, - pipelineLinkRefEnd: '</a>', - }, false); + hasRef() { + return !_.isEmpty(this.pipeline.ref); + }, + }, + watch: { + // When the component is initially mounted it may start with an empty stages array. + // Once the prop is updated, we set the first stage as the selected one + stages(newVal) { + if (newVal.length) { + this.selectedStage = newVal[0].name; + } }, }, methods: { onStageClick(stage) { - // todo: consider moving into store - this.selectedStage = stage.name; - - // update dropdown with jobs - // jobs container is a new component. this.$emit('requestSidebarStageDropdown', stage); + this.selectedStage = stage.name; }, }, }; </script> <template> - <div class="block-last"> - <ci-icon :status="pipelineStatus" /> + <div class="block-last dropdown"> + <ci-icon + :status="pipeline.details.status" + class="vertical-align-middle" + /> + + {{ __('Pipeline') }} + <a + :href="pipeline.path" + class="js-pipeline-path link-commit" + > + #{{ pipeline.id }} + </a> + <template v-if="hasRef"> + {{ __('from') }} + <a + :href="pipeline.ref.path" + class="link-commit ref-name" + > + {{ pipeline.ref.name }} + </a> + </template> - <p v-html="pipelineLink"></p> + <button + type="button" + data-toggle="dropdown" + class="js-selected-stage dropdown-menu-toggle prepend-top-8" + > + {{ selectedStage }} + <i class="fa fa-chevron-down" ></i> + </button> - <div class="dropdown"> - <button - type="button" - data-toggle="dropdown" + <ul class="dropdown-menu"> + <li + v-for="stage in stages" + :key="stage.name" > - {{ selectedStage }} - <icon name="chevron-down" /> - </button> - <ul class="dropdown-menu"> - <li - v-for="(stage, index) in stages" - :key="index" + <button + type="button" + class="js-stage-item stage-item" + @click="onStageClick(stage)" > - <button - type="button" - class="stage-item" - @click="onStageClick(stage)" - > - {{ stage.name }} - </button> - </li> - </ul> - </div> + {{ stage.name }} + </button> + </li> + </ul> </div> </template> diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js index 0136ec4d194..ae40f4cdf3b 100644 --- a/app/assets/javascripts/jobs/job_details_bundle.js +++ b/app/assets/javascripts/jobs/job_details_bundle.js @@ -1,8 +1,9 @@ -import { mapState } from 'vuex'; +import _ from 'underscore'; +import { mapState, mapActions } from 'vuex'; import Vue from 'vue'; import Job from '../job'; import JobHeader from './components/header.vue'; -import DetailsBlock from './components/sidebar_details_block.vue'; +import Sidebar from './components/sidebar.vue'; import createStore from './store'; export default () => { @@ -13,6 +14,7 @@ export default () => { const store = createStore(); store.dispatch('setJobEndpoint', dataset.endpoint); + store.dispatch('fetchJob'); // Header @@ -43,17 +45,25 @@ export default () => { new Vue({ el: detailsBlockElement, components: { - DetailsBlock, + Sidebar, }, - store, computed: { - ...mapState(['job', 'isLoading']), + ...mapState(['job']), + }, + watch: { + job(newVal, oldVal) { + if (_.isEmpty(oldVal) && !_.isEmpty(newVal.pipeline)) { + this.fetchStages(); + } + }, }, + methods: { + ...mapActions(['fetchStages']), + }, + store, render(createElement) { - return createElement('details-block', { + return createElement('sidebar', { props: { - isLoading: this.isLoading, - job: this.job, runnerHelpUrl: dataset.runnerHelpUrl, terminalPath: detailsBlockDataset.terminalPath, }, diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 7f5406d6f43..298367c9342 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -62,7 +62,9 @@ export const fetchJob = ({ state, dispatch }) => { }); }; -export const receiveJobSuccess = ({ commit }, data) => commit(types.RECEIVE_JOB_SUCCESS, data); +export const receiveJobSuccess = ({ commit }, data) => { + commit(types.RECEIVE_JOB_SUCCESS, data); +}; export const receiveJobError = ({ commit }) => { commit(types.RECEIVE_JOB_ERROR); flash(__('An error occurred while fetching the job.')); @@ -137,8 +139,11 @@ export const fetchStages = ({ state, dispatch }) => { dispatch('requestStages'); axios - .get(state.stagesEndpoint) - .then(({ data }) => dispatch('receiveStagesSuccess', data)) + .get(state.job.pipeline.path) + .then(({ data }) => { + dispatch('receiveStagesSuccess', data.details.stages); + dispatch('fetchJobsForStage', data.details.stages[0]); + }) .catch(() => dispatch('receiveStagesError')); }; export const receiveStagesSuccess = ({ commit }, data) => @@ -152,16 +157,23 @@ export const receiveStagesError = ({ commit }) => { * Jobs list on sidebar - depend on stages dropdown */ export const requestJobsForStage = ({ commit }) => commit(types.REQUEST_JOBS_FOR_STAGE); -export const setSelectedStage = ({ commit }, stage) => commit(types.SET_SELECTED_STAGE, stage); // On stage click, set selected stage + fetch job -export const fetchJobsForStage = ({ state, dispatch }, stage) => { - dispatch('setSelectedStage', stage); +export const fetchJobsForStage = ({ dispatch }, stage) => { dispatch('requestJobsForStage'); axios - .get(state.stageJobsEndpoint) - .then(({ data }) => dispatch('receiveJobsForStageSuccess', data)) + .get(stage.dropdown_path, { + params: { + retried: 1, + }, + }) + .then(({ data }) => { + const retriedJobs = data.retried.map(job => Object.assign({}, job, { retried: true })); + const jobs = data.latest_statuses.concat(retriedJobs); + + dispatch('receiveJobsForStageSuccess', jobs); + }) .catch(() => dispatch('receiveJobsForStageError')); }; export const receiveJobsForStageSuccess = ({ commit }, data) => diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index bd2212edec7..61b4862b4e3 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -2,54 +2,114 @@ import _ from 'underscore'; export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; -const SCROLL_THRESHOLD = 300; +const SCROLL_THRESHOLD = 500; export default class LazyLoader { constructor(options = {}) { + this.intersectionObserver = null; this.lazyImages = []; this.observerNode = options.observerNode || '#content-body'; - const throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300); - const debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300); - - window.addEventListener('scroll', throttledScrollCheck); - window.addEventListener('resize', debouncedElementsInView); - const scrollContainer = options.scrollContainer || window; - scrollContainer.addEventListener('load', () => this.loadCheck()); + scrollContainer.addEventListener('load', () => this.register()); + } + + static supportsIntersectionObserver() { + return 'IntersectionObserver' in window; } + searchLazyImages() { - const that = this; requestIdleCallback( () => { - that.lazyImages = [].slice.call(document.querySelectorAll('.lazy')); + const lazyImages = [].slice.call(document.querySelectorAll('.lazy')); - if (that.lazyImages.length) { - that.checkElementsInView(); + if (LazyLoader.supportsIntersectionObserver()) { + if (this.intersectionObserver) { + lazyImages.forEach(img => this.intersectionObserver.observe(img)); + } + } else if (lazyImages.length) { + this.lazyImages = lazyImages; + this.checkElementsInView(); } }, { timeout: 500 }, ); } + startContentObserver() { const contentNode = document.querySelector(this.observerNode) || document.querySelector('body'); - if (contentNode) { - const observer = new MutationObserver(() => this.searchLazyImages()); + this.mutationObserver = new MutationObserver(() => this.searchLazyImages()); - observer.observe(contentNode, { + this.mutationObserver.observe(contentNode, { childList: true, subtree: true, }); } } - loadCheck() { - this.searchLazyImages(); + + stopContentObserver() { + if (this.mutationObserver) { + this.mutationObserver.takeRecords(); + this.mutationObserver.disconnect(); + this.mutationObserver = null; + } + } + + unregister() { + this.stopContentObserver(); + if (this.intersectionObserver) { + this.intersectionObserver.takeRecords(); + this.intersectionObserver.disconnect(); + this.intersectionObserver = null; + } + if (this.throttledScrollCheck) { + window.removeEventListener('scroll', this.throttledScrollCheck); + } + if (this.debouncedElementsInView) { + window.removeEventListener('resize', this.debouncedElementsInView); + } + } + + register() { + if (LazyLoader.supportsIntersectionObserver()) { + this.startIntersectionObserver(); + } else { + this.startLegacyObserver(); + } this.startContentObserver(); + this.searchLazyImages(); } + + startIntersectionObserver = () => { + this.throttledElementsInView = _.throttle(() => this.checkElementsInView(), 300); + this.intersectionObserver = new IntersectionObserver(this.onIntersection, { + rootMargin: `${SCROLL_THRESHOLD}px 0px`, + thresholds: 0.1, + }); + }; + + onIntersection = entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + this.intersectionObserver.unobserve(entry.target); + this.lazyImages.push(entry.target); + } + }); + this.throttledElementsInView(); + }; + + startLegacyObserver() { + this.throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300); + this.debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300); + window.addEventListener('scroll', this.throttledScrollCheck); + window.addEventListener('resize', this.debouncedElementsInView); + } + scrollCheck() { requestAnimationFrame(() => this.checkElementsInView()); } + checkElementsInView() { const scrollTop = window.pageYOffset; const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD; @@ -61,18 +121,29 @@ export default class LazyLoader { const imgTop = scrollTop + imgBoundRect.top; const imgBound = imgTop + imgBoundRect.height; - if (scrollTop < imgBound && visHeight > imgTop) { + if (scrollTop <= imgBound && visHeight >= imgTop) { requestAnimationFrame(() => { LazyLoader.loadImage(selectedImage); }); return false; } + /* + If we are scrolling fast, the img we watched intersecting could have left the view port. + So we are going watch for new intersections. + */ + if (LazyLoader.supportsIntersectionObserver()) { + if (this.intersectionObserver) { + this.intersectionObserver.observe(selectedImage); + } + return false; + } return true; } return false; }); } + static loadImage(img) { if (img.getAttribute('data-src')) { let imgUrl = img.getAttribute('data-src'); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 30925940807..e14fff7a610 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -88,6 +88,7 @@ export const handleLocationHash = () => { const fixedDiffStats = document.querySelector('.js-diff-files-changed'); const fixedNav = document.querySelector('.navbar-gitlab'); const performanceBar = document.querySelector('#js-peek'); + const topPadding = 8; let adjustment = 0; if (fixedNav) adjustment -= fixedNav.offsetHeight; @@ -108,6 +109,10 @@ export const handleLocationHash = () => { adjustment -= performanceBar.offsetHeight; } + if (isInMRPage()) { + adjustment -= topPadding; + } + window.scrollBy(0, adjustment); }; @@ -381,8 +386,11 @@ export const objectToQueryString = (params = {}) => .map(param => `${param}=${params[param]}`) .join('&'); -export const buildUrlWithCurrentLocation = param => - (param ? `${window.location.pathname}${param}` : window.location.pathname); +export const buildUrlWithCurrentLocation = param => { + if (param) return `${window.location.pathname}${param}`; + + return window.location.pathname; +}; /** * Based on the current location and the string parameters provided @@ -616,6 +624,17 @@ export const roundOffFloat = (number, precision = 0) => { return Math.round(number * multiplier) / multiplier; }; +/** + * Represents navigation type constants of the Performance Navigation API. + * Detailed explanation see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigation. + */ +export const NavigationType = { + TYPE_NAVIGATE: 0, + TYPE_RELOAD: 1, + TYPE_BACK_FORWARD: 2, + TYPE_RESERVED: 255, +}; + window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index ce0bc4d40e9..f7429601afa 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -31,11 +31,17 @@ function blockTagText(text, textArea, blockTag, selected) { } } -function moveCursor(textArea, tag, wrapped, removedLastNewLine) { +function moveCursor({ textArea, tag, wrapped, removedLastNewLine, select }) { var pos; if (!textArea.setSelectionRange) { return; } + if (select && select.length > 0) { + // calculate the part of the text to be selected + const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select)); + const endPosition = startPosition + select.length; + return textArea.setSelectionRange(startPosition, endPosition); + } if (textArea.selectionStart === textArea.selectionEnd) { if (wrapped) { pos = textArea.selectionStart - tag.length; @@ -51,7 +57,7 @@ function moveCursor(textArea, tag, wrapped, removedLastNewLine) { } } -export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap) { +export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) { var textToInsert, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; removedLastNewLine = false; removedFirstNewLine = false; @@ -82,11 +88,16 @@ export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; + const textPlaceholder = '{text}'; + if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { if (blockTag != null && blockTag !== '') { textToInsert = blockTagText(text, textArea, blockTag, selected); } else { textToInsert = selectedSplit.map(function(val) { + if (tag.indexOf(textPlaceholder) > -1) { + return tag.replace(textPlaceholder, val); + } if (val.indexOf(tag) === 0) { return "" + (val.replace(tag, '')); } else { @@ -94,6 +105,8 @@ export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap } }).join('\n'); } + } else if (tag.indexOf(textPlaceholder) > -1) { + textToInsert = tag.replace(textPlaceholder, selected); } else { textToInsert = "" + startChar + tag + selected + (wrap ? tag : ' '); } @@ -107,17 +120,17 @@ export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap } insertText(textArea, textToInsert); - return moveCursor(textArea, tag, wrap, removedLastNewLine); + return moveCursor({ textArea, tag: tag.replace(textPlaceholder, selected), wrap, removedLastNewLine, select }); } -function updateText(textArea, tag, blockTag, wrap) { +function updateText({ textArea, tag, blockTag, wrap, select }) { var $textArea, selected, text; $textArea = $(textArea); textArea = $textArea.get(0); text = $textArea.val(); selected = selectedText(text, textArea); $textArea.focus(); - return insertMarkdownText(textArea, text, tag, blockTag, selected, wrap); + return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }); } function replaceRange(s, start, end, substitute) { @@ -127,7 +140,12 @@ function replaceRange(s, start, end, substitute) { export function addMarkdownListeners(form) { return $('.js-md', form).off('click').on('click', function() { const $this = $(this); - return updateText($this.closest('.md-area').find('textarea'), $this.data('mdTag'), $this.data('mdBlock'), !$this.data('mdPrepend')); + return updateText({ + textArea: $this.closest('.md-area').find('textarea'), + tag: $this.data('mdTag'), + blockTag: $this.data('mdBlock'), + wrap: !$this.data('mdPrepend'), + select: $this.data('mdSelect') }); }); } diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 763429d7242..78f56ab57ff 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -194,9 +194,7 @@ export default class MergeRequestTabs { if (bp.getBreakpointSize() !== 'lg') { this.shrinkView(); } - if (this.diffViewType() === 'parallel') { - this.expandViewContainer(); - } + this.expandViewContainer(); this.destroyPipelinesView(); this.commitsTab.classList.remove('active'); } else if (action === 'pipelines') { @@ -355,7 +353,7 @@ export default class MergeRequestTabs { localTimeAgo($('.js-timeago', 'div#diffs')); syntaxHighlight($('#diffs .js-syntax-highlight')); - if (this.diffViewType() === 'parallel' && this.isDiffAction(this.currentAction)) { + if (this.isDiffAction(this.currentAction)) { this.expandViewContainer(); } this.diffsLoaded = true; @@ -408,19 +406,23 @@ export default class MergeRequestTabs { } diffViewType() { - return $('.inline-parallel-buttons a.active').data('viewType'); + return $('.inline-parallel-buttons button.active').data('viewType'); } isDiffAction(action) { return action === 'diffs' || action === 'new/diffs'; } - expandViewContainer() { + expandViewContainer(removeLimited = true) { const $wrapper = $('.content-wrapper .container-fluid').not('.breadcrumbs'); if (this.fixedLayoutPref === null) { this.fixedLayoutPref = $wrapper.hasClass('container-limited'); } - $wrapper.removeClass('container-limited'); + if (this.diffViewType() === 'parallel' || removeLimited) { + $wrapper.removeClass('container-limited'); + } else { + $wrapper.addClass('container-limited'); + } } resetViewContainer() { diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index d8e8efb982a..618a1581d8f 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -11,6 +11,7 @@ import commentForm from './comment_form.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; +import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; export default { name: 'NotesApp', @@ -96,6 +97,9 @@ export default { }); } }, + updated() { + this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'))); + }, methods: { ...mapActions({ fetchDiscussions: 'fetchDiscussions', diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index b798a254459..339ce67438a 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -4,6 +4,8 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { + IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); + initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, isGroupDecendent: true, diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 74b3a515e84..ef65196872c 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -3,9 +3,10 @@ import Issue from '~/issue'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; -import '~/issue_show/index'; +import initIssueableApp from '~/issue_show'; export default function () { + initIssueableApp(); new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index 3647048a872..ec39db12e74 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -7,10 +7,13 @@ import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; document.addEventListener('DOMContentLoaded', () => { + IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); + initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); + new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 8a079b4b38a..ebe18b47e4e 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -147,7 +147,7 @@ const bindEvents = () => { $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl)); - $projectName.keyup(() => { + $projectName.on('keyup change', () => { onProjectNameChange($projectName, $projectPath); hasUserDefinedProjectPath = $projectPath.val().trim().length > 0; }); diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index dc609d6f90e..d196f497362 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -139,7 +139,7 @@ export default { <section class="media-section"> <div class="media"> <status-icon :status="statusIconName" /> - <div class="media-body space-children d-flex flex-align-self-center"> + <div class="media-body d-flex flex-align-self-center"> <span class="js-code-text code-text"> {{ headerText }} diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 2e1d6e9643a..8660b0546cf 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -51,10 +51,10 @@ export default { <template> <div class="block"> <issuable-time-tracker - :time_estimate="store.timeEstimate" - :time_spent="store.totalTimeSpent" - :human_time_estimate="store.humanTimeEstimate" - :human_time_spent="store.humanTotalTimeSpent" + :time-estimate="store.timeEstimate" + :time-spent="store.totalTimeSpent" + :human-time-estimate="store.humanTimeEstimate" + :human-time-spent="store.humanTotalTimeSpent" :root-path="store.rootPath" /> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 2ee3e1f322e..ef76dc13ce9 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -19,24 +19,20 @@ export default { TimeTrackingHelpState, }, props: { - // eslint-disable-next-line vue/prop-name-casing - time_estimate: { + timeEstimate: { type: Number, required: true, }, - // eslint-disable-next-line vue/prop-name-casing - time_spent: { + timeSpent: { type: Number, required: true, }, - // eslint-disable-next-line vue/prop-name-casing - human_time_estimate: { + humanTimeEstimate: { type: String, required: false, default: '', }, - // eslint-disable-next-line vue/prop-name-casing - human_time_spent: { + humanTimeSpent: { type: String, required: false, default: '', @@ -52,18 +48,6 @@ export default { }; }, computed: { - timeSpent() { - return this.time_spent; - }, - timeEstimate() { - return this.time_estimate; - }, - timeEstimateHumanReadable() { - return this.human_time_estimate; - }, - timeSpentHumanReadable() { - return this.human_time_spent; - }, hasTimeSpent() { return !!this.timeSpent; }, @@ -94,10 +78,12 @@ export default { this.showHelp = show; }, update(data) { - this.time_estimate = data.time_estimate; - this.time_spent = data.time_spent; - this.human_time_estimate = data.human_time_estimate; - this.human_time_spent = data.human_time_spent; + const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = data; + + this.timeEstimate = timeEstimate; + this.timeSpent = timeSpent; + this.humanTimeEstimate = humanTimeEstimate; + this.humanTimeSpent = humanTimeSpent; }, }, }; @@ -114,8 +100,8 @@ export default { :show-help-state="showHelpState" :show-spent-only-state="showSpentOnlyState" :show-estimate-only-state="showEstimateOnlyState" - :time-spent-human-readable="timeSpentHumanReadable" - :time-estimate-human-readable="timeEstimateHumanReadable" + :time-spent-human-readable="humanTimeSpent" + :time-estimate-human-readable="humanTimeEstimate" /> <div class="title hide-collapsed"> {{ __('Time tracking') }} @@ -145,11 +131,11 @@ export default { <div class="time-tracking-content hide-collapsed"> <time-tracking-estimate-only-pane v-if="showEstimateOnlyState" - :time-estimate-human-readable="timeEstimateHumanReadable" + :time-estimate-human-readable="humanTimeEstimate" /> <time-tracking-spent-only-pane v-if="showSpentOnlyState" - :time-spent-human-readable="timeSpentHumanReadable" + :time-spent-human-readable="humanTimeSpent" /> <time-tracking-no-tracking-pane v-if="showNoTimeTrackingState" @@ -158,8 +144,8 @@ export default { v-if="showComparisonState" :time-estimate="timeEstimate" :time-spent="timeSpent" - :time-spent-human-readable="timeSpentHumanReadable" - :time-estimate-human-readable="timeEstimateHumanReadable" + :time-spent-human-readable="humanTimeSpent" + :time-estimate-human-readable="humanTimeEstimate" /> <transition name="help-state-toggle"> <time-tracking-help-state diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index b15ad0e5586..87da65a1b1f 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -7,6 +7,8 @@ export default class SidebarMilestone { if (!el) return; + const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = el.dataset; + // eslint-disable-next-line no-new new Vue({ el, @@ -15,10 +17,10 @@ export default class SidebarMilestone { }, render: createElement => createElement('timeTracker', { props: { - time_estimate: parseInt(el.dataset.timeEstimate, 10), - time_spent: parseInt(el.dataset.timeSpent, 10), - human_time_estimate: el.dataset.humanTimeEstimate, - human_time_spent: el.dataset.humanTimeSpent, + timeEstimate: parseInt(timeEstimate, 10), + timeSpent: parseInt(timeSpent, 10), + humanTimeEstimate, + humanTimeSpent, rootPath: '/', }, }), diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 720ae11aaa6..8684005e0fb 100644 --- a/app/assets/javascripts/ide/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -3,7 +3,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import { pluralize } from '~/lib/utils/text_utility'; import { __, sprintf } from '~/locale'; -import { getCommitIconMap } from '../utils'; +import { getCommitIconMap } from '~/ide/utils'; export default { components: { @@ -32,6 +32,11 @@ export default { required: false, default: false, }, + size: { + type: Number, + required: false, + default: 12, + }, }, computed: { changedIcon() { @@ -42,7 +47,7 @@ export default { return `${getCommitIconMap(this.file).icon}${suffix}`; }, changedIconClass() { - return `ide-${this.changedIcon} float-left`; + return `${this.changedIcon} float-left d-block`; }, tooltipTitle() { if (!this.showTooltip) return undefined; @@ -78,13 +83,30 @@ export default { :title="tooltipTitle" data-container="body" data-placement="right" - class="ide-file-changed-icon" + class="file-changed-icon ml-auto" > <icon v-if="showIcon" :name="changedIcon" - :size="12" + :size="size" :css-classes="changedIconClass" /> </span> </template> + +<style> +.file-addition, +.file-addition-solid { + color: #1aaa55; +} + +.file-modified, +.file-modified-solid { + color: #fc9403; +} + +.file-deletion, +.file-deletion-solid { + color: #db3b21; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index f1ef50d0e3d..a07d63a495d 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -1,9 +1,11 @@ <script> +import { Link } from '@gitlab-org/gitlab-ui'; import Icon from '../../icon.vue'; import { numberToHumanSize } from '../../../../lib/utils/number_utils'; export default { components: { + 'gl-link': Link, Icon, }, props: { @@ -37,7 +39,7 @@ export default { ({{ fileSizeReadable }}) </template> </p> - <a + <gl-link :href="path" class="btn btn-default" rel="nofollow" @@ -49,7 +51,7 @@ export default { css-classes="float-left append-right-8" /> {{ __('Download') }} - </a> + </gl-link> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index c797ad62a5d..36a345130c0 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -1,12 +1,14 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; +import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; export default { name: 'FileRow', components: { FileIcon, Icon, + ChangedFileIcon, }, props: { file: { @@ -22,6 +24,16 @@ export default { required: false, default: null, }, + hideExtraOnTree: { + type: Boolean, + required: false, + default: false, + }, + showChangedIcon: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -65,6 +77,9 @@ export default { toggleTreeOpen(path) { this.$emit('toggleTreeOpen', path); }, + clickedFile(path) { + this.$emit('clickFile', path); + }, clickFile() { // Manual Action if a tree is selected/opened if (this.isTree && this.hasUrlAtCurrentRoute()) { @@ -72,6 +87,8 @@ export default { } if (this.$router) this.$router.push(`/project${this.file.url}`); + + if (this.isBlob) this.clickedFile(this.file.path); }, scrollIntoView(isInit = false) { const block = isInit && this.isTree ? 'center' : 'nearest'; @@ -126,17 +143,24 @@ export default { class="file-row-name str-truncated" > <file-icon + v-if="!showChangedIcon || file.type === 'tree'" :file-name="file.name" :loading="file.loading" :folder="isTree" :opened="file.opened" :size="16" /> + <changed-file-icon + v-else + :file="file" + :size="16" + class="append-right-5" + /> {{ file.name }} </span> <component :is="extraComponent" - v-if="extraComponent" + v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')" :file="file" :mouse-over="mouseOver" /> @@ -148,8 +172,11 @@ export default { :key="childFile.key" :file="childFile" :level="level + 1" + :hide-extra-on-tree="hideExtraOnTree" :extra-component="extraComponent" + :show-changed-icon="showChangedIcon" @toggleTreeOpen="toggleTreeOpen" + @clickFile="clickedFile" /> </template> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 8c22f3f6536..afc4196c729 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -106,6 +106,12 @@ icon="code" /> <toolbar-button + tag="[{text}](url)" + tag-select="url" + button-title="Add a link" + icon="link" + /> + <toolbar-button :prepend="true" tag="* " button-title="Add a bullet list" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 9f1e009efdd..bda33636369 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -27,6 +27,11 @@ required: false, default: '', }, + tagSelect: { + type: String, + required: false, + default: '', + }, prepend: { type: Boolean, required: false, @@ -40,6 +45,7 @@ <button v-tooltip :data-md-tag="tag" + :data-md-select="tagSelect" :data-md-block="tagBlock" :data-md-prepend="prepend" :title="buttonTitle" diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 08e102e57c3..ee3157bcb1b 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -18,12 +18,14 @@ */ +import { Link } from '@gitlab-org/gitlab-ui'; import userAvatarImage from './user_avatar_image.vue'; import tooltip from '../../directives/tooltip'; export default { name: 'UserAvatarLink', components: { + 'gl-link': Link, userAvatarImage, }, directives: { @@ -83,7 +85,7 @@ export default { </script> <template> - <a + <gl-link :href="linkHref" class="user-avatar-link"> <user-avatar-image @@ -99,5 +101,5 @@ export default { :title="tooltipText" :tooltip-placement="tooltipPlacement" >{{ username }}</span> - </a> + </gl-link> </template> diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index e2bbcc67a67..2193e8e8de3 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -113,7 +113,7 @@ } .avatar-container { - margin-right: 0; + margin: 0 auto; } } diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index d2ba76f5160..50d4298d418 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -11,6 +11,10 @@ padding: 0 2px; background-color: $blue-100; border-radius: $border-radius-default; + + &.current-user { + background-color: $orange-100; + } } .gfm-color_chip { diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 7d53a631cdf..7e30747963a 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -19,6 +19,17 @@ } } + // leave enough space for the close icon + .modal-title { + &.mw-100, + &.w-100 { + // after upgrading to Bootstrap 4.2 we can use $modal-header-padding-x here + // https://github.com/twbs/bootstrap/pull/26976 + margin-right: -2rem; + padding-right: 2rem; + } + } + .page-title { margin-top: 0; } @@ -59,7 +70,7 @@ } @include media-breakpoint-up(sm) { - .btn:first-of-type { + .btn:nth-child(1) { margin-left: auto; } } diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 65f0a0d18e2..07d82e984ba 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -517,21 +517,6 @@ $ide-commit-header-height: 48px; } } -.ide-file-addition, -.ide-file-addition-solid { - color: $green-500; -} - -.ide-file-modified, -.ide-file-modified-solid { - color: $orange-500; -} - -.ide-file-deletion, -.ide-file-deletion-solid { - color: $red-500; -} - .multi-file-commit-list-collapsed { display: flex; flex-direction: column; @@ -1399,14 +1384,6 @@ $ide-commit-header-height: 48px; color: $theme-gray-700; } -.ide-file-changed-icon { - margin-left: auto; - - > svg { - display: block; - } -} - .file-row:hover, .file-row:focus { .ide-new-btn { diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 14ba8b1df83..ed877f625b5 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -328,23 +328,6 @@ } } - .build-dropdown { - margin: $gl-padding 0; - padding: 0; - - .dropdown-menu-toggle { - margin-top: #{$gl-padding / 2}; - } - - svg { - position: relative; - top: 3px; - margin-right: 3px; - width: 14px; - height: 14px; - } - } - .builds-container { background-color: $white-light; border-top: 1px solid $border-color; @@ -381,15 +364,11 @@ position: absolute; left: 15px; top: 20px; - display: none; + display: block; } &.active { font-weight: $gl-font-weight-bold; - - .icon-arrow-right { - display: block; - } } &.retried { diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 10764e0f3df..628a4ca38da 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -223,6 +223,7 @@ } } +.clipboard-group, .commit-sha-group { display: inline-flex; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 987dcd32e3a..5035714b95f 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -571,8 +571,6 @@ } .files { - margin-top: 1px; - .diff-file:last-child { margin-bottom: 0; } @@ -987,3 +985,63 @@ .discussion-body .image .frame { position: relative; } + +.diff-tree-list { + width: 320px; +} + +.diff-files-holder { + flex: 1; + min-width: 0; +} + +.compare-versions-container { + min-width: 0; +} + +.tree-list-holder { + position: sticky; + top: 100px; + max-height: calc(100vh - 100px); + padding-right: $gl-padding; + + .file-row { + margin-left: 0; + margin-right: 0; + } + + .with-performance-bar & { + top: 135px; + } +} + +.tree-list-scroll { + max-height: 100%; + padding-top: $grid-size; + padding-bottom: $grid-size; + border-top: 1px solid $border-color; + border-bottom: 1px solid $border-color; + overflow-y: scroll; + overflow-x: auto; +} + +.tree-list-search .form-control { + padding-left: 30px; +} + +.tree-list-icon { + top: 50%; + left: 10px; + transform: translateY(-50%); + + &, + svg { + fill: $gl-text-color-tertiary; + } +} + +.tree-list-clear-icon { + right: 10px; + left: auto; + line-height: 0; +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 97b131687d3..45382d4ea43 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -723,6 +723,17 @@ align-items: center; padding: 16px; z-index: 199; + white-space: nowrap; + + .dropdown-menu-toggle { + width: auto; + max-width: 170px; + + svg { + top: 10px; + right: 8px; + } + } } .content-block { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b87034d10b6..d7dbc712743 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -109,6 +109,15 @@ class ApplicationController < ActionController::Base request.env['rack.session.options'][:expire_after] = Settings.gitlab['unauthenticated_session_expire_delay'] end + def render(*args) + super.tap do + # Set a header for custom error pages to prevent them from being intercepted by gitlab-workhorse + if response.content_type == 'text/html' && (400..599).cover?(response.status) + response.headers['X-GitLab-Custom-Error'] = '1' + end + end + end + protected def append_info_to_payload(payload) diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index bd110d646e5..d0f59aa8162 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -14,6 +14,8 @@ class Projects::ArtifactsController < Projects::ApplicationController before_action :entry, only: [:file] def download + return render_404 unless artifacts_file + send_upload(artifacts_file, attachment: artifacts_file.filename) end @@ -100,7 +102,7 @@ class Projects::ArtifactsController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def artifacts_file - @artifacts_file ||= build.artifacts_file + @artifacts_file ||= build.artifacts_file_for_type(params[:file_type] || :archive) end def entry diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 86583adc6a4..5639402a1e9 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -106,6 +106,10 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap @commits = set_commits_for_rendering(@merge_request.commits) @commit = @merge_request.diff_head_commit + # FIXME: We have to assign a presenter to another instance variable + # due to class_name checks being made with issuable classes + @mr_presenter = @merge_request.present(current_user: current_user) + @labels = LabelsFinder.new(current_user, project_id: @project.id).execute set_pipeline_variables diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index dfb69de650b..d691744d72a 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -333,6 +333,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @target_project = @merge_request.target_project @target_branches = @merge_request.target_project.repository.branch_names @noteable = @merge_request + + # FIXME: We have to assign a presenter to another instance variable + # due to class_name checks being made with issuable classes + @mr_presenter = @merge_request.present(current_user: current_user) end def finder_type diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 7352c5e9bec..a9417369ca2 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -16,6 +16,7 @@ class ProjectsController < Projects::ApplicationController before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :lfs_blob_ids, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] + before_action :present_project, only: [:edit] # Authorize before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] @@ -433,4 +434,8 @@ class ProjectsController < Projects::ApplicationController def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42440') end + + def present_project + @project = @project.present(current_user: current_user) + end end diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb index fd7aeca0d8b..2e82bda8730 100644 --- a/app/finders/events_finder.rb +++ b/app/finders/events_finder.rb @@ -12,6 +12,7 @@ class EventsFinder # Arguments: # source - which user or project to looks for events on # current_user - only return events for projects visible to this user + # WARNING: does not consider project feature visibility! # params: # action: string # target_type: string diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 251a559878a..9e24154e4b6 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -128,7 +128,7 @@ class IssuableFinder labels_count = 1 if use_cte_for_search? finder.execute.reorder(nil).group(:state).count.each do |key, value| - counts[Array(key).last.to_sym] += value / labels_count + counts[count_key(key)] += value / labels_count end counts[:all] = counts.values.sum @@ -236,16 +236,16 @@ class IssuableFinder # rubocop: enable CodeReuse/ActiveRecord def assignee_id? - params[:assignee_id].present? && params[:assignee_id] != NONE + params[:assignee_id].present? && params[:assignee_id].to_s != NONE end def assignee_username? - params[:assignee_username].present? && params[:assignee_username] != NONE + params[:assignee_username].present? && params[:assignee_username].to_s != NONE end def no_assignee? # Assignee_id takes precedence over assignee_username - params[:assignee_id] == NONE || params[:assignee_username] == NONE + params[:assignee_id].to_s == NONE || params[:assignee_username].to_s == NONE end # rubocop: disable CodeReuse/ActiveRecord @@ -297,6 +297,10 @@ class IssuableFinder klass.all end + def count_key(value) + Array(value).last.to_sym + end + # rubocop: disable CodeReuse/ActiveRecord def by_scope(items) return items.none if current_user_related? && !current_user diff --git a/app/finders/joined_groups_finder.rb b/app/finders/joined_groups_finder.rb index 18cc6891ca4..4d8128dd824 100644 --- a/app/finders/joined_groups_finder.rb +++ b/app/finders/joined_groups_finder.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class JoinedGroupsFinder < UnionFinder +class JoinedGroupsFinder def initialize(user) @user = user end @@ -8,19 +8,8 @@ class JoinedGroupsFinder < UnionFinder # Finds the groups of the source user, optionally limited to those visible to # the current user. def execute(current_user = nil) - segments = all_groups(current_user) - - find_union(segments, Group).order_id_desc - end - - private - - def all_groups(current_user) - groups = [] - - groups << @user.authorized_groups.visible_to_user(current_user) if current_user - groups << @user.authorized_groups.public_to_user(current_user) - - groups + @user.authorized_groups + .public_or_visible_to_user(current_user) + .order_id_desc end end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index b698a3c7b09..50c051c3aa1 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -27,13 +27,17 @@ # updated_before: datetime # class MergeRequestsFinder < IssuableFinder + def self.scalar_params + @scalar_params ||= super + [:wip] + end + def klass MergeRequest end def filter_items(_items) items = by_source_branch(super) - + items = by_wip(items) by_target_branch(items) end @@ -61,5 +65,24 @@ class MergeRequestsFinder < IssuableFinder items.where(target_branch: target_branch) end - # rubocop: enable CodeReuse/ActiveRecord + + def item_project_ids(items) + items&.reorder(nil)&.select(:target_project_id) + end + + def by_wip(items) + if params[:wip] == 'yes' + items.where(wip_match(items.arel_table)) + elsif params[:wip] == 'no' + items.where.not(wip_match(items.arel_table)) + else + items + end + end + + def wip_match(table) + table[:title].matches('WIP:%') + .or(table[:title].matches('WIP %')) + .or(table[:title].matches('[WIP]%')) + end end diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb index a4daf5b5841..eeca5026da1 100644 --- a/app/finders/user_recent_events_finder.rb +++ b/app/finders/user_recent_events_finder.rb @@ -3,6 +3,7 @@ # Get user activity feed for projects common for a user and a logged in user # # - current_user: The user viewing the events +# WARNING: does not consider project feature visibility! # - user: The user for which to load the events # - params: # - offset: The page of events to return diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index c9a5431d18e..15cbfeea609 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -254,7 +254,8 @@ module ApplicationSettingsHelper :user_default_internal_regex, :user_oauth_applications, :version_check_enabled, - :web_ide_clientside_preview_enabled + :web_ide_clientside_preview_enabled, + :diff_max_patch_bytes ] end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 56f6686da57..97406fefd43 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -327,11 +327,15 @@ module IssuablesHelper end def issuable_button_visibility(issuable, closed) + return 'hidden' if issuable_button_hidden?(issuable, closed) + end + + def issuable_button_hidden?(issuable, closed) case issuable when Issue - issue_button_visibility(issuable, closed) + issue_button_hidden?(issuable, closed) when MergeRequest - merge_request_button_visibility(issuable, closed) + merge_request_button_hidden?(issuable, closed) end end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index f7d448ea3a7..957ab06b0ca 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -64,7 +64,11 @@ module IssuesHelper end def issue_button_visibility(issue, closed) - return 'hidden' if issue.closed? == closed + return 'hidden' if issue_button_hidden?(issue, closed) + end + + def issue_button_hidden?(issue, closed) + issue.closed? == closed || (!closed && issue.discussion_locked) end def confidential_icon(issue) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 87af6fb08f0..23d7aa427bb 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -80,7 +80,11 @@ module MergeRequestsHelper end def merge_request_button_visibility(merge_request, closed) - return 'hidden' if merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_without_fork? + return 'hidden' if merge_request_button_hidden?(merge_request, closed) + end + + def merge_request_button_hidden?(merge_request, closed) + merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_without_fork? end def merge_request_version_path(project, merge_request, merge_request_diff, start_sha = nil) diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb index 75637eb0676..ab77b149072 100644 --- a/app/helpers/version_check_helper.rb +++ b/app/helpers/version_check_helper.rb @@ -9,4 +9,17 @@ module VersionCheckHelper image_url = VersionCheck.new.url image_tag image_url, class: 'js-version-status-badge' end + + def link_to_version + if Gitlab.pre_release? + commit_link = link_to(Gitlab.revision, Gitlab::COM_URL + namespace_project_commits_path('gitlab-org', source_code_project, Gitlab.revision)) + [Gitlab::VERSION, content_tag(:small, commit_link)].join(' ').html_safe + else + link_to Gitlab::VERSION, Gitlab::COM_URL + namespace_project_tag_path('gitlab-org', source_code_project, "v#{Gitlab::VERSION}") + end + end + + def source_code_project + 'gitlab-ce' + end end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 67af0a4eb98..be085496731 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -3,13 +3,14 @@ module Emails module MergeRequests def new_merge_request_email(recipient_id, merge_request_id, reason = nil) - setup_merge_request_mail(merge_request_id, recipient_id) + setup_merge_request_mail(merge_request_id, recipient_id, present: true) mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id, reason)) end def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) - setup_merge_request_mail(merge_request_id, recipient_id) + setup_merge_request_mail(merge_request_id, recipient_id, present: true) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) end @@ -75,11 +76,16 @@ module Emails private - def setup_merge_request_mail(merge_request_id, recipient_id) + def setup_merge_request_mail(merge_request_id, recipient_id, present: false) @merge_request = MergeRequest.find(merge_request_id) @project = @merge_request.project @target_url = project_merge_request_url(@project, @merge_request) + if present + recipient = User.find(recipient_id) + @mr_presenter = @merge_request.present(current_user: recipient) + end + @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 5f835a8da75..65a2f760f93 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -182,6 +182,12 @@ class ApplicationSetting < ActiveRecord::Base numericality: { less_than_or_equal_to: :gitaly_timeout_default }, if: :gitaly_timeout_default + validates :diff_max_patch_bytes, + presence: true, + numericality: { only_integer: true, + greater_than_or_equal_to: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, + less_than_or_equal_to: Gitlab::Git::Diff::MAX_PATCH_BYTES_UPPER_BOUND } + validates :user_default_internal_regex, js_regex: true, allow_nil: true SUPPORTED_KEY_TYPES.each do |type| @@ -293,7 +299,8 @@ class ApplicationSetting < ActiveRecord::Base user_default_external: false, user_default_internal_regex: nil, user_show_add_ssh_key_message: true, - usage_stats_set_by_user_id: nil + usage_stats_set_by_user_id: nil, + diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES } end diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb index d12dd93ce2e..7cae60a74d6 100644 --- a/app/models/blob_viewer/package_json.rb +++ b/app/models/blob_viewer/package_json.rb @@ -33,7 +33,8 @@ module BlobViewer end def homepage - json_data['homepage'] + url = json_data['homepage'] + url if Gitlab::UrlSanitizer.valid?(url) end def npm_url diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3dadb95443a..c9091c19705 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -522,6 +522,13 @@ module Ci self.job_artifacts.update_all(expire_at: nil) end + def artifacts_file_for_type(type) + file = job_artifacts.find_by(file_type: Ci::JobArtifact.file_types[type])&.file + # TODO: to be removed once legacy artifacts is removed + file ||= legacy_artifacts_file if type == :archive + file + end + def coverage_regex super || project.try(:build_coverage_regex) end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 259d85e2fe5..cb73fc74bb6 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -15,6 +15,7 @@ module Ci metadata: nil, trace: nil, junit: 'junit.xml', + codequality: 'codequality.json', sast: 'gl-sast-report.json', dependency_scanning: 'gl-dependency-scanning-report.json', container_scanning: 'gl-container-scanning-report.json', @@ -26,6 +27,7 @@ module Ci metadata: :gzip, trace: :raw, junit: :gzip, + codequality: :gzip, sast: :gzip, dependency_scanning: :gzip, container_scanning: :gzip, @@ -73,7 +75,8 @@ module Ci sast: 5, ## EE-specific dependency_scanning: 6, ## EE-specific container_scanning: 7, ## EE-specific - dast: 8 ## EE-specific + dast: 8, ## EE-specific + codequality: 9 ## EE-specific } enum file_format: { diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index 017ec0b145a..08514d6af4e 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -10,5 +10,9 @@ module Ci alias_attribute :secret_value, :value validates :key, uniqueness: { scope: :pipeline_id } + + def hook_attrs + { key: key, value: value } + end end end diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index d4d3859dfd5..a9df59fc059 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -15,6 +15,9 @@ module Clusters state :scheduled, value: 1 state :installing, value: 2 state :installed, value: 3 + state :updating, value: 4 + state :updated, value: 5 + state :update_errored, value: 6 event :make_scheduled do transition [:installable, :errored] => :scheduled @@ -32,6 +35,18 @@ module Clusters transition any => :errored end + event :make_updating do + transition [:installed, :updated, :update_errored] => :updating + end + + event :make_updated do + transition [:updating] => :updated + end + + event :make_update_errored do + transition any => :update_errored + end + before_transition any => [:scheduled] do |app_status, _| app_status.status_reason = nil end @@ -40,6 +55,15 @@ module Clusters status_reason = transition.args.first app_status.status_reason = status_reason if status_reason end + + before_transition any => [:updating] do |app_status, _| + app_status.status_reason = nil + end + + before_transition any => [:update_errored] do |app_status, transition| + status_reason = transition.args.first + app_status.status_reason = status_reason if status_reason + end end end end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index 9785011720a..7723c07279d 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -5,8 +5,10 @@ module Storage extend ActiveSupport::Concern def move_dir - if any_project_has_container_registry_tags? - raise Gitlab::UpdatePathError.new('Namespace cannot be moved, because at least one project has tags in container registry') + proj_with_tags = first_project_with_container_registry_tags + + if proj_with_tags + raise Gitlab::UpdatePathError.new("Namespace #{name} (#{id}) cannot be moved because at least one project (e.g. #{proj_with_tags.name} (#{proj_with_tags.id})) has tags in container registry") end parent_was = if parent_changed? && parent_id_was.present? diff --git a/app/models/event.rb b/app/models/event.rb index 596155a9525..2e690f8c013 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -148,6 +148,8 @@ class Event < ActiveRecord::Base end end + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity def visible_to_user?(user = nil) if push? || commit_note? Ability.allowed?(user, :download_code, project) @@ -159,12 +161,18 @@ class Event < ActiveRecord::Base Ability.allowed?(user, :read_issue, note? ? note_target : target) elsif merge_request? || merge_request_note? Ability.allowed?(user, :read_merge_request, note? ? note_target : target) + elsif personal_snippet_note? + Ability.allowed?(user, :read_personal_snippet, note_target) + elsif project_snippet_note? + Ability.allowed?(user, :read_project_snippet, note_target) elsif milestone? - Ability.allowed?(user, :read_project, project) + Ability.allowed?(user, :read_milestone, project) else false # No other event types are visible end end + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/CyclomaticComplexity def project_name if project @@ -306,6 +314,10 @@ class Event < ActiveRecord::Base note? && target && target.for_snippet? end + def personal_snippet_note? + note? && target && target.for_personal_snippet? + end + def note_target target.noteable end diff --git a/app/models/group.rb b/app/models/group.rb index 62af20d2142..612c546ca57 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -82,8 +82,17 @@ class Group < Namespace User.reference_pattern end - def visible_to_user(user) - where(id: user.authorized_groups.select(:id).reorder(nil)) + # WARNING: This method should never be used on its own + # please do make sure the number of rows you are filtering is small + # enough for this query + def public_or_visible_to_user(user) + return public_to_user unless user + + public_for_user = public_to_user_arel(user) + visible_for_user = visible_to_user_arel(user) + public_or_visible = public_for_user.or(visible_for_user) + + where(public_or_visible) end def select_for_project_authorization @@ -95,6 +104,23 @@ class Group < Namespace super end end + + private + + def public_to_user_arel(user) + self.arel_table[:visibility_level] + .in(Gitlab::VisibilityLevel.levels_for_user(user)) + end + + def visible_to_user_arel(user) + groups_table = self.arel_table + authorized_groups = user.authorized_groups.as('authorized') + + groups_table.project(1) + .from(authorized_groups) + .where(authorized_groups[:id].eq(groups_table[:id])) + .exists + end end # Overrides notification_settings has_many association diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 771a61b090f..68ba4b213b2 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -3,6 +3,16 @@ class WebHook < ActiveRecord::Base include Sortable + attr_encrypted :token, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_truncated + + attr_encrypted :url, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm', + key: Settings.attr_encrypted_db_key_base_truncated + has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent validates :url, presence: true, public_url: { allow_localhost: lambda(&:allow_local_requests?), @@ -27,4 +37,38 @@ class WebHook < ActiveRecord::Base def allow_local_requests? false end + + # In 11.4, the web_hooks table has both `token` and `encrypted_token` fields. + # Ensure that the encrypted version always takes precedence if present. + alias_method :attr_encrypted_token, :token + def token + attr_encrypted_token.presence || read_attribute(:token) + end + + # In 11.4, the web_hooks table has both `token` and `encrypted_token` fields. + # Pending a background migration to encrypt all fields, we should just clear + # the unencrypted value whenever the new value is set. + alias_method :'attr_encrypted_token=', :'token=' + def token=(value) + self.attr_encrypted_token = value + + write_attribute(:token, nil) + end + + # In 11.4, the web_hooks table has both `url` and `encrypted_url` fields. + # Ensure that the encrypted version always takes precedence if present. + alias_method :attr_encrypted_url, :url + def url + attr_encrypted_url.presence || read_attribute(:url) + end + + # In 11.4, the web_hooks table has both `url` and `encrypted_url` fields. + # Pending a background migration to encrypt all fields, we should just clear + # the unencrypted value whenever the new value is set. + alias_method :'attr_encrypted_url=', :'url=' + def url=(value) + self.attr_encrypted_url = value + + write_attribute(:url, nil) + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index dd5d494997d..6559f94a696 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -6,6 +6,7 @@ class MergeRequest < ActiveRecord::Base include Issuable include Noteable include Referable + include Presentable include IgnorableColumn include TimeTrackable include ManualInverseAssociation @@ -260,7 +261,7 @@ class MergeRequest < ActiveRecord::Base end end - WIP_REGEX = /\A\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze + WIP_REGEX = /\A*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze def self.work_in_progress?(title) !!(title =~ WIP_REGEX) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index c54be778d1b..599bedde27d 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -135,6 +135,10 @@ class Namespace < ActiveRecord::Base all_projects.any?(&:has_container_registry_tags?) end + def first_project_with_container_registry_tags + all_projects.find(&:has_container_registry_tags?) + end + def send_update_instructions projects.each do |project| project.send_move_instructions("#{full_path_was}/#{project.path}") diff --git a/app/models/project.rb b/app/models/project.rb index 503fbc30768..59f088156c7 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1365,6 +1365,18 @@ class Project < ActiveRecord::Base end end + # Filters `users` to return only authorized users of the project + def members_among(users) + if users.is_a?(ActiveRecord::Relation) && !users.loaded? + authorized_users.merge(users) + else + return [] if users.empty? + + user_ids = authorized_users.where(users: { id: users.map(&:id) }).pluck(:id) + users.select { |user| user_ids.include?(user.id) } + end + end + def default_branch @default_branch ||= repository.root_ref if repository.exists? end diff --git a/app/models/user.rb b/app/models/user.rb index eeac87e2e52..cd3b1c95b7e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -674,10 +674,12 @@ class User < ActiveRecord::Base # Returns the groups a user has access to, either through a membership or a project authorization def authorized_groups - Group.from_union([ - groups, - authorized_projects.joins(:namespace).select('namespaces.*') - ]) + Group.unscoped do + Group.from_union([ + groups, + authorized_projects.joins(:namespace).select('namespaces.*') + ]) + end end # Returns the groups a user is a member of, either directly or through a parent group diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 102907a8bd3..42fd213d03b 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -58,7 +58,7 @@ class WikiPage attr_reader :page # The attributes Hash used for storing and validating - # new Page values before writing to the Gollum repository. + # new Page values before writing to the raw repository. attr_accessor :attributes def hook_attrs @@ -111,10 +111,7 @@ class WikiPage # The processed/formatted content of this page. def formatted_content - # Assuming @page exists, nil formatted_data means we didn't load it - # before hand (i.e. page was fetched by Gitaly), so we fetch it separately. - # If the page was fetched by Gollum, formatted_data would've been a String. - @attributes[:formatted_content] ||= @page&.formatted_data || @wiki.page_formatted_data(@page) + @attributes[:formatted_content] ||= @wiki.page_formatted_data(@page) end # The markup format for the page. diff --git a/app/serializers/diff_line_entity.rb b/app/serializers/diff_line_entity.rb index 2119a1017d3..942714b7787 100644 --- a/app/serializers/diff_line_entity.rb +++ b/app/serializers/diff_line_entity.rb @@ -9,6 +9,6 @@ class DiffLineEntity < Grape::Entity expose :meta_positions, as: :meta_data expose :rich_text do |line| - line.rich_text || CGI.escapeHTML(line.text) + ERB::Util.html_escape(line.rich_text || line.text) end end diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index 35f5cff0e0c..5017fa093f3 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -14,8 +14,8 @@ module Clusters else check_timeout end - rescue Kubeclient::HttpError => ke - app.make_errored!("Kubernetes error: #{ke.message}") unless app.errored? + rescue Kubeclient::HttpError + app.make_errored!("Kubernetes error") unless app.errored? end private @@ -27,7 +27,7 @@ module Clusters end def on_failed - app.make_errored!(installation_errors || 'Installation silently failed') + app.make_errored!('Installation failed') ensure remove_installation_pod end diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb index 7e3c0e77a83..dd8d2ed5eb6 100644 --- a/app/services/clusters/applications/install_service.rb +++ b/app/services/clusters/applications/install_service.rb @@ -12,10 +12,10 @@ module Clusters ClusterWaitForAppInstallationWorker.perform_in( ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) - rescue Kubeclient::HttpError => ke - app.make_errored!("Kubernetes error: #{ke.message}") - rescue StandardError => e - app.make_errored!("Can't start installation process. #{e.message}") + rescue Kubeclient::HttpError + app.make_errored!("Kubernetes error.") + rescue StandardError + app.make_errored!("Can't start installation process.") end end end diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml new file mode 100644 index 00000000000..408e569fe07 --- /dev/null +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -0,0 +1,16 @@ += form_for @application_setting, url: admin_application_settings_path(anchor: 'js-merge-request-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :diff_max_patch_bytes, 'Maximum diff patch size (Bytes)', class: 'label-light' + = f.number_field :diff_max_patch_bytes, class: 'form-control' + %span.form-text.text-muted + Diff files surpassing this limit will be presented as 'too large' + and won't be expandable. + + = link_to icon('question-circle'), + help_page_path('user/admin_area/diff_limits', + anchor: 'maximum-diff-patch-size') + + = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 4523332493b..908b30cc3ce 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -5,7 +5,7 @@ .sub-section .form-group .form-check - = f.check_box :hashed_storage_enabled, class: 'form-check-input' + = f.check_box :hashed_storage_enabled, class: 'form-check-input qa-hashed-storage-checkbox' = f.label :hashed_storage_enabled, class: 'form-check-label' do Use hashed storage paths for newly created and renamed projects .form-text.text-muted @@ -48,4 +48,4 @@ .form-text.text-muted = circuitbreaker_failure_reset_time_help_text - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: "btn btn-success qa-save-changes-button" diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index d8029e0c54a..be13138a764 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -13,7 +13,7 @@ .settings-content = render partial: 'repository_mirrors_form' -%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.qa-repository-storage-settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 = _('Repository storage') diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index e2043183a97..279db189a24 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -24,6 +24,17 @@ .settings-content = render 'account_and_limit' +%section.settings.as-diff-limits.no-animate#js-merge-request-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Diff limits') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Diff content limits') + .settings-content + = render 'diff_limits' + %section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 @@ -46,7 +57,7 @@ .settings-content = render 'signin' -%section.qa-terms-settings.settings.as-terms.no-animate#js-terms-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-terms.no-animate#js-terms-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 = _('Terms of Service and Privacy Policy') diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 593a6d816e3..e69143abe45 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -1,4 +1,5 @@ - page_title @application.name, "Applications" + %h3.page-title Application: #{@application.name} @@ -6,23 +7,29 @@ %table.table %tr %td - Application Id + = _('Application ID') %td - %code#application_id= @application.uid + .clipboard-group + .input-group + %input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true } + .input-group-append + = clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default") %tr %td - Secret: + = _('Secret') %td - %code#secret= @application.secret - + .clipboard-group + .input-group + %input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true } + .input-group-append + = clipboard_button(target: '#application_id', title: _("Copy secret to clipboard"), class: "btn btn btn-default") %tr %td - Callback url + = _('Callback URL') %td - @application.redirect_uri.split.each do |uri| %div %span.monospace= uri - %tr %td Trusted diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index bb76ac6d5f6..776bbc36ec2 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -10,18 +10,25 @@ %table.table %tr %td - = _('Application Id') + = _('Application ID') %td - %code#application_id= @application.uid + .clipboard-group + .input-group + %input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true } + .input-group-append + = clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default") %tr %td - = _('Secret:') + = _('Secret') %td - %code#secret= @application.secret - + .clipboard-group + .input-group + %input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true } + .input-group-append + = clipboard_button(target: '#application_id', title: _("Copy secret to clipboard"), class: "btn btn btn-default") %tr %td - = _('Callback url') + = _('Callback URL') %td - @application.redirect_uri.split.each do |uri| %div diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index 198c2d35b29..dfa5d820ce9 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -7,7 +7,7 @@ GitLab Community Edition - if user_signed_in? - %span= link_to Gitlab::VERSION, Gitlab::COM_URL + namespace_project_tag_path('gitlab-org', 'gitlab-ce', "v#{Gitlab::VERSION}") + %span= link_to_version = version_status_badge %hr diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index f912a32ee1a..5f15ba87729 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -1,4 +1,4 @@ -.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } +.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll .context-header = link_to admin_root_path, title: _('Admin Overview') do @@ -197,10 +197,10 @@ = link_to admin_application_settings_path do .nav-icon-container = sprite_icon('settings') - %span.nav-item-name + %span.nav-item-name.qa-admin-settings-item = _('Settings') - %ul.sidebar-sub-level-items + %ul.sidebar-sub-level-items.qa-admin-sidebar-submenu = nav_link(controller: :application_settings, html_options: { class: "fly-out-top-item" } ) do = link_to admin_application_settings_path do %strong.fly-out-top-item-name @@ -215,7 +215,7 @@ %span = _('Integrations') = nav_link(path: 'application_settings#repository') do - = link_to repository_admin_application_settings_path, title: _('Repository') do + = link_to repository_admin_application_settings_path, title: _('Repository'), class: 'qa-admin-settings-repository-item' do %span = _('Repository') - if template_exists?('admin/application_settings/templates') diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index dd6a84e503d..5acd45b74a7 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -9,7 +9,7 @@ %p Assignee: #{@merge_request.assignee_name} -= render_if_exists 'notify/merge_request_approvers', merge_request: @merge_request += render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter - if @merge_request.description %div diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb index d5b8f8d764f..754f4bca1cd 100644 --- a/app/views/notify/new_merge_request_email.text.erb +++ b/app/views/notify/new_merge_request_email.text.erb @@ -5,6 +5,6 @@ New Merge Request <%= @merge_request.to_reference %> <%= merge_path_description(@merge_request, 'to') %> Author: <%= @merge_request.author_name %> Assignee: <%= @merge_request.assignee_name %> -<%= render_if_exists 'notify/merge_request_approvers', merge_request: @merge_request %> +<%= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter %> <%= @merge_request.description %> diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 8fb6aa55436..5436806162d 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -18,14 +18,15 @@ Preview %li.md-header-toolbar.active - = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: "Add bold text" }) - = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: "Add italic text" }) - = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: "Insert a quote" }) - = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: "Insert code" }) - = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: "Add a bullet list" }) - = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: "Add a numbered list" }) - = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: "Add a task list" }) - %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: "Go full screen", data: { container: "body" } } + = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") }) + = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") }) + = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") }) + = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") }) + = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") }) + = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") }) + = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") }) + = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") }) + %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } } = sprite_icon("screen-full") .md-write-holder diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index d104608b2fe..75f35360e5e 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -9,7 +9,7 @@ .project-empty-note-panel %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } .prepend-top-20 - %h4 + %h4.append-bottom-20 = _('The repository for this project is empty') - if @project.can_current_user_push_code? diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml deleted file mode 100644 index 66a3b8b8fd1..00000000000 --- a/app/views/projects/jobs/_sidebar.html.haml +++ /dev/null @@ -1,38 +0,0 @@ -%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } - .sidebar-container - .blocks-container - #js-details-block-vue{ data: { terminal_path: can?(current_user, :create_build_terminal, @build) && @build.has_terminal? ? terminal_project_job_path(@project, @build) : nil } } - - - if @build.pipeline.stages_count > 1 - .block-last.dropdown.build-dropdown - %div - %span{ class: "ci-status-icon-#{@build.pipeline.status}" } - = ci_icon_for_status(@build.pipeline.status) - Pipeline - = link_to "##{@build.pipeline.id}", project_pipeline_path(@project, @build.pipeline), class: 'link-commit' - from - = link_to "#{@build.pipeline.ref}", project_ref_path(@project, @build.pipeline.ref), class: 'link-commit ref-name' - %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } - %span.stage-selection More - = icon('chevron-down') - %ul.dropdown-menu - - @build.pipeline.legacy_stages.each do |stage| - %li - %a.stage-item= stage.name - - .builds-container - - HasStatus::ORDERED_STATUSES.each do |build_status| - - builds.select{|build| build.status == build_status}.each do |build| - .build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } } - - tooltip = sanitize(build.tooltip_message.dup) - = link_to(project_job_path(@project, build), data: { toggle: 'tooltip', title: tooltip, container: 'body' }) do - = sprite_icon('arrow-right', size:16, css_class: 'icon-arrow-right') - %span{ class: "ci-status-icon-#{build.status}" } - = ci_icon_for_status(build.status) - %span - - if build.name - = build.name - - else - = build.id - - if build.retried? - = sprite_icon('retry', size:16, css_class: 'icon-retry') diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 5321bc46e73..db62de80bf3 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -93,7 +93,7 @@ - else = render "empty_states" - = render "sidebar", builds: @builds + #js-details-block-vue{ data: { terminal_path: can?(current_user, :create_build_terminal, @build) && @build.has_terminal? ? terminal_project_job_path(@project, @build) : nil } } .js-build-options{ data: javascript_build_options } diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml index 5a59f956cb5..13b967beba1 100644 --- a/app/views/projects/merge_requests/_form.html.haml +++ b/app/views/projects/merge_requests/_form.html.haml @@ -1,4 +1,4 @@ = form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' }, data: { markdown_version: @merge_request.cached_markdown_version } do |f| - = render 'shared/issuable/form', f: f, issuable: @merge_request + = render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index d5c4134dee2..464f8fa65e9 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -11,7 +11,7 @@ = link_to 'Change branches', mr_change_branches_path(@merge_request) %hr = form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| - = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits + = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter = f.hidden_field :source_project_id = f.hidden_field :source_branch = f.hidden_field :target_project_id diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml index c35f6f5a3c1..f6b3a49eacb 100644 --- a/app/views/shared/groups/_empty_state.html.haml +++ b/app/views/shared/groups/_empty_state.html.haml @@ -1,4 +1,4 @@ -.group-empty-state.row.align-items-center.justify-content-center.qa-groups-empty-state +.group-empty-state.row.align-items-center.justify-content-center .icon.text-center.order-md-2 = custom_icon("icon_empty_groups") diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml index 67e1cd0d67b..49b812baefc 100644 --- a/app/views/shared/groups/_search_form.html.haml +++ b/app/views/shared/groups/_search_form.html.haml @@ -1,2 +1,2 @@ = form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f| - = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2" + = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter qa-groups-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2" diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml index 70e05eb1c8c..4f6a71b6071 100644 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -1,16 +1,18 @@ - is_current_user = issuable_author_is_current_user(issuable) - display_issuable_type = issuable_display_type(issuable) - button_method = issuable_close_reopen_button_method(issuable) +- are_close_and_open_buttons_hidden = issuable_button_hidden?(issuable, true) && issuable_button_hidden?(issuable, false) -- if can_update - - if is_current_user +- if is_current_user + - if can_update = link_to "Close #{display_issuable_type}", close_issuable_path(issuable), method: button_method, class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}" - - else - = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable - - if can_reopen && is_current_user + - if can_reopen = link_to "Reopen #{display_issuable_type}", reopen_issuable_path(issuable), method: button_method, class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}" - else - = link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), - class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: 'Report abuse' + - if can_update && !are_close_and_open_buttons_hidden + = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable + - else + = link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), + class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: 'Report abuse' diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 5b28a43a361..b33c758b464 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -1,6 +1,7 @@ - form = local_assigns.fetch(:f) - commits = local_assigns[:commits] - project = @target_project || @project +- presenter = local_assigns.fetch(:presenter, nil) = form_errors(issuable) @@ -29,7 +30,7 @@ = render 'shared/issuable/form/metadata', issuable: issuable, form: form -= render_if_exists 'shared/issuable/approvals', issuable: issuable, form: form += render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form = render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 659e03fd67d..c4d177361e7 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -33,13 +33,13 @@ #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { action: 'submit' } } - %button.btn.btn-link + %button.btn.btn-link{ type: 'button' } = sprite_icon('search') %span Press Enter or click to search %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.btn.btn-link + %button.btn.btn-link{ type: 'button' } -# Encapsulate static class name `{{icon}}` inside #{} to bypass -# haml lint's ClassAttributeWithStaticValue %svg @@ -60,7 +60,7 @@ #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } - %button.btn.btn-link + %button.btn.btn-link{ type: 'button' } No Assignee %li.divider.droplab-item-ignore - if current_user @@ -73,38 +73,46 @@ #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } - %button.btn.btn-link + %button.btn.btn-link{ type: 'button' } No Milestone %li.filter-dropdown-item{ data: { value: 'upcoming' } } - %button.btn.btn-link + %button.btn.btn-link{ type: 'button' } Upcoming %li.filter-dropdown-item{ 'data-value' => 'started' } - %button.btn.btn-link + %button.btn.btn-link{ type: 'button' } Started %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.btn.btn-link.js-data-value + %button.btn.btn-link.js-data-value{ type: 'button' } {{title}} #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } - %button.btn.btn-link + %button.btn.btn-link{ type: 'button' } No Label %li.divider.droplab-item-ignore %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.btn.btn-link + %button.btn.btn-link{ type: 'button' } %span.dropdown-label-box{ style: 'background: {{color}}' } %span.label-title.js-data-value {{title}} #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item - %button.btn.btn-link + %button.btn.btn-link{ type: 'button' } %gl-emoji %span.js-data-value.prepend-left-10 {{name}} + #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('Yes') + %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('No') = render_if_exists 'shared/issuable/filter_weight', type: type |