diff options
Diffstat (limited to 'app')
80 files changed, 531 insertions, 481 deletions
diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js index 459cdd53f9b..ba9d9a3e1f7 100644 --- a/app/assets/javascripts/copy_as_gfm.js +++ b/app/assets/javascripts/copy_as_gfm.js @@ -18,12 +18,12 @@ const gfmRules = { }, }, TaskListFilter: { - 'input[type=checkbox].task-list-item-checkbox'(el, text) { + 'input[type=checkbox].task-list-item-checkbox'(el) { return `[${el.checked ? 'x' : ' '}]`; }, }, ReferenceFilter: { - '.tooltip'(el, text) { + '.tooltip'(el) { return ''; }, 'a.gfm:not([data-link=true])'(el, text) { @@ -39,15 +39,15 @@ const gfmRules = { }, }, TableOfContentsFilter: { - 'ul.section-nav'(el, text) { + 'ul.section-nav'(el) { return '[[_TOC_]]'; }, }, EmojiFilter: { - 'img.emoji'(el, text) { + 'img.emoji'(el) { return el.getAttribute('alt'); }, - 'gl-emoji'(el, text) { + 'gl-emoji'(el) { return `:${el.getAttribute('data-name')}:`; }, }, @@ -57,13 +57,13 @@ const gfmRules = { }, }, VideoLinkFilter: { - '.video-container'(el, text) { + '.video-container'(el) { const videoEl = el.querySelector('video'); if (!videoEl) return false; return CopyAsGFM.nodeToGFM(videoEl); }, - 'video'(el, text) { + 'video'(el) { return `![${el.dataset.title}](${el.getAttribute('src')})`; }, }, @@ -74,19 +74,19 @@ const gfmRules = { 'code.code.math[data-math-style=inline]'(el, text) { return `$\`${text}\`$`; }, - 'span.katex-display span.katex-mathml'(el, text) { + 'span.katex-display span.katex-mathml'(el) { const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); if (!mathAnnotation) return false; return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; }, - 'span.katex-mathml'(el, text) { + 'span.katex-mathml'(el) { const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); if (!mathAnnotation) return false; return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; }, - 'span.katex-html'(el, text) { + 'span.katex-html'(el) { // We don't want to include the content of this element in the copied text. return ''; }, @@ -95,7 +95,7 @@ const gfmRules = { }, }, SanitizationFilter: { - 'a[name]:not([href]):empty'(el, text) { + 'a[name]:not([href]):empty'(el) { return el.outerHTML; }, 'dl'(el, text) { @@ -143,7 +143,7 @@ const gfmRules = { }, }, MarkdownFilter: { - 'br'(el, text) { + 'br'(el) { // Two spaces at the end of a line are turned into a BR return ' '; }, @@ -162,7 +162,7 @@ const gfmRules = { 'blockquote'(el, text) { return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); }, - 'img'(el, text) { + 'img'(el) { return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; }, 'a.anchor'(el, text) { @@ -222,10 +222,10 @@ const gfmRules = { 'sup'(el, text) { return `^${text}`; }, - 'hr'(el, text) { + 'hr'(el) { return '-----'; }, - 'table'(el, text) { + 'table'(el) { const theadEl = el.querySelector('thead'); const tbodyEl = el.querySelector('tbody'); if (!theadEl || !tbodyEl) return false; @@ -233,11 +233,11 @@ const gfmRules = { const theadText = CopyAsGFM.nodeToGFM(theadEl); const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); - return theadText + tbodyText; + return [theadText, tbodyText].join('\n'); }, 'thead'(el, text) { const cells = _.map(el.querySelectorAll('th'), (cell) => { - let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2; + let chars = CopyAsGFM.nodeToGFM(cell).length + 2; let before = ''; let after = ''; @@ -262,10 +262,15 @@ const gfmRules = { return before + middle + after; }); - return `${text}|${cells.join('|')}|`; + const separatorRow = `|${cells.join('|')}|`; + + return [text, separatorRow].join('\n'); }, - 'tr'(el, text) { - const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim()); + 'tr'(el) { + const cellEls = el.querySelectorAll('td, th'); + if (cellEls.length === 0) return false; + + const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell)); return `| ${cells.join(' | ')} |`; }, }, @@ -273,12 +278,12 @@ const gfmRules = { class CopyAsGFM { constructor() { - $(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); - $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); - $(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this)); + $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); + $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); + $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM); } - copyAsGFM(e, transformer) { + static copyAsGFM(e, transformer) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; @@ -292,26 +297,59 @@ class CopyAsGFM { e.stopPropagation(); clipboardData.setData('text/plain', el.textContent); - clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el)); + clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); } - pasteGFM(e) { + static pasteGFM(e) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; + const text = clipboardData.getData('text/plain'); const gfm = clipboardData.getData('text/x-gfm'); if (!gfm) return; e.preventDefault(); - window.gl.utils.insertText(e.target, gfm); + window.gl.utils.insertText(e.target, (textBefore, textAfter) => { + // If the text before the cursor contains an odd number of backticks, + // we are either inside an inline code span that starts with 1 backtick + // or a code block that starts with 3 backticks. + // This logic still holds when there are one or more _closed_ code spans + // or blocks that will have 2 or 6 backticks. + // This will break down when the actual code block contains an uneven + // number of backticks, but this is a rare edge case. + const backtickMatch = textBefore.match(/`/g); + const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1; + + if (insideCodeBlock) { + return text; + } + + return gfm; + }); } static transformGFMSelection(documentFragment) { - // If the documentFragment contains more than just Markdown, don't copy as GFM. - if (documentFragment.querySelector('.md, .wiki')) return null; + const gfmEls = documentFragment.querySelectorAll('.md, .wiki'); + switch (gfmEls.length) { + case 0: { + return documentFragment; + } + case 1: { + return gfmEls[0]; + } + default: { + const allGfmEl = document.createElement('div'); + + for (let i = 0; i < gfmEls.length; i += 1) { + const lineEl = gfmEls[i]; + allGfmEl.appendChild(lineEl); + allGfmEl.appendChild(document.createTextNode('\n\n')); + } - return documentFragment; + return allGfmEl; + } + } } static transformCodeSelection(documentFragment) { @@ -343,7 +381,7 @@ class CopyAsGFM { return codeEl; } - static nodeToGFM(node) { + static nodeToGFM(node, respectWhitespaceParam = false) { if (node.nodeType === Node.COMMENT_NODE) { return ''; } @@ -352,7 +390,9 @@ class CopyAsGFM { return node.textContent; } - const text = this.innerGFM(node); + const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE'); + + const text = this.innerGFM(node, respectWhitespace); if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { return text; @@ -366,7 +406,17 @@ class CopyAsGFM { if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue; - const result = func(node, text); + let result; + if (func.length === 2) { + // if `func` takes 2 arguments, it depends on text. + // if there is no text, we don't need to generate GFM for this node. + if (text.length === 0) continue; + + result = func(node, text); + } else { + result = func(node); + } + if (result === false) continue; return result; @@ -376,7 +426,7 @@ class CopyAsGFM { return text; } - static innerGFM(parentNode) { + static innerGFM(parentNode, respectWhitespace = false) { const nodes = parentNode.childNodes; const clonedParentNode = parentNode.cloneNode(true); @@ -386,13 +436,19 @@ class CopyAsGFM { const node = nodes[i]; const clonedNode = clonedNodes[i]; - const text = this.nodeToGFM(node); + const text = this.nodeToGFM(node, respectWhitespace); // `clonedNode.replaceWith(text)` is not yet widely supported clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); } - return clonedParentNode.innerText || clonedParentNode.textContent; + let nodeText = clonedParentNode.innerText || clonedParentNode.textContent; + + if (!respectWhitespace) { + nodeText = nodeText.trim(); + } + + return nodeText; } } diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js index cfd7e2ca189..a5427417031 100644 --- a/app/assets/javascripts/droplab/plugins/ajax_filter.js +++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js @@ -1,4 +1,5 @@ /* eslint-disable */ +import AjaxCache from '../../lib/utils/ajax_cache'; const AjaxFilter = { init: function(hook) { @@ -58,50 +59,24 @@ const AjaxFilter = { this.loading = true; var params = config.params || {}; params[config.searchKey] = searchValue; - var self = this; - self.cache = self.cache || {}; var url = config.endpoint + this.buildParams(params); - var urlCachedData = self.cache[url]; - if (urlCachedData) { - self._loadData(urlCachedData, config, self); - } else { - this._loadUrlData(url) - .then(function(data) { - self._loadData(data, config, self); - }, config.onError).catch(config.onError); - } + return AjaxCache.retrieve(url) + .then((data) => { + this._loadData(data, config); + }) + .catch(config.onError); }, - _loadUrlData: function _loadUrlData(url) { - var self = this; - return new Promise(function(resolve, reject) { - var xhr = new XMLHttpRequest; - xhr.open('GET', url, true); - xhr.onreadystatechange = function () { - if(xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 200) { - var data = JSON.parse(xhr.responseText); - self.cache[url] = data; - return resolve(data); - } else { - return reject([xhr.responseText, xhr.status]); - } - } - }; - xhr.send(); - }); - }, - - _loadData: function _loadData(data, config, self) { - const list = self.hook.list; + _loadData(data, config) { + const list = this.hook.list; if (config.loadingTemplate && list.data === undefined || list.data.length === 0) { const dataLoadingTemplate = list.list.querySelector('[data-loading-template]'); if (dataLoadingTemplate) { - dataLoadingTemplate.outerHTML = self.listTemplate; + dataLoadingTemplate.outerHTML = this.listTemplate; } } - if (!self.destroyed) { + if (!this.destroyed) { var hookListChildren = list.list.children; var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic'); if (onlyDynamicList && data.length === 0) { @@ -109,7 +84,7 @@ const AjaxFilter = { } list.setData.call(list, data); } - self.notLoading(); + this.notLoading(); list.currentIndex = 0; }, diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js index 15052dbd362..c51d4b056af 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js @@ -13,13 +13,17 @@ export default { required: false, default: true, }, + allowedKeys: { + type: Array, + required: true, + }, }, computed: { processedItems() { return this.items.map((item) => { const { tokens, searchToken } - = gl.FilteredSearchTokenizer.processTokens(item); + = gl.FilteredSearchTokenizer.processTokens(item, this.allowedKeys); const resultantTokens = tokens.map(token => ({ prefix: `${token.key}:`, diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 5d92d29c399..2af242a69df 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -2,14 +2,18 @@ import Filter from '~/droplab/plugins/filter'; import './filtered_search_dropdown'; class DropdownHint extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { + constructor(droplab, dropdown, input, tokenKeys, filter) { super(droplab, dropdown, input, filter); this.config = { Filter: { template: 'hint', - filterFunction: gl.DropdownUtils.filterHint.bind(null, input), + filterFunction: gl.DropdownUtils.filterHint.bind(null, { + input, + allowedKeys: tokenKeys.getKeys(), + }), }, }; + this.tokenKeys = tokenKeys; } itemClicked(e) { @@ -52,20 +56,13 @@ class DropdownHint extends gl.FilteredSearchDropdown { } renderContent() { - const dropdownData = []; - - [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { - const { icon, hint, tag, type } = dropdownMenu.dataset; - if (icon && hint && tag) { - dropdownData.push( - Object.assign({ - icon: `fa-${icon}`, - hint, - tag: `<${tag}>`, - }, type && { type }), - ); - } - }); + const dropdownData = gl.FilteredSearchTokenKeys.get() + .map(tokenKey => ({ + icon: `fa-${tokenKey.icon}`, + hint: tokenKey.key, + tag: `<${tokenKey.symbol}${tokenKey.key}>`, + type: tokenKey.type, + })); this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); this.droplab.setData(this.hookId, dropdownData); diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index f20193eecba..34a9e34070c 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -5,7 +5,7 @@ import Filter from '~/droplab/plugins/filter'; import './filtered_search_dropdown'; class DropdownNonUser extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter, endpoint, symbol) { + constructor(droplab, dropdown, input, tokenKeys, filter, endpoint, symbol) { super(droplab, dropdown, input, filter); this.symbol = symbol; this.config = { diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index 42538780e50..6b4338ca1d6 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -4,7 +4,7 @@ import AjaxFilter from '~/droplab/plugins/ajax_filter'; import './filtered_search_dropdown'; class DropdownUser extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { + constructor(droplab, dropdown, input, tokenKeys, filter) { super(droplab, dropdown, input, filter); this.config = { AjaxFilter: { @@ -25,6 +25,7 @@ class DropdownUser extends gl.FilteredSearchDropdown { }, }, }; + this.tokenKeys = tokenKeys; } itemClicked(e) { @@ -43,7 +44,7 @@ class DropdownUser extends gl.FilteredSearchDropdown { getSearchInput() { const query = gl.DropdownUtils.getSearchInput(this.input); - const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); + const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); let value = lastToken || ''; diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index bc7c1dffece..5c02a7a53d3 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -50,10 +50,12 @@ class DropdownUtils { return updatedItem; } - static filterHint(input, item) { + static filterHint(config, item) { + const { input, allowedKeys } = config; const updatedItem = item; const searchInput = gl.DropdownUtils.getSearchQuery(input); - const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput); + const { lastToken, tokens } = + gl.FilteredSearchTokenizer.processTokens(searchInput, allowedKeys); const lastKey = lastToken.key || lastToken || ''; const allowMultiple = item.type === 'array'; const itemInExistingTokens = tokens.some(t => t.key === item.hint); 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 49a6cd1ac77..6bc6bc43f51 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -2,10 +2,10 @@ import DropLab from '~/droplab/drop_lab'; import FilteredSearchContainer from './container'; class FilteredSearchDropdownManager { - constructor(baseEndpoint = '', page) { + constructor(baseEndpoint = '', tokenizer, page) { this.container = FilteredSearchContainer.container; this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); - this.tokenizer = gl.FilteredSearchTokenizer; + this.tokenizer = tokenizer; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.page = page; @@ -98,7 +98,8 @@ class FilteredSearchDropdownManager { if (!mappingKey.reference) { const dl = this.droplab; - const defaultArguments = [null, dl, element, this.filteredSearchInput, key]; + const defaultArguments = + [null, dl, element, this.filteredSearchInput, this.filteredSearchTokenKeys, key]; const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); // Passing glArguments to `new gl[glClass](<arguments>)` @@ -141,7 +142,8 @@ class FilteredSearchDropdownManager { setDropdown() { const query = gl.DropdownUtils.getSearchQuery(true); - const { lastToken, searchToken } = this.tokenizer.processTokens(query); + const { lastToken, searchToken } = + this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys()); if (this.currentDropdown) { this.updateCurrentDropdownOffset(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 57d247e11a9..58f2b75bd50 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -15,6 +15,7 @@ class FilteredSearchManager { this.recentSearchesStore = new RecentSearchesStore({ isLocalStorageAvailable: RecentSearchesService.isAvailable(), + allowedKeys: this.filteredSearchTokenKeys.getKeys(), }); const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); const projectPath = searchHistoryDropdownElement ? @@ -46,7 +47,7 @@ class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; - this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); + this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, page); this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesStore, @@ -318,7 +319,7 @@ class FilteredSearchManager { handleInputVisualToken() { const input = this.filteredSearchInput; const { tokens, searchToken } - = gl.FilteredSearchTokenizer.processTokens(input.value); + = this.tokenizer.processTokens(input.value, this.filteredSearchTokenKeys.getKeys()); const { isLastVisualTokenValid } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); @@ -444,7 +445,7 @@ class FilteredSearchManager { this.saveCurrentSearchQuery(); const { tokens, searchToken } - = this.tokenizer.processTokens(searchQuery); + = this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys()); const currentState = gl.utils.getParameterByName('state') || 'opened'; paths.push(`state=${currentState}`); 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 1abad9d1b73..025d4d8795b 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -3,21 +3,25 @@ const tokenKeys = [{ type: 'string', param: 'username', symbol: '@', + icon: 'pencil', }, { key: 'assignee', type: 'string', param: 'username', symbol: '@', + icon: 'user', }, { key: 'milestone', type: 'string', param: 'title', symbol: '%', + icon: 'clock-o', }, { key: 'label', type: 'array', param: 'name[]', symbol: '~', + icon: 'tag', }]; const alternativeTokenKeys = [{ @@ -56,6 +60,10 @@ class FilteredSearchTokenKeys { return tokenKeys; } + static getKeys() { + return tokenKeys.map(i => i.key); + } + static getAlternatives() { return alternativeTokenKeys; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js index aa513b3aeae..f2e66503e5e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js @@ -1,8 +1,7 @@ import './filtered_search_token_keys'; class FilteredSearchTokenizer { - static processTokens(input) { - const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key); + static processTokens(input, allowedKeys) { // Regex extracts `(token):(symbol)(value)` // Values that start with a double quote must end in a double quote (same for single) const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g'); diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js index b2e6f63aacf..27e49d4fb96 100644 --- a/app/assets/javascripts/filtered_search/recent_searches_root.js +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -37,6 +37,7 @@ class RecentSearchesRoot { <recent-searches-dropdown-content :items="recentSearches" :is-local-storage-available="isLocalStorageAvailable" + :allowed-keys="allowedKeys" /> `, components: { diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js index 35fc15e4c87..aaa0c349d93 100644 --- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -1,10 +1,11 @@ import _ from 'underscore'; class RecentSearchesStore { - constructor(initialState = {}) { + constructor(initialState = {}, allowedKeys) { this.state = Object.assign({ isLocalStorageAvailable: true, recentSearches: [], + allowedKeys, }, initialState); } diff --git a/app/assets/javascripts/group_name.js b/app/assets/javascripts/group_name.js index 62675d7e67e..462d792b8d5 100644 --- a/app/assets/javascripts/group_name.js +++ b/app/assets/javascripts/group_name.js @@ -44,18 +44,18 @@ export default class GroupName { showToggle() { this.title.classList.add('wrap'); this.toggle.classList.remove('hidden'); - if (this.isHidden) this.groupTitle.classList.add('is-hidden'); + if (this.isHidden) this.groupTitle.classList.add('hidden'); } hideToggle() { this.title.classList.remove('wrap'); this.toggle.classList.add('hidden'); - if (this.isHidden) this.groupTitle.classList.remove('is-hidden'); + if (this.isHidden) this.groupTitle.classList.remove('hidden'); } toggleGroups() { this.isHidden = !this.isHidden; - this.groupTitle.classList.toggle('is-hidden'); + this.groupTitle.classList.toggle('hidden'); } render() { diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js index cf030d613df..f1fe95e12e8 100644 --- a/app/assets/javascripts/lib/utils/ajax_cache.js +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -1,21 +1,11 @@ -class AjaxCache { +import Cache from './cache'; + +class AjaxCache extends Cache { constructor() { - this.internalStorage = { }; + super(); this.pendingRequests = { }; } - get(endpoint) { - return this.internalStorage[endpoint]; - } - - hasData(endpoint) { - return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint); - } - - remove(endpoint) { - delete this.internalStorage[endpoint]; - } - retrieve(endpoint) { if (this.hasData(endpoint)) { return Promise.resolve(this.get(endpoint)); diff --git a/app/assets/javascripts/lib/utils/cache.js b/app/assets/javascripts/lib/utils/cache.js new file mode 100644 index 00000000000..3141f1eeafc --- /dev/null +++ b/app/assets/javascripts/lib/utils/cache.js @@ -0,0 +1,19 @@ +class Cache { + constructor() { + this.internalStorage = { }; + } + + get(key) { + return this.internalStorage[key]; + } + + hasData(key) { + return Object.prototype.hasOwnProperty.call(this.internalStorage, key); + } + + remove(key) { + delete this.internalStorage[key]; + } +} + +export default Cache; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 7e62773ae6c..a537267643e 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -198,10 +198,12 @@ const textBefore = value.substring(0, selectionStart); const textAfter = value.substring(selectionEnd, value.length); - const newText = textBefore + text + textAfter; + + const insertedText = text instanceof Function ? text(textBefore, textAfter) : text; + const newText = textBefore + insertedText + textAfter; target.value = newText; - target.selectionStart = target.selectionEnd = selectionStart + text.length; + target.selectionStart = target.selectionEnd = selectionStart + insertedText.length; // Trigger autosave $(target).trigger('input'); diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js new file mode 100644 index 00000000000..88f8a622c00 --- /dev/null +++ b/app/assets/javascripts/lib/utils/users_cache.js @@ -0,0 +1,28 @@ +import Api from '../../api'; +import Cache from './cache'; + +class UsersCache extends Cache { + retrieve(username) { + if (this.hasData(username)) { + return Promise.resolve(this.get(username)); + } + + return Api.users('', { username }) + .then((users) => { + if (!users.length) { + throw new Error(`User "${username}" could not be found!`); + } + + if (users.length > 1) { + throw new Error(`Expected username "${username}" to be unique!`); + } + + const user = users[0]; + this.internalStorage[username] = user; + return user; + }); + // missing catch is intentional, error handling depends on use case + } +} + +export default new UsersCache(); diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js index 5325e495815..edc2293915f 100644 --- a/app/assets/javascripts/raven/index.js +++ b/app/assets/javascripts/raven/index.js @@ -6,6 +6,10 @@ const index = function index() { currentUserId: gon.current_user_id, whitelistUrls: [gon.gitlab_url], isProduction: process.env.NODE_ENV, + release: gon.revision, + tags: { + revision: gon.revision, + }, }); return RavenConfig; diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js index c7fe1cacf49..ae54fa5f1a9 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/raven/raven_config.js @@ -1,4 +1,5 @@ import Raven from 'raven-js'; +import $ from 'jquery'; const IGNORE_ERRORS = [ // Random plugins/extensions @@ -57,6 +58,8 @@ const RavenConfig = { configure() { Raven.config(this.options.sentryDsn, { + release: this.options.release, + tags: this.options.tags, whitelistUrls: this.options.whitelistUrls, environment: this.options.isProduction ? 'production' : 'development', ignoreErrors: this.IGNORE_ERRORS, @@ -72,7 +75,7 @@ const RavenConfig = { }, bindRavenErrors() { - window.$(document).on('ajaxError.raven', this.handleRavenErrors); + $(document).on('ajaxError.raven', this.handleRavenErrors); }, handleRavenErrors(event, req, config, err) { diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index b07b3a4d3a5..dace03554e8 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -38,7 +38,7 @@ import './shortcuts_navigation'; } ShortcutsIssuable.prototype.replyWithSelectedText = function() { - var quote, documentFragment, selected, separator; + var quote, documentFragment, el, selected, separator; var replyField = $('.js-main-target-form #note_note'); documentFragment = window.gl.utils.getSelectedFragment(); @@ -47,10 +47,8 @@ import './shortcuts_navigation'; return; } - // If the documentFragment contains more than just Markdown, don't copy as GFM. - if (documentFragment.querySelector('.md, .wiki')) return; - - selected = window.gl.CopyAsGFM.nodeToGFM(documentFragment); + el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); + selected = window.gl.CopyAsGFM.nodeToGFM(el); if (selected.trim() === "") { return; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js index 1488a66c695..da4abf0b68f 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js @@ -69,10 +69,11 @@ export default { <div> <assignee-title :number-of-assignees="store.assignees.length" - :loading="loading" + :loading="loading || store.isFetching.assignees" :editable="store.editable" /> <assignees + v-if="!store.isFetching.assignees" class="value" :root-path="store.rootPath" :users="store.assignees" diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 2d44c05bb8d..3356dd0191f 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -10,6 +10,9 @@ export default class SidebarStore { this.humanTimeEstimate = ''; this.humanTimeSpent = ''; this.assignees = []; + this.isFetching = { + assignees: true, + }; SidebarStore.singleton = this; } @@ -18,6 +21,7 @@ export default class SidebarStore { } setAssigneeData(data) { + this.isFetching.assignees = false; if (data.assignees) { this.assignees = data.assignees; } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index fb78ea92da1..7c15abfff10 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -11,10 +11,6 @@ export default function deviseState(data) { return 'conflicts'; } else if (data.work_in_progress) { return 'workInProgress'; - } else if (this.mergeWhenPipelineSucceeds) { - return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds'; - } else if (!this.canMerge) { - return 'notAllowedToMerge'; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { return 'pipelineFailed'; } else if (this.hasMergeableDiscussionsState) { @@ -23,6 +19,10 @@ export default function deviseState(data) { return 'pipelineBlocked'; } else if (this.hasSHAChanged) { return 'shaMismatch'; + } else if (this.mergeWhenPipelineSucceeds) { + return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds'; + } else if (!this.canMerge) { + return 'notAllowedToMerge'; } else if (this.canBeMerged) { return 'readyToMerge'; } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 06661b930e3..c07bd25e6fd 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -4,7 +4,7 @@ import { getStateKey } from '../dependencies'; export default class MergeRequestStore { constructor(data) { - this.startingSha = data.diff_head_sha; + this.sha = data.diff_head_sha; this.setData(data); } @@ -16,7 +16,6 @@ export default class MergeRequestStore { this.targetBranch = data.target_branch; this.sourceBranch = data.source_branch; this.mergeStatus = data.merge_status; - this.sha = data.diff_head_sha; this.commitMessage = data.merge_commit_message; this.commitMessageWithDescription = data.merge_commit_message_with_description; this.commitsCount = data.commits_count; @@ -69,7 +68,7 @@ export default class MergeRequestStore { this.canMerge = !!data.merge_path; this.canCreateIssue = currentUser.can_create_issue || false; this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; - this.hasSHAChanged = this.sha !== this.startingSha; + this.hasSHAChanged = this.sha !== data.diff_head_sha; this.canBeMerged = data.can_be_merged || false; // Cherry-pick and Revert actions related diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js index d5f87588c28..740930dce5b 100644 --- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js +++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js @@ -4,7 +4,7 @@ import VueResource from 'vue-resource'; Vue.use(VueResource); // Maintain a global counter for active requests -// see: spec/support/wait_for_vue_resource.rb +// see: spec/support/wait_for_requests.rb Vue.http.interceptors.push((request, next) => { window.activeVueResources = window.activeVueResources || 0; window.activeVueResources += 1; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 0db3ac1a60e..d64b1237b2c 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -110,6 +110,7 @@ .award-control { margin: 0 5px 6px 0; outline: 0; + position: relative; &.disabled { cursor: default; @@ -227,8 +228,8 @@ .award-control-icon-positive, .award-control-icon-super-positive { position: absolute; - left: 11px; - bottom: 7px; + left: 10px; + bottom: 6px; opacity: 0; @include transition(opacity, transform); } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 637731cc479..f0994e968c8 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -263,7 +263,9 @@ } .filtered-search-input-dropdown-menu { + max-height: 215px; max-width: 280px; + overflow: auto; @media (max-width: $screen-xs-min) { width: auto; @@ -372,11 +374,6 @@ padding: 0; } -.filter-dropdown { - max-height: 215px; - overflow: auto; -} - @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { .issue-bulk-update-dropdown-toggle { width: 100px; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index ce8b27a1951..d8645afb7da 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -40,7 +40,17 @@ header { } &.with-horizontal-nav { - border-color: transparent; + border-bottom: 0; + + .navbar-border { + height: 1px; + position: absolute; + right: 0; + left: 0; + bottom: -1px; + background-color: $border-color; + opacity: 0; + } } .container-fluid { @@ -114,16 +124,6 @@ header { } } - .navbar-border { - height: 1px; - position: absolute; - right: 0; - left: 0; - bottom: 0; - background-color: $border-color; - opacity: 0; - } - .global-dropdown { position: absolute; left: -10px; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 018f61ca3a8..5b62d7fa3a7 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -83,4 +83,8 @@ position: fixed; top: $header-height; } + + &:not(.affix-top) { + min-height: 100%; + } } diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index aa0c512a277..1fd734d279b 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -5,7 +5,7 @@ .note-text { p:last-child { - margin-bottom: 0; + margin-bottom: 0 !important; } } @@ -23,7 +23,6 @@ } .timeline-entry { - padding: $gl-padding $gl-btn-padding 0; border-color: $white-normal; color: $gl-text-color; border-bottom: 1px solid $border-white-light; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 9db26f99a75..49e453c7dbc 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -164,10 +164,6 @@ .discussion-body, .diff-file { - .notes .note { - padding: 10px 15px; - } - .discussion-reply-holder { background-color: $white-light; padding: 10px 16px; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 4b15fc2bd82..51918917329 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -43,7 +43,11 @@ ul.notes { } .discussion-body { - padding-top: 15px; + padding-top: 8px; + + .panel { + margin-bottom: 0; + } } .discussion { @@ -53,6 +57,7 @@ ul.notes { } .note { + padding: $gl-padding $gl-btn-padding 0; display: block; position: relative; border-bottom: 1px solid $white-normal; @@ -78,11 +83,7 @@ ul.notes { &.note-discussion { &.timeline-entry { - padding: 14px 10px; - } - - .system-note { - padding: 0; + padding: $gl-padding 10px; } } @@ -167,7 +168,7 @@ ul.notes { margin-left: 65px; } - .note-header { + .note-header-info { padding-bottom: 0; } @@ -377,7 +378,11 @@ ul.notes { .note-header-info { min-width: 0; - padding-bottom: 5px; + padding-bottom: 8px; +} + +.system-note .note-header-info { + padding-bottom: 0; } .note-headline-light { @@ -582,6 +587,17 @@ ul.notes { } } +.discussion-body, +.diff-file { + .notes .note { + padding: 10px 15px; + + &.system-note { + padding: 0; + } + } +} + .diff-file { .is-over { .add-diff-note { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8ce9150e4a9..ab5aed24917 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base include EnforcesTwoFactorAuthentication before_action :authenticate_user_from_private_token! + before_action :authenticate_user_from_rss_token! before_action :authenticate_user! before_action :validate_user_service_ticket! before_action :check_password_expiration @@ -72,13 +73,20 @@ class ApplicationController < ActionController::Base user = User.find_by_authentication_token(token) || User.find_by_personal_access_token(token) - if user && can?(user, :log_in) - # Notice we are passing store false, so the user is not - # actually stored in the session and a token is needed - # for every request. If you want the token to work as a - # sign in token, you can simply remove store: false. - sign_in user, store: false - end + sessionless_sign_in(user) + end + + # This filter handles authentication for atom request with an rss_token + def authenticate_user_from_rss_token! + return unless request.format.atom? + + token = params[:rss_token].presence + + return unless token.present? + + user = User.find_by_rss_token(token) + + sessionless_sign_in(user) end def log_exception(exception) @@ -282,4 +290,14 @@ class ApplicationController < ActionController::Base ensure Gitlab::I18n.reset_locale end + + def sessionless_sign_in(user) + if user && can?(user, :log_in) + # Notice we are passing store false, so the user is not + # actually stored in the session and a token is needed + # for every request. If you want the token to work as a + # sign in token, you can simply remove store: false. + sign_in user, store: false + end + end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 57e23cea00e..8cd1c47eb3f 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -40,6 +40,14 @@ class ProfilesController < Profiles::ApplicationController redirect_to profile_account_path end + def reset_rss_token + if current_user.reset_rss_token! + flash[:notice] = "RSS token was successfully reset" + end + + redirect_to profile_account_path + end + def audit_log @events = AuditEvent.where(entity_type: "User", entity_id: current_user.id). order("created_at DESC"). diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index fd57afbd05f..efe83776834 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -31,6 +31,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController def folder folder_environments = project.environments.where(environment_type: params[:id]) @environments = folder_environments.with_state(params[:scope] || :available) + .order(:name) respond_to do |format| format.html diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 544715d62ea..cc62e1fa99b 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -257,7 +257,7 @@ class ProjectsController < Projects::ApplicationController # # pages list order: repository readme, wiki home, issues list, customize workflow def render_landing_page - if @project.feature_available?(:repository, current_user) + if can?(current_user, :download_code, @project) return render 'projects/no_repo' unless @project.repository_exists? render 'projects/empty' if @project.empty_repo? else diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index de959f13713..d36bb4ab074 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -49,7 +49,7 @@ module PreferencesHelper user_view = current_user.project_view - if @project.feature_available?(:repository, current_user) + if can?(current_user, :download_code, @project) user_view elsif user_view == "activity" "activity" diff --git a/app/helpers/rss_helper.rb b/app/helpers/rss_helper.rb index ea5d2932ef4..9ac4df88dc3 100644 --- a/app/helpers/rss_helper.rb +++ b/app/helpers/rss_helper.rb @@ -1,5 +1,5 @@ module RssHelper def rss_url_options - { format: :atom, private_token: current_user.try(:private_token) } + { format: :atom, rss_token: current_user.try(:rss_token) } end end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index d889d141101..209bd56b78a 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -17,7 +17,8 @@ module SystemNoteHelper 'visible' => 'icon_eye', 'milestone' => 'icon_clock_o', 'discussion' => 'icon_comment_o', - 'moved' => 'icon_arrow_circle_o_right' + 'moved' => 'icon_arrow_circle_o_right', + 'outdated' => 'icon_edit' }.freeze def icon_for_system_note(note) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index ffafc678968..fe63728ea23 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -89,6 +89,7 @@ class CommitStatus < ActiveRecord::Base else PipelineUpdateWorker.perform_async(pipeline.id) end + ExpireJobCacheWorker.perform_async(commit_status.id) end end end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index a7bdf5587b2..eee1a36ac6b 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -47,4 +47,12 @@ module DiscussionOnDiff prev_lines end + + def line_code_in_diffs(diff_refs) + if active?(diff_refs) + line_code + elsif diff_refs && created_at_diff?(diff_refs) + original_line_code + end + end end diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 14ddd2fcc88..800574d8be0 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -19,21 +19,9 @@ class DiffDiscussion < Discussion def merge_request_version_params return unless for_merge_request? + return {} if active? - if active? - {} - else - diff_refs = position.diff_refs - - if diff = noteable.merge_request_diff_for(diff_refs) - { diff_id: diff.id } - elsif diff = noteable.merge_request_diff_for(diff_refs.head_sha) - { - diff_id: diff.id, - start_sha: diff_refs.start_sha - } - end - end + noteable.version_params_for(position.diff_refs) end def reply_attributes diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 76c59199afd..1764004078e 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -8,6 +8,7 @@ class DiffNote < Note serialize :original_position, Gitlab::Diff::Position serialize :position, Gitlab::Diff::Position + serialize :change_position, Gitlab::Diff::Position validates :original_position, presence: true validates :position, presence: true @@ -25,7 +26,7 @@ class DiffNote < Note DiffDiscussion end - %i(original_position position).each do |meth| + %i(original_position position change_position).each do |meth| define_method "#{meth}=" do |new_position| if new_position.is_a?(String) new_position = JSON.parse(new_position) rescue nil @@ -36,6 +37,8 @@ class DiffNote < Note new_position = Gitlab::Diff::Position.new(new_position) end + return if new_position == read_attribute(meth) + super(new_position) end end @@ -45,7 +48,7 @@ class DiffNote < Note end def diff_line - @diff_line ||= diff_file.line_for_position(self.original_position) if diff_file + @diff_line ||= diff_file&.line_for_position(self.original_position) end def for_line?(line) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 9be00880438..2eec013fa9d 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -416,13 +416,24 @@ class MergeRequest < ActiveRecord::Base @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha] end + def version_params_for(diff_refs) + if diff = merge_request_diff_for(diff_refs) + { diff_id: diff.id } + elsif diff = merge_request_diff_for(diff_refs.head_sha) + { + diff_id: diff.id, + start_sha: diff_refs.start_sha + } + end + end + def reload_diff_if_branch_changed if source_branch_changed? || target_branch_changed? reload_diff end end - def reload_diff + def reload_diff(current_user = nil) return unless open? old_diff_refs = self.diff_refs @@ -432,7 +443,8 @@ class MergeRequest < ActiveRecord::Base update_diff_notes_positions( old_diff_refs: old_diff_refs, - new_diff_refs: new_diff_refs + new_diff_refs: new_diff_refs, + current_user: current_user ) end @@ -861,7 +873,7 @@ class MergeRequest < ActiveRecord::Base diff_sha_refs && diff_sha_refs.complete? end - def update_diff_notes_positions(old_diff_refs:, new_diff_refs:) + def update_diff_notes_positions(old_diff_refs:, new_diff_refs:, current_user: nil) return unless has_complete_diff_refs? return if new_diff_refs == old_diff_refs @@ -875,7 +887,7 @@ class MergeRequest < ActiveRecord::Base service = Notes::DiffPositionUpdateService.new( self.project, - nil, + current_user, old_diff_refs: old_diff_refs, new_diff_refs: new_diff_refs, paths: paths diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index f0a3c30ea74..6e3917a10a3 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -175,12 +175,11 @@ class MergeRequestDiff < ActiveRecord::Base self == merge_request.merge_request_diff end - def compare_with(sha, straight: true) + def compare_with(sha) # When compare merge request versions we want diff A..B instead of A...B # so we handle cases when user does squash and rebase of the commits between versions. # For this reason we set straight to true by default. - CompareService.new(project, head_commit_sha) - .execute(project, sha, straight: straight) + CompareService.new(project, head_commit_sha).execute(project, sha, straight: true) end def commits_count diff --git a/app/models/note.rb b/app/models/note.rb index 46d0a4f159f..60257aac93b 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -124,13 +124,12 @@ class Note < ActiveRecord::Base groups = {} diff_notes.fresh.discussions.each do |discussion| - if discussion.active?(diff_refs) - discussions = groups[discussion.line_code] ||= [] - elsif diff_refs && discussion.created_at_diff?(diff_refs) - discussions = groups[discussion.original_line_code] ||= [] - end + line_code = discussion.line_code_in_diffs(diff_refs) - discussions << discussion if discussions + if line_code + discussions = groups[line_code] ||= [] + discussions << discussion + end end groups diff --git a/app/models/project.rb b/app/models/project.rb index 65745fd6d37..cfca0dcd2f2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -205,7 +205,7 @@ class Project < ActiveRecord::Base presence: true, dynamic_path: true, length: { maximum: 255 }, - format: { with: Gitlab::Regex.project_path_regex, + format: { with: Gitlab::Regex.project_path_format_regex, message: Gitlab::Regex.project_path_regex_message }, uniqueness: { scope: :namespace_id } diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 50435b67eda..eddf308eae3 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -76,7 +76,7 @@ class IssueTrackerService < Service message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" result = true end - rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED => error + rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}" end Rails.logger.info(message) diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index f388773efee..a91a986e195 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -294,7 +294,7 @@ class JiraService < IssueTrackerService def jira_request yield - rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e + rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" nil end diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index b44f4fe000c..414c95f7705 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -2,6 +2,7 @@ class SystemNoteMetadata < ActiveRecord::Base ICON_TYPES = %w[ commit description merge confidential visible label assignee cross_reference title time_tracking branch milestone discussion task moved opened closed merged + outdated ].freeze validates :note, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index 837ab78228b..cf3914568a6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,6 +15,7 @@ class User < ActiveRecord::Base add_authentication_token_field :authentication_token add_authentication_token_field :incoming_email_token + add_authentication_token_field :rss_token default_value_for :admin, false default_value_for(:external) { current_application_settings.user_default_external } @@ -1004,6 +1005,13 @@ class User < ActiveRecord::Base save end + # each existing user needs to have an `rss_token`. + # we do this on read since migrating all existing users is not a feasible + # solution. + def rss_token + ensure_rss_token! + end + protected # override, from Devise::Validatable diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 1131d6f4913..81d217929d5 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -66,12 +66,12 @@ module MergeRequests filter_merge_requests(merge_requests).each do |merge_request| if merge_request.source_branch == @branch_name || force_push? - merge_request.reload_diff + merge_request.reload_diff(current_user) else mr_commit_ids = merge_request.commits_sha push_commit_ids = @commits.map(&:id) matches = mr_commit_ids & push_commit_ids - merge_request.reload_diff if matches.any? + merge_request.reload_diff(current_user) if matches.any? end merge_request.mark_as_unchecked diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index fadcce5d9b6..54b19e6d651 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -8,7 +8,7 @@ module MergeRequests create_note(merge_request) notification_service.reopen_mr(merge_request, current_user) execute_hooks(merge_request, 'reopen') - merge_request.reload_diff + merge_request.reload_diff(current_user) merge_request.mark_as_unchecked end diff --git a/app/services/notes/diff_position_update_service.rb b/app/services/notes/diff_position_update_service.rb index 0cb731f5bc3..eff7b287269 100644 --- a/app/services/notes/diff_position_update_service.rb +++ b/app/services/notes/diff_position_update_service.rb @@ -1,26 +1,29 @@ module Notes class DiffPositionUpdateService < BaseService def execute(note) - new_position = tracer.trace(note.position) + results = tracer.trace(note.position) + return unless results - # Don't update the position if the type doesn't match, since that means - # the diff line commented on was changed, and the comment is now outdated - old_position = note.position - if new_position && - new_position != old_position && - new_position.type == old_position.type + position = results[:position] + outdated = results[:outdated] - note.position = new_position - end + if outdated + note.change_position = position - note + if note.persisted? && current_user + SystemNoteService.diff_discussion_outdated(note.to_discussion, project, current_user, position) + end + else + note.position = position + note.change_position = nil + end end private def tracer @tracer ||= Gitlab::Diff::PositionTracer.new( - repository: project.repository, + project: project, old_diff_refs: params[:old_diff_refs], new_diff_refs: params[:new_diff_refs], paths: params[:paths] diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 93bf1fb1615..0837c07e6aa 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -258,7 +258,7 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end - def self.resolve_all_discussions(merge_request, project, author) + def resolve_all_discussions(merge_request, project, author) body = "resolved all discussions" create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion')) @@ -274,6 +274,28 @@ module SystemNoteService note end + def diff_discussion_outdated(discussion, project, author, change_position) + merge_request = discussion.noteable + diff_refs = change_position.diff_refs + version_index = merge_request.merge_request_diffs.viewable.count + + body = "changed this line in" + if version_params = merge_request.version_params_for(diff_refs) + line_code = change_position.line_code(project.repository) + url = url_helpers.diffs_namespace_project_merge_request_url(project.namespace, project, merge_request, version_params.merge(anchor: line_code)) + + body << " [version #{version_index} of the diff](#{url})" + else + body << " version #{version_index} of the diff" + end + + note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) + note = Note.create(note_attributes.merge(system: true)) + note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated') + + note + end + # Called when the title of a Noteable is changed # # noteable - Noteable object that responds to `title` diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb index d992b0c3725..8d4d7180baf 100644 --- a/app/validators/dynamic_path_validator.rb +++ b/app/validators/dynamic_path_validator.rb @@ -6,199 +6,26 @@ # Values are checked for formatting and exclusion from a list of reserved path # names. class DynamicPathValidator < ActiveModel::EachValidator - # All routes that appear on the top level must be listed here. - # This will make sure that groups cannot be created with these names - # as these routes would be masked by the paths already in place. - # - # Example: - # /api/api-project - # - # the path `api` shouldn't be allowed because it would be masked by `api/*` - # - TOP_LEVEL_ROUTES = %w[ - - - .well-known - abuse_reports - admin - all - api - assets - autocomplete - ci - dashboard - explore - files - groups - health_check - help - hooks - import - invites - issues - jwt - koding - member - merge_requests - new - notes - notification_settings - oauth - profile - projects - public - repository - robots.txt - s - search - sent_notifications - services - snippets - teams - u - unicorn_test - unsubscribes - uploads - users - ].freeze - - # This list should contain all words following `/*namespace_id/:project_id` in - # routes that contain a second wildcard. - # - # Example: - # /*namespace_id/:project_id/badges/*ref/build - # - # If `badges` was allowed as a project/group name, we would not be able to access the - # `badges` route for those projects: - # - # Consider a namespace with path `foo/bar` and a project called `badges`. - # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg` - # - # When accessing this path the route would be matched to the `badges` path - # with the following params: - # - namespace_id: `foo` - # - project_id: `bar` - # - ref: `badges/master` - # - # Failing to find the project, this would result in a 404. - # - # By rejecting `badges` the router can _count_ on the fact that `badges` will - # be preceded by the `namespace/project`. - WILDCARD_ROUTES = %w[ - badges - blame - blob - builds - commits - create - create_dir - edit - environments/folders - files - find_file - gitlab-lfs/objects - info/lfs/objects - new - preview - raw - refs - tree - update - wikis - ].freeze - - # These are all the paths that follow `/groups/*id/ or `/groups/*group_id` - # We need to reject these because we have a `/groups/*id` page that is the same - # as the `/*id`. - # - # If we would allow a subgroup to be created with the name `activity` then - # this group would not be accessible through `/groups/parent/activity` since - # this would map to the activity-page of it's parent. - GROUP_ROUTES = %w[ - activity - analytics - audit_events - avatar - edit - group_members - hooks - issues - labels - ldap - ldap_group_links - merge_requests - milestones - notification_setting - pipeline_quota - projects - subgroups - ].freeze - - CHILD_ROUTES = (WILDCARD_ROUTES | GROUP_ROUTES).freeze - - def self.without_reserved_wildcard_paths_regex - @without_reserved_wildcard_paths_regex ||= regex_excluding_child_paths(WILDCARD_ROUTES) - end - - def self.without_reserved_child_paths_regex - @without_reserved_child_paths_regex ||= regex_excluding_child_paths(CHILD_ROUTES) - end - - # This is used to validate a full path. - # It doesn't match paths - # - Starting with one of the top level words - # - Containing one of the child level words in the middle of a path - def self.regex_excluding_child_paths(child_routes) - reserved_top_level_words = Regexp.union(TOP_LEVEL_ROUTES) - not_starting_in_reserved_word = %r{\A/?(?!(#{reserved_top_level_words})(/|\z))} - - reserved_child_level_words = Regexp.union(child_routes) - not_containing_reserved_child = %r{(?!\S+/(#{reserved_child_level_words})(/|\z))} - - %r{#{not_starting_in_reserved_word} - #{not_containing_reserved_child} - #{Gitlab::Regex.full_namespace_regex}}x - end - - def self.valid?(path) - path =~ Gitlab::Regex.full_namespace_regex && !full_path_reserved?(path) - end - - def self.full_path_reserved?(path) - path = path.to_s.downcase - _project_part, namespace_parts = path.reverse.split('/', 2).map(&:reverse) - - wildcard_reserved?(path) || child_reserved?(namespace_parts) - end - - def self.child_reserved?(path) - return false unless path - - path !~ without_reserved_child_paths_regex - end - - def self.wildcard_reserved?(path) - return false unless path + class << self + def valid_namespace_path?(path) + "#{path}/" =~ Gitlab::Regex.full_namespace_path_regex + end - path !~ without_reserved_wildcard_paths_regex + def valid_project_path?(path) + "#{path}/" =~ Gitlab::Regex.full_project_path_regex + end end - delegate :full_path_reserved?, - :child_reserved?, - to: :class - - def path_reserved_for_record?(record, value) + def path_valid_for_record?(record, value) full_path = record.respond_to?(:full_path) ? record.full_path : value - # For group paths the entire path cannot contain a reserved child word - # The path doesn't contain the last `_project_part` so we need to validate - # if the entire path. - # Example: - # A *group* with full path `parent/activity` is reserved. - # A *project* with full path `parent/activity` is allowed. - if record.is_a? Group - child_reserved?(full_path) + return true unless full_path + + case record + when Project + self.class.valid_project_path?(full_path) else - full_path_reserved?(full_path) + self.class.valid_namespace_path?(full_path) end end @@ -208,7 +35,7 @@ class DynamicPathValidator < ActiveModel::EachValidator return end - if path_reserved_for_record?(record, value) + unless path_valid_for_record?(record, value) record.errors.add(attribute, "#{value} is a reserved name") end end diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml index ae918086a57..c7b63d9de98 100644 --- a/app/views/admin/requests_profiles/index.html.haml +++ b/app/views/admin/requests_profiles/index.html.haml @@ -20,7 +20,7 @@ %ul.content-list - profiles.each do |profile| %li - = link_to profile.time.to_s(:long), admin_requests_profile_path(profile), data: {no_turbolink: true} + = link_to profile.time.to_s(:long), admin_requests_profile_path(profile) - else %p No profiles found diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 74992e439f3..578e751ab47 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -32,10 +32,9 @@ - elsif discussion.diff_discussion? on = conditional_link_to url.present?, url do - - if discussion.active? - the diff - - else - an outdated diff + - unless discussion.active? + an old version of + the diff = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") = render "discussions/headline", discussion: discussion diff --git a/app/views/profiles/accounts/_reset_token.html.haml b/app/views/profiles/accounts/_reset_token.html.haml new file mode 100644 index 00000000000..c31a4a8ecd4 --- /dev/null +++ b/app/views/profiles/accounts/_reset_token.html.haml @@ -0,0 +1,11 @@ +- name = label.parameterize +- attribute = name.underscore + +.reset-action + %p.cgray + = label_tag name, label, class: "label-light" + = text_field_tag name, current_user.send(attribute), class: 'form-control', readonly: true, onclick: 'this.select()' + %p.help-block + = help_text + .prepend-top-default + = link_to button_label, [:reset, attribute, :profile], method: :put, data: { confirm: 'Are you sure?' }, class: 'btn btn-default private-token' diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 73f33e69d68..a319b18e507 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -8,35 +8,17 @@ .row.prepend-top-default .col-lg-3.profile-settings-sidebar %h4.prepend-top-0 - = incoming_email_token_enabled? ? "Private Tokens" : "Private Token" + Private Tokens %p - Keep - = incoming_email_token_enabled? ? "these tokens" : "this token" - secret, anyone with access to them can interact with GitLab as if they were you. + Keep these tokens secret, anyone with access to them can interact with + GitLab as if they were you. .col-lg-9.private-tokens-reset - .reset-action - %p.cgray - - if current_user.private_token - = label_tag "private-token", "Private token", class: "label-light" - = text_field_tag "private-token", current_user.private_token, class: "form-control", readonly: true, onclick: "this.select()" - - else - %span You don't have one yet. Click generate to fix it. - %p.help-block - Your private token is used to access the API and Atom feeds without username/password authentication. - .prepend-top-default - - if current_user.private_token - = link_to 'Reset private token', reset_private_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default private-token" - - else - = f.submit 'Generate', class: "btn btn-default" + = render partial: 'reset_token', locals: { label: 'Private token', button_label: 'Reset private token', help_text: 'Your private token is used to access the API and Atom feeds without username/password authentication.' } + + = render partial: 'reset_token', locals: { label: 'RSS token', button_label: 'Reset RSS token', help_text: 'Your RSS token is used to create urls for personalized RSS feeds.' } + - if incoming_email_token_enabled? - .reset-action - %p.cgray - = label_tag "incoming-email-token", "Incoming Email Token", class: 'label-light' - = text_field_tag "incoming-email-token", current_user.incoming_email_token, class: "form-control", readonly: true, onclick: "this.select()" - %p.help-block - Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses. - .prepend-top-default - = link_to 'Reset incoming email token', reset_incoming_email_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default incoming-email-token" + = render partial: 'reset_token', locals: { label: 'Incoming email token', button_label: 'Reset incoming email token', help_text: 'Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses.' } %hr .row.prepend-top-default diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 99690e6b98a..0ff19b3eab1 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -37,9 +37,9 @@ = f.select :dashboard, dashboard_choices, {}, class: 'form-control' .form-group = f.label :project_view, class: 'label-light' do - Project view + Project home page content = f.select :project_view, project_view_choices, {}, class: 'form-control' .help-block - Choose what content you want to see on a project's home page. + Choose what content you want to see on a project’s home page .form-group = f.submit 'Save changes', class: 'btn btn-save' diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 0fd19780570..9a9fca78df3 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -24,7 +24,7 @@ = render 'projects/buttons/fork' %span.hidden-xs - - if @project.feature_available?(:repository, current_user) + - if can?(current_user, :download_code, @project) .project-clone-holder = render "shared/clone_panel" diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index e796920ac82..a190a8760ef 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -36,7 +36,7 @@ = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.') - if retried - = icon('spinner', class: 'text-warning has-tooltip', title: 'Job was retried') + = icon('refresh', class: 'text-warning has-tooltip', title: 'Job was retried') .label-container - if job.tags.any? diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 6051ea2f1ce..3a1be3fa4b6 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -13,7 +13,7 @@ .block-connector = render "projects/diffs/diffs", diffs: @diffs, environment: @environment - = render "shared/notes/notes_with_form" + = render "shared/notes/notes_with_form", :autocomplete => true - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| = render "projects/commit/change", type: type, commit: @commit, title: @commit.title diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 4dfda54feb5..8b095f4ca10 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,7 +1,7 @@ - content_for :note_actions do - if can?(current_user, :update_issue, @issue) - = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {no_turbolink: true, original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' #notes - = render 'shared/notes/notes_with_form' + = render 'shared/notes/notes_with_form', :autocomplete => true diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 82d8e4d769b..67403c36d7f 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -2,6 +2,8 @@ - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_description @issue.description - page_card_attributes @issue.card_attributes +- can_update_issue = can?(current_user, :update_issue, @issue) +- can_report_spam = @issue.submittable_as_spam_by?(current_user) .clearfix.detail-page-header .issuable-header @@ -27,27 +29,29 @@ = icon('caret-down') .dropdown-menu.dropdown-menu-align-right.hidden-lg %ul - %li - = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link' - - if can?(current_user, :update_issue, @issue) + - if can_update_issue %li - = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) %li - = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li - = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) - - if @issue.submittable_as_spam_by?(current_user) + = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + - if can_report_spam %li = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' + - if can_update_issue || can_report_spam + %li.divider + %li + = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link' + - if can_update_issue + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' + = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + - if can_report_spam + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do New issue - - if can?(current_user, :update_issue, @issue) - = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - - if @issue.submittable_as_spam_by?(current_user) - = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' - = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' .issue-details.issuable-details .detail-page-description.content-block diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index 2e6420db212..b787edb3427 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -8,4 +8,4 @@ %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } {{ buttonText }} -#notes= render "shared/notes/notes_with_form" +#notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml index 37117bc64a3..0999b95c9c9 100644 --- a/app/views/projects/merge_requests/show/_versions.html.haml +++ b/app/views/projects/merge_requests/show/_versions.html.haml @@ -91,7 +91,7 @@ comparing two versions - else viewing an old version - of this merge request. + of the diff. .pull-right = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 2cd82e1b661..082a6bcbb2a 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -15,7 +15,7 @@ None %td.next-run-cell - if pipeline_schedule.active? - = time_ago_with_tooltip(pipeline_schedule.next_run_at) + = time_ago_with_tooltip(pipeline_schedule.real_next_run) - else Inactive %td diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index aab1c043e66..847f3c2f348 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -9,4 +9,4 @@ .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes= render "shared/notes/notes_with_form" + #notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/shared/icons/_icon_status_skipped.svg b/app/views/shared/icons/_icon_status_skipped.svg index 1998dfef9ea..a9ba29c922c 100755 --- a/app/views/shared/icons/_icon_status_skipped.svg +++ b/app/views/shared/icons/_icon_status_skipped.svg @@ -1 +1 @@ -<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M7.69 7.7l-.905.905a.7.7 0 0 0 .99.99l1.85-1.85c.411-.412.411-1.078 0-1.49l-1.85-1.85a.7.7 0 0 0-.99.99l.905.905H4.48a.7.7 0 0 0 0 1.4h3.21z"/></g></svg> +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M7 14A7 7 0 1 1 7 0a7 7 0 0 1 0 14z"/><path d="M7 13A6 6 0 1 0 7 1a6 6 0 0 0 0 12z" fill="#FFF" fill-rule="nonzero"/><path d="M6.415 7.04L4.579 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L5.341 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L6.415 7.04zm2.54 0L7.119 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L7.881 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L8.955 7.04z"/></svg> diff --git a/app/views/shared/icons/_icon_status_skipped_borderless.svg b/app/views/shared/icons/_icon_status_skipped_borderless.svg index fb3e930b3cb..3c8a26d7f4d 100644 --- a/app/views/shared/icons/_icon_status_skipped_borderless.svg +++ b/app/views/shared/icons/_icon_status_skipped_borderless.svg @@ -1 +1 @@ -<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.0846,12.1 L10.6623,13.5223 C10.2454306,13.9539168 10.2513924,14.6399933 10.6756996,15.0643004 C11.1000067,15.4886076 11.7860832,15.4945694 12.2177,15.0777 L15.1261,12.1693 C15.7708612,11.5230891 15.7708612,10.4769109 15.1261,9.8307 L12.2177,6.9223 C11.7860832,6.50543057 11.1000067,6.51139239 10.6756996,6.93569957 C10.2513924,7.36000675 10.2454306,8.04608322 10.6623,8.4777 L12.0846,9.9 L7.04,9.9 C6.43248678,9.9 5.94,10.3924868 5.94,11 C5.94,11.6075132 6.43248678,12.1 7.04,12.1 L12.0846,12.1 L12.0846,12.1 Z" id="Shape"></path></svg> +<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path d="M14.072 11.063l-2.82 2.82a.46.46 0 0 0-.001.652l.495.495a.457.457 0 0 0 .653-.001l3.7-3.7a.46.46 0 0 0 .001-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.479-3.479a.464.464 0 0 0-.654.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/><path d="M10.08 11.063l-2.819 2.82a.46.46 0 0 0-.002.652l.496.495a.457.457 0 0 0 .652-.001l3.7-3.7a.46.46 0 0 0 .002-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.48-3.479a.464.464 0 0 0-.653.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/></svg> diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 80974bdb066..d36707dd042 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -45,7 +45,7 @@ {{hint}} %span.js-filter-tag.dropdown-light-content {{tag}} - #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } + #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item %button.btn.btn-link.dropdown-user @@ -55,7 +55,7 @@ {{name}} %span.dropdown-light-content @{{username}} - #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } + #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 @@ -70,7 +70,7 @@ {{name}} %span.dropdown-light-content @{{username}} - #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } + #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 @@ -86,7 +86,7 @@ %li.filter-dropdown-item %button.btn.btn-link.js-data-value {{title}} - #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } + #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 diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index e9ce7b7ce9c..26567c08eb6 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,5 +1,8 @@ - if issuable.is_a?(Issue) #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } + .title.hide-collapsed + Assignee + = icon('spinner spin') - else .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } - if issuable.assignee diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 05bb1970e21..785b1b22a49 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -23,4 +23,4 @@ to post a comment :javascript - var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}", false) + var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}", #{autocomplete}) diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 51dbbc32cc9..216184eb839 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -9,4 +9,4 @@ .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes= render "shared/notes/notes_with_form" + #notes= render "shared/notes/notes_with_form", :autocomplete => false diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 2b70d70e360..c587155bc4f 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -71,7 +71,7 @@ = @user.location - unless @user.organization.blank? .profile-link-holder.middle-dot-divider - = icon('building') + = icon('briefcase') = @user.organization - if @user.bio.present? diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb new file mode 100644 index 00000000000..08e281e7350 --- /dev/null +++ b/app/workers/expire_job_cache_worker.rb @@ -0,0 +1,35 @@ +class ExpireJobCacheWorker + include Sidekiq::Worker + include BuildQueue + + def perform(job_id) + job = CommitStatus.joins(:pipeline, :project).find_by(id: job_id) + return unless job + + pipeline = job.pipeline + project = job.project + + Gitlab::EtagCaching::Store.new.tap do |store| + store.touch(project_pipeline_path(project, pipeline)) + store.touch(project_job_path(project, job)) + end + end + + private + + def project_pipeline_path(project, pipeline) + Gitlab::Routing.url_helpers.namespace_project_pipeline_path( + project.namespace, + project, + pipeline, + format: :json) + end + + def project_job_path(project, job) + Gitlab::Routing.url_helpers.namespace_project_build_path( + project.namespace, + project, + job.id, + format: :json) + end +end diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 603e2f1aaea..d760f5b140f 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -10,6 +10,7 @@ class ExpirePipelineCacheWorker store = Gitlab::EtagCaching::Store.new store.touch(project_pipelines_path(project)) + store.touch(project_pipeline_path(project, pipeline)) store.touch(commit_pipelines_path(project, pipeline.commit)) if pipeline.commit store.touch(new_merge_request_pipelines_path(project)) each_pipelines_merge_request_path(project, pipeline) do |path| @@ -28,6 +29,14 @@ class ExpirePipelineCacheWorker format: :json) end + def project_pipeline_path(project, pipeline) + Gitlab::Routing.url_helpers.namespace_project_pipeline_path( + project.namespace, + project, + pipeline, + format: :json) + end + def commit_pipelines_path(project, commit) Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path( project.namespace, |