diff options
Diffstat (limited to 'app')
760 files changed, 10158 insertions, 5769 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index d1396b6c4bc..85eb08cc97d 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,6 +5,7 @@ import axios from './lib/utils/axios_utils'; const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', + groupMembersPath: '/api/:version/groups/:id/members', subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', @@ -40,6 +41,12 @@ const Api = { }); }, + groupMembers(id) { + const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url); + }, + // Return groups list. Filtered by query groups(query, options, callback = $.noop) { const url = Api.buildUrl(Api.groupsPath); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index cace8bb9dba..73ce3e760ab 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -437,7 +437,7 @@ export class AwardsHandler { createAwardButtonForVotesBlock(votesBlock, emojiName) { const buttonHtml = ` - <button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom"> + <button class="btn award-control js-emoji-btn has-tooltip active" title="You"> ${this.emoji.glEmojiTag(emojiName)} <span class="award-control-text js-counter">1</span> </button> diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 85a15b38de1..df74eb2c2f7 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -90,7 +90,7 @@ export default { }, badgeImageUrlExample() { const exampleUrl = - 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/badge.svg'; + 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/pipeline.svg'; return sprintf(s__('Badges|e.g. %{exampleUrl}'), { exampleUrl, }); diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 56293d5f96f..d1d75658181 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -1,11 +1,10 @@ -import installCustomElements from 'document-register-element'; +import 'document-register-element'; import isEmojiUnicodeSupported from '../emoji/support'; -installCustomElements(window); +class GlEmoji extends HTMLElement { + constructor() { + super(); -export default function installGlEmojiElement() { - const GlEmojiElementProto = Object.create(HTMLElement.prototype); - GlEmojiElementProto.createdCallback = function createdCallback() { const emojiUnicode = this.textContent.trim(); const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset; @@ -43,9 +42,11 @@ export default function installGlEmojiElement() { }); } } - }; + } +} - document.registerElement('gl-emoji', { - prototype: GlEmojiElementProto, - }); +export default function installGlEmojiElement() { + if (!customElements.get('gl-emoji')) { + customElements.define('gl-emoji', GlEmoji); + } } diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index fe02096d903..9482a9f166d 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -1,320 +1,5 @@ -/* eslint-disable object-shorthand, no-unused-vars, no-use-before-define, no-restricted-syntax, guard-for-in, no-continue */ - import $ from 'jquery'; -import _ from 'underscore'; -import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils'; -import { placeholderImage } from '~/lazy_loader'; - -const gfmRules = { - // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert - // GitLab Flavored Markdown (GFM) to HTML. - // These handlers consequently convert that same HTML to GFM to be copied to the clipboard. - // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML - // from GFM should have a handler here, in reverse order. - // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. - InlineDiffFilter: { - 'span.idiff.addition'(el, text) { - return `{+${text}+}`; - }, - 'span.idiff.deletion'(el, text) { - return `{-${text}-}`; - }, - }, - TaskListFilter: { - 'input[type=checkbox].task-list-item-checkbox'(el) { - return `[${el.checked ? 'x' : ' '}]`; - }, - }, - ReferenceFilter: { - '.tooltip'(el) { - return ''; - }, - 'a.gfm:not([data-link=true])'(el, text) { - return el.dataset.original || text; - }, - }, - AutolinkFilter: { - a(el, text) { - // Fallback on the regular MarkdownFilter's `a` handler. - if (text !== el.getAttribute('href')) return false; - - return text; - }, - }, - TableOfContentsFilter: { - 'ul.section-nav'(el) { - return '[[_TOC_]]'; - }, - }, - EmojiFilter: { - 'img.emoji'(el) { - return el.getAttribute('alt'); - }, - 'gl-emoji'(el) { - return `:${el.getAttribute('data-name')}:`; - }, - }, - ImageLinkFilter: { - 'a.no-attachment-icon'(el, text) { - return text; - }, - }, - ImageLazyLoadFilter: { - img(el, text) { - return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; - }, - }, - VideoLinkFilter: { - '.video-container'(el) { - const videoEl = el.querySelector('video'); - if (!videoEl) return false; - - return CopyAsGFM.nodeToGFM(videoEl); - }, - video(el) { - return `![${el.dataset.title}](${el.getAttribute('src')})`; - }, - }, - MermaidFilter: { - 'svg.mermaid'(el, text) { - const sourceEl = el.querySelector('text.source'); - if (!sourceEl) return false; - - return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``; - }, - 'svg.mermaid style, svg.mermaid g'(el, text) { - // We don't want to include the content of these elements in the copied text. - return ''; - }, - }, - MathFilter: { - 'pre.code.math[data-math-style=display]'(el, text) { - return `\`\`\`math\n${text.trim()}\n\`\`\``; - }, - 'code.code.math[data-math-style=inline]'(el, text) { - return `$\`${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) { - const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); - if (!mathAnnotation) return false; - - return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; - }, - 'span.katex-html'(el) { - // We don't want to include the content of this element in the copied text. - return ''; - }, - 'annotation[encoding="application/x-tex"]'(el, text) { - return text.trim(); - }, - }, - SanitizationFilter: { - 'a[name]:not([href]):empty'(el) { - return el.outerHTML; - }, - dl(el, text) { - let lines = text - .replace(/\n\n/g, '\n') - .trim() - .split('\n'); - // Add two spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - lines = lines.map(l => { - const line = l.trim(); - if (line.length === 0) return ''; - - return ` ${line}`; - }); - - return `<dl>\n${lines.join('\n')}\n</dl>\n`; - }, - 'dt, dd, summary, details'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}</${tag}>\n`; - }, - 'sup, sub, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}</${tag}>`; - }, - }, - SyntaxHighlightFilter: { - 'pre.code.highlight'(el, t) { - const text = t.trimRight(); - - let lang = el.getAttribute('lang'); - if (!lang || lang === 'plaintext') { - lang = ''; - } - - // Prefixes lines with 4 spaces if the code contains triple backticks - if (lang === '' && text.match(/^```/gm)) { - return text - .split('\n') - .map(l => { - const line = l.trim(); - if (line.length === 0) return ''; - - return ` ${line}`; - }) - .join('\n'); - } - - return `\`\`\`${lang}\n${text}\n\`\`\``; - }, - 'pre > code'(el, text) { - // Don't wrap code blocks in `` - return text; - }, - }, - MarkdownFilter: { - br(el) { - // Two spaces at the end of a line are turned into a BR - return ' '; - }, - code(el, text) { - let backtickCount = 1; - const backtickMatch = text.match(/`+/); - if (backtickMatch) { - backtickCount = backtickMatch[0].length + 1; - } - - const backticks = Array(backtickCount + 1).join('`'); - const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; - - return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks; - }, - blockquote(el, text) { - return text - .trim() - .split('\n') - .map(s => `> ${s}`.trim()) - .join('\n'); - }, - img(el) { - const imageSrc = el.src; - const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || ''; - return `![${el.getAttribute('alt')}](${imageUrl})`; - }, - 'a.anchor'(el, text) { - // Don't render a Markdown link for the anchor link inside a heading - return text; - }, - a(el, text) { - return `[${text}](${el.getAttribute('href')})`; - }, - li(el, text) { - const lines = text.trim().split('\n'); - const firstLine = `- ${lines.shift()}`; - // Add four spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - const nextLines = lines.map(s => { - if (s.trim().length === 0) return ''; - - return ` ${s}`; - }); - - return `${firstLine}\n${nextLines.join('\n')}`; - }, - ul(el, text) { - return text; - }, - ol(el, text) { - // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists. - return text.replace(/^- /gm, '1. '); - }, - h1(el, text) { - return `# ${text.trim()}\n`; - }, - h2(el, text) { - return `## ${text.trim()}\n`; - }, - h3(el, text) { - return `### ${text.trim()}\n`; - }, - h4(el, text) { - return `#### ${text.trim()}\n`; - }, - h5(el, text) { - return `##### ${text.trim()}\n`; - }, - h6(el, text) { - return `###### ${text.trim()}\n`; - }, - strong(el, text) { - return `**${text}**`; - }, - em(el, text) { - return `_${text}_`; - }, - del(el, text) { - return `~~${text}~~`; - }, - hr(el) { - // extra leading \n is to ensure that there is a blank line between - // a list followed by an hr, otherwise this breaks old redcarpet rendering - return '\n-----\n'; - }, - p(el, text) { - return `${text.trim()}\n`; - }, - table(el) { - const theadEl = el.querySelector('thead'); - const tbodyEl = el.querySelector('tbody'); - if (!theadEl || !tbodyEl) return false; - - const theadText = CopyAsGFM.nodeToGFM(theadEl); - const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); - - return [theadText, tbodyText].join('\n'); - }, - thead(el, text) { - const cells = _.map(el.querySelectorAll('th'), cell => { - let chars = CopyAsGFM.nodeToGFM(cell).length + 2; - - let before = ''; - let after = ''; - const alignment = cell.align || cell.style.textAlign; - - switch (alignment) { - case 'center': - before = ':'; - after = ':'; - chars -= 2; - break; - case 'right': - after = ':'; - chars -= 1; - break; - default: - break; - } - - chars = Math.max(chars, 3); - - const middle = Array(chars + 1).join('-'); - - return before + middle + after; - }); - - const separatorRow = `|${cells.join('|')}|`; - - return [text, separatorRow].join('\n'); - }, - 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(' | ')} |`; - }, - }, -}; +import { getSelectedFragment } from '~/lib/utils/common_utils'; export class CopyAsGFM { constructor() { @@ -347,8 +32,24 @@ export class CopyAsGFM { e.preventDefault(); e.stopPropagation(); + const div = document.createElement('div'); + div.appendChild(el.cloneNode(true)); + const html = div.innerHTML; + clipboardData.setData('text/plain', el.textContent); - clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); + clipboardData.setData('text/html', html); + // We are also setting this as fallback to transform the selection to gfm on paste + clipboardData.setData('text/x-gfm-html', html); + + CopyAsGFM.nodeToGFM(el) + .then(res => { + clipboardData.setData('text/x-gfm', res); + }) + .catch(() => { + // Not showing the error as Firefox might doesn't allow + // it or other browsers who have a time limit on the execution + // of the copy event + }); } static pasteGFM(e) { @@ -357,11 +58,28 @@ export class CopyAsGFM { const text = clipboardData.getData('text/plain'); const gfm = clipboardData.getData('text/x-gfm'); - if (!gfm) return; + const gfmHtml = clipboardData.getData('text/x-gfm-html'); + if (!gfm && !gfmHtml) return; e.preventDefault(); - window.gl.utils.insertText(e.target, (textBefore, textAfter) => { + // We have the original selection already converted to gfm + if (gfm) { + CopyAsGFM.insertPastedText(e.target, text, gfm); + } else { + // Due to the async copy call we are not able to produce gfm so we transform the cached HTML + const div = document.createElement('div'); + div.innerHTML = gfmHtml; + CopyAsGFM.nodeToGFM(div) + .then(transformedGfm => { + CopyAsGFM.insertPastedText(e.target, text, transformedGfm); + }) + .catch(() => {}); + } + } + + static insertPastedText(target, text, gfm) { + window.gl.utils.insertText(target, textBefore => { // 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. @@ -443,75 +161,22 @@ export class CopyAsGFM { return codeElement; } - static nodeToGFM(node, respectWhitespaceParam = false) { - if (node.nodeType === Node.COMMENT_NODE) { - return ''; - } - - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent; - } - - const respectWhitespace = - respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE'); - - const text = this.innerGFM(node, respectWhitespace); - - if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - return text; - } - - for (const filter in gfmRules) { - const rules = gfmRules[filter]; - - for (const selector in rules) { - const func = rules[selector]; - - if (!nodeMatchesSelector(node, selector)) continue; - - 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; - } - } - - return text; - } - - static innerGFM(parentNode, respectWhitespace = false) { - const nodes = parentNode.childNodes; - - const clonedParentNode = parentNode.cloneNode(true); - const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0); - - for (let i = 0; i < nodes.length; i += 1) { - const node = nodes[i]; - const clonedNode = clonedNodes[i]; - - const text = this.nodeToGFM(node, respectWhitespace); - - // `clonedNode.replaceWith(text)` is not yet widely supported - clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); - } - - let nodeText = clonedParentNode.innerText || clonedParentNode.textContent; - - if (!respectWhitespace) { - nodeText = nodeText.trim(); - } - - return nodeText; + static nodeToGFM(node) { + return Promise.all([ + import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'), + import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'), + import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'), + ]) + .then(([prosemirrorModel, schema, markdownSerializer]) => { + const { DOMParser } = prosemirrorModel; + const wrapEl = document.createElement('div'); + wrapEl.appendChild(node.cloneNode(true)); + const doc = DOMParser.fromSchema(schema.default).parse(wrapEl); + + const res = markdownSerializer.default.serialize(doc); + return res; + }) + .catch(() => {}); } } diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js new file mode 100644 index 00000000000..47e5fc65c48 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js @@ -0,0 +1,106 @@ +import Doc from './nodes/doc'; +import Paragraph from './nodes/paragraph'; +import Text from './nodes/text'; + +import Blockquote from './nodes/blockquote'; +import CodeBlock from './nodes/code_block'; +import HardBreak from './nodes/hard_break'; +import Heading from './nodes/heading'; +import HorizontalRule from './nodes/horizontal_rule'; +import Image from './nodes/image'; + +import Table from './nodes/table'; +import TableHead from './nodes/table_head'; +import TableBody from './nodes/table_body'; +import TableHeaderRow from './nodes/table_header_row'; +import TableRow from './nodes/table_row'; +import TableCell from './nodes/table_cell'; + +import Emoji from './nodes/emoji'; +import Reference from './nodes/reference'; + +import TableOfContents from './nodes/table_of_contents'; +import Video from './nodes/video'; + +import BulletList from './nodes/bullet_list'; +import OrderedList from './nodes/ordered_list'; +import ListItem from './nodes/list_item'; + +import DescriptionList from './nodes/description_list'; +import DescriptionTerm from './nodes/description_term'; +import DescriptionDetails from './nodes/description_details'; + +import TaskList from './nodes/task_list'; +import OrderedTaskList from './nodes/ordered_task_list'; +import TaskListItem from './nodes/task_list_item'; + +import Summary from './nodes/summary'; +import Details from './nodes/details'; + +import Bold from './marks/bold'; +import Italic from './marks/italic'; +import Strike from './marks/strike'; +import InlineDiff from './marks/inline_diff'; + +import Link from './marks/link'; +import Code from './marks/code'; +import MathMark from './marks/math'; +import InlineHTML from './marks/inline_html'; + +// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform +// GitLab Flavored Markdown (GFM) to HTML. +// The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard. +// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML +// from GFM should have a node or mark here. +// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. + +export default [ + new Doc(), + new Paragraph(), + new Text(), + + new Blockquote(), + new CodeBlock(), + new HardBreak(), + new Heading({ maxLevel: 6 }), + new HorizontalRule(), + new Image(), + + new Table(), + new TableHead(), + new TableBody(), + new TableHeaderRow(), + new TableRow(), + new TableCell(), + + new Emoji(), + new Reference(), + + new TableOfContents(), + new Video(), + + new BulletList(), + new OrderedList(), + new ListItem(), + + new DescriptionList(), + new DescriptionTerm(), + new DescriptionDetails(), + + new TaskList(), + new OrderedTaskList(), + new TaskListItem(), + + new Summary(), + new Details(), + + new Bold(), + new Italic(), + new Strike(), + new InlineDiff(), + + new Link(), + new Code(), + new MathMark(), + new InlineHTML(), +]; diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js new file mode 100644 index 00000000000..b537954c1cb --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { Bold as BaseBold } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Bold extends BaseBold { + get toMarkdown() { + return defaultMarkdownSerializer.marks.strong; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js new file mode 100644 index 00000000000..a760ee80dd0 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/code.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { Code as BaseCode } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Code extends BaseCode { + get toMarkdown() { + return defaultMarkdownSerializer.marks.code; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js new file mode 100644 index 00000000000..ce425e80cd3 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js @@ -0,0 +1,41 @@ +/* eslint-disable class-methods-use-this */ + +import { Mark } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter +export default class InlineDiff extends Mark { + get name() { + return 'inline_diff'; + } + + get schema() { + return { + attrs: { + addition: { + default: true, + }, + }, + parseDOM: [ + { tag: 'span.idiff.addition', attrs: { addition: true } }, + { tag: 'span.idiff.deletion', attrs: { addition: false } }, + ], + toDOM: node => [ + 'span', + { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` }, + 0, + ], + }; + } + + get toMarkdown() { + return { + mixable: true, + open(state, mark) { + return mark.attrs.addition ? '{+' : '{-'; + }, + close(state, mark) { + return mark.attrs.addition ? '+}' : '-}'; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js new file mode 100644 index 00000000000..ebed8698e21 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js @@ -0,0 +1,46 @@ +/* eslint-disable class-methods-use-this */ + +import { Mark } from 'tiptap'; +import _ from 'underscore'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class InlineHTML extends Mark { + get name() { + return 'inline_html'; + } + + get schema() { + return { + excludes: '', + attrs: { + tag: {}, + title: { default: null }, + }, + parseDOM: [ + { + tag: 'sup, sub, kbd, q, samp, var', + getAttrs: el => ({ tag: el.nodeName.toLowerCase() }), + }, + { + tag: 'abbr', + getAttrs: el => ({ tag: 'abbr', title: el.getAttribute('title') }), + }, + ], + toDOM: node => [node.attrs.tag, { title: node.attrs.title }, 0], + }; + } + + get toMarkdown() { + return { + mixable: true, + open(state, mark) { + return `<${mark.attrs.tag}${ + mark.attrs.title ? ` title="${state.esc(_.escape(mark.attrs.title))}"` : '' + }>`; + }, + close(state, mark) { + return `</${mark.attrs.tag}>`; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js new file mode 100644 index 00000000000..44b35c97739 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { Italic as BaseItalic } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Italic extends BaseItalic { + get toMarkdown() { + return defaultMarkdownSerializer.marks.em; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js new file mode 100644 index 00000000000..5c23d6a5ceb --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/link.js @@ -0,0 +1,21 @@ +/* eslint-disable class-methods-use-this */ + +import { Link as BaseLink } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Link extends BaseLink { + get toMarkdown() { + return { + mixable: true, + open(state, mark, parent, index) { + const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index); + return open === '<' ? '' : open; + }, + close(state, mark, parent, index) { + const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index); + return close === '>' ? '' : close; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js new file mode 100644 index 00000000000..e582fb18f15 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/math.js @@ -0,0 +1,41 @@ +/* eslint-disable class-methods-use-this */ + +import { Mark } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter +export default class MathMark extends Mark { + get name() { + return 'math'; + } + + get schema() { + return { + parseDOM: [ + // Matches HTML generated by Banzai::Filter::MathFilter + { + tag: 'code.code.math[data-math-style=inline]', + priority: 51, + }, + // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js + { + tag: 'span.katex', + contentElement: 'annotation[encoding="application/x-tex"]', + }, + ], + toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0], + }; + } + + get toMarkdown() { + return { + escape: false, + open(state, mark, parent, index) { + return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`; + }, + close(state, mark, parent, index) { + return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`; + }, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js new file mode 100644 index 00000000000..c2951a40a4b --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js @@ -0,0 +1,15 @@ +/* eslint-disable class-methods-use-this */ + +import { Strike as BaseStrike } from 'tiptap-extensions'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Strike extends BaseStrike { + get toMarkdown() { + return { + open: '~~', + close: '~~', + mixable: true, + expelEnclosingWhitespace: true, + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js new file mode 100644 index 00000000000..b0bc8f79643 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js @@ -0,0 +1,13 @@ +/* eslint-disable class-methods-use-this */ + +import { Blockquote as BaseBlockquote } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Blockquote extends BaseBlockquote { + toMarkdown(state, node) { + if (!node.childCount) return; + + defaultMarkdownSerializer.nodes.blockquote(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js new file mode 100644 index 00000000000..3b0792e1af8 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { BulletList as BaseBulletList } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class BulletList extends BaseBulletList { + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.bullet_list(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js new file mode 100644 index 00000000000..1e0c05eff08 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js @@ -0,0 +1,99 @@ +/* eslint-disable class-methods-use-this */ + +import { CodeBlock as BaseCodeBlock } from 'tiptap-extensions'; + +const PLAINTEXT_LANG = 'plaintext'; + +// Transforms generated HTML back to GFM for: +// - Banzai::Filter::SyntaxHighlightFilter +// - Banzai::Filter::MathFilter +// - Banzai::Filter::MermaidFilter +// - Banzai::Filter::SuggestionFilter +export default class CodeBlock extends BaseCodeBlock { + get schema() { + return { + content: 'text*', + marks: '', + group: 'block', + code: true, + defining: true, + attrs: { + lang: { default: PLAINTEXT_LANG }, + }, + parseDOM: [ + // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter + { + tag: 'pre.code.highlight', + preserveWhitespace: 'full', + getAttrs: el => { + const lang = el.getAttribute('lang'); + if (!lang || lang === '') return {}; + + return { lang }; + }, + }, + // Matches HTML generated by Banzai::Filter::MathFilter, + // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js + { + tag: 'span.katex-display', + preserveWhitespace: 'full', + contentElement: 'annotation[encoding="application/x-tex"]', + attrs: { lang: 'math' }, + }, + // Matches HTML generated by Banzai::Filter::MermaidFilter, + // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js + { + tag: 'svg.mermaid', + preserveWhitespace: 'full', + contentElement: 'text.source', + attrs: { lang: 'mermaid' }, + }, + // Matches HTML generated by Banzai::Filter::SuggestionFilter, + // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue + { + tag: '.md-suggestion', + skip: true, + }, + { + tag: '.md-suggestion-header', + ignore: true, + }, + { + tag: '.md-suggestion-diff', + preserveWhitespace: 'full', + getContent: (el, schema) => + [...el.querySelectorAll('.line_content.new span')].map(span => + schema.text(span.innerText), + ), + attrs: { lang: 'suggestion' }, + }, + ], + toDOM: node => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]], + }; + } + + toMarkdown(state, node) { + if (!node.childCount) return; + + const { + textContent: text, + attrs: { lang }, + } = node; + + // Prefixes lines with 4 spaces if the code contains a line that starts with triple backticks + if (lang === PLAINTEXT_LANG && text.match(/^```/gm)) { + state.wrapBlock(' ', null, node, () => state.text(text, false)); + return; + } + + state.write('```'); + if (lang !== PLAINTEXT_LANG) state.write(lang); + + state.ensureNewLine(); + state.text(text, false); + state.ensureNewLine(); + + state.write('```'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js new file mode 100644 index 00000000000..a4451d8ce8d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class DescriptionDetails extends Node { + get name() { + return 'description_details'; + } + + get schema() { + return { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'dd' }], + toDOM: () => ['dd', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(1); + state.write('<dd>'); + state.text(node.textContent, false); + state.write('</dd>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js new file mode 100644 index 00000000000..6aa1aca29d7 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class DescriptionList extends Node { + get name() { + return 'description_list'; + } + + get schema() { + return { + content: '(description_term+ description_details+)+', + group: 'block', + parseDOM: [{ tag: 'dl' }], + toDOM: () => ['dl', 0], + }; + } + + toMarkdown(state, node) { + state.write('<dl>\n'); + state.wrapBlock(' ', null, node, () => state.renderContent(node)); + state.flushClose(1); + state.ensureNewLine(); + state.write('</dl>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js new file mode 100644 index 00000000000..89057ec6444 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class DescriptionTerm extends Node { + get name() { + return 'description_term'; + } + + get schema() { + return { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'dt' }], + toDOM: () => ['dt', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(state.closed && state.closed.type === node.type ? 1 : 2); + state.write('<dt>'); + state.text(node.textContent, false); + state.write('</dt>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/details.js b/app/assets/javascripts/behaviors/markdown/nodes/details.js new file mode 100644 index 00000000000..1c40dbb8168 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/details.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Details extends Node { + get name() { + return 'details'; + } + + get schema() { + return { + content: 'summary block*', + group: 'block', + parseDOM: [{ tag: 'details' }], + toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0], + }; + } + + toMarkdown(state, node) { + state.write('<details>\n'); + state.renderContent(node); + state.flushClose(1); + state.ensureNewLine(); + state.write('</details>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/doc.js b/app/assets/javascripts/behaviors/markdown/nodes/doc.js new file mode 100644 index 00000000000..88b16fd85da --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/doc.js @@ -0,0 +1,15 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +export default class Doc extends Node { + get name() { + return 'doc'; + } + + get schema() { + return { + content: 'block+', + }; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js new file mode 100644 index 00000000000..a7cc3e828f5 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js @@ -0,0 +1,41 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::EmojiFilter +export default class Emoji extends Node { + get name() { + return 'emoji'; + } + + get schema() { + return { + inline: true, + group: 'inline', + attrs: { + name: {}, + title: {}, + moji: {}, + }, + parseDOM: [ + { + tag: 'gl-emoji', + getAttrs: el => ({ + name: el.dataset.name, + title: el.getAttribute('title'), + moji: el.textContent, + }), + }, + ], + toDOM: node => [ + 'gl-emoji', + { 'data-name': node.attrs.name, title: node.attrs.title }, + node.attrs.moji, + ], + }; + } + + toMarkdown(state, node) { + state.write(`:${node.attrs.name}:`); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js new file mode 100644 index 00000000000..59e5d8ab3e2 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js @@ -0,0 +1,10 @@ +/* eslint-disable class-methods-use-this */ + +import { HardBreak as BaseHardBreak } from 'tiptap-extensions'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class HardBreak extends BaseHardBreak { + toMarkdown(state) { + if (!state.atBlank()) state.write(' \n'); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js new file mode 100644 index 00000000000..fec8608cf5d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js @@ -0,0 +1,13 @@ +/* eslint-disable class-methods-use-this */ + +import { Heading as BaseHeading } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Heading extends BaseHeading { + toMarkdown(state, node) { + if (!node.childCount) return; + + defaultMarkdownSerializer.nodes.heading(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js new file mode 100644 index 00000000000..695c7160bde --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class HorizontalRule extends BaseHorizontalRule { + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.horizontal_rule(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js new file mode 100644 index 00000000000..c225a5ed876 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js @@ -0,0 +1,52 @@ +/* eslint-disable class-methods-use-this */ + +import { Image as BaseImage } from 'tiptap-extensions'; +import { placeholderImage } from '~/lazy_loader'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +export default class Image extends BaseImage { + get schema() { + return { + attrs: { + src: {}, + alt: { + default: null, + }, + title: { + default: null, + }, + }, + group: 'inline', + inline: true, + draggable: true, + parseDOM: [ + // Matches HTML generated by Banzai::Filter::ImageLinkFilter + { + tag: 'a.no-attachment-icon', + priority: 51, + skip: true, + }, + // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter + { + tag: 'img[src]', + getAttrs: el => { + const imageSrc = el.src; + const imageUrl = + imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || ''; + + return { + src: imageUrl, + title: el.getAttribute('title'), + alt: el.getAttribute('alt'), + }; + }, + }, + ], + toDOM: node => ['img', node.attrs], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.image(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js new file mode 100644 index 00000000000..4237637ed9a --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js @@ -0,0 +1,11 @@ +/* eslint-disable class-methods-use-this */ + +import { ListItem as BaseListItem } from 'tiptap-extensions'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class ListItem extends BaseListItem { + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.list_item(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js new file mode 100644 index 00000000000..4c1542d14ea --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js @@ -0,0 +1,10 @@ +/* eslint-disable class-methods-use-this */ + +import { OrderedList as BaseOrderedList } from 'tiptap-extensions'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class OrderedList extends BaseOrderedList { + toMarkdown(state, node) { + state.renderList(node, ' ', () => '1. '); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js new file mode 100644 index 00000000000..25c4976a1bc --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter +export default class OrderedTaskList extends Node { + get name() { + return 'ordered_task_list'; + } + + get schema() { + return { + group: 'block', + content: '(task_list_item|list_item)+', + parseDOM: [ + { + priority: 51, + tag: 'ol.task-list', + }, + ], + toDOM: () => ['ol', { class: 'task-list' }, 0], + }; + } + + toMarkdown(state, node) { + state.renderList(node, ' ', () => '1. '); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js new file mode 100644 index 00000000000..dec3207b1bb --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js @@ -0,0 +1,24 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Paragraph extends Node { + get name() { + return 'paragraph'; + } + + get schema() { + return { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.paragraph(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js new file mode 100644 index 00000000000..5d6bbeca833 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/reference.js @@ -0,0 +1,52 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses +export default class Reference extends Node { + get name() { + return 'reference'; + } + + get schema() { + return { + inline: true, + group: 'inline', + atom: true, + attrs: { + className: {}, + referenceType: {}, + originalText: { default: null }, + href: {}, + text: {}, + }, + parseDOM: [ + { + tag: 'a.gfm:not([data-link=true])', + priority: 51, + getAttrs: el => ({ + className: el.className, + referenceType: el.dataset.referenceType, + originalText: el.dataset.original, + href: el.getAttribute('href'), + text: el.textContent, + }), + }, + ], + toDOM: node => [ + 'a', + { + class: node.attrs.className, + href: node.attrs.href, + 'data-reference-type': node.attrs.referenceType, + 'data-original': node.attrs.originalText, + }, + node.attrs.text, + ], + }; + } + + toMarkdown(state, node) { + state.write(node.attrs.originalText || node.attrs.text); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/summary.js b/app/assets/javascripts/behaviors/markdown/nodes/summary.js new file mode 100644 index 00000000000..2e36e316d71 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/summary.js @@ -0,0 +1,27 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Summary extends Node { + get name() { + return 'summary'; + } + + get schema() { + return { + content: 'text*', + marks: '', + defining: true, + parseDOM: [{ tag: 'summary' }], + toDOM: () => ['summary', 0], + }; + } + + toMarkdown(state, node) { + state.write('<summary>'); + state.text(node.textContent, false); + state.write('</summary>'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table.js b/app/assets/javascripts/behaviors/markdown/nodes/table.js new file mode 100644 index 00000000000..a7fcb9227cd --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table.js @@ -0,0 +1,25 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class Table extends Node { + get name() { + return 'table'; + } + + get schema() { + return { + content: 'table_head table_body', + group: 'block', + isolating: true, + parseDOM: [{ tag: 'table' }], + toDOM: () => ['table', 0], + }; + } + + toMarkdown(state, node) { + state.renderContent(node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js new file mode 100644 index 00000000000..403556dc0c8 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js @@ -0,0 +1,24 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableBody extends Node { + get name() { + return 'table_body'; + } + + get schema() { + return { + content: 'table_row+', + parseDOM: [{ tag: 'tbody' }], + toDOM: () => ['tbody', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(1); + state.renderContent(node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js new file mode 100644 index 00000000000..c63bfe10e39 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js @@ -0,0 +1,35 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableCell extends Node { + get name() { + return 'table_cell'; + } + + get schema() { + return { + attrs: { + header: { default: false }, + align: { default: null }, + }, + content: 'inline*', + isolating: true, + parseDOM: [ + { + tag: 'td, th', + getAttrs: el => ({ + header: el.tagName === 'TH', + align: el.getAttribute('align') || el.style.textAlign, + }), + }, + ], + toDOM: node => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0], + }; + } + + toMarkdown(state, node) { + state.renderInline(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js new file mode 100644 index 00000000000..4cb94bf088c --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js @@ -0,0 +1,24 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableHead extends Node { + get name() { + return 'table_head'; + } + + get schema() { + return { + content: 'table_header_row', + parseDOM: [{ tag: 'thead' }], + toDOM: () => ['thead', 0], + }; + } + + toMarkdown(state, node) { + state.flushClose(1); + state.renderContent(node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js new file mode 100644 index 00000000000..e7eee636402 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js @@ -0,0 +1,43 @@ +/* eslint-disable class-methods-use-this */ + +import TableRow from './table_row'; + +const CENTER_ALIGN = 'center'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableHeaderRow extends TableRow { + get name() { + return 'table_header_row'; + } + + get schema() { + return { + content: 'table_cell+', + parseDOM: [ + { + tag: 'thead tr', + priority: 51, + }, + ], + toDOM: () => ['tr', 0], + }; + } + + toMarkdown(state, node) { + const cellWidths = super.toMarkdown(state, node); + + state.flushClose(1); + + state.write('|'); + node.forEach((cell, _, i) => { + if (i) state.write('|'); + + state.write(cell.attrs.align === CENTER_ALIGN ? ':' : '-'); + state.write(state.repeat('-', cellWidths[i])); + state.write(cell.attrs.align === CENTER_ALIGN || cell.attrs.align === 'right' ? ':' : '-'); + }); + state.write('|'); + + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js new file mode 100644 index 00000000000..20c7fa8a9ab --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js @@ -0,0 +1,33 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter +export default class TableOfContents extends Node { + get name() { + return 'table_of_contents'; + } + + get schema() { + return { + group: 'block', + atom: true, + parseDOM: [ + { + tag: 'ul.section-nav', + priority: 51, + }, + { + tag: 'p.table-of-contents', + priority: 51, + }, + ], + toDOM: () => ['p', { class: 'table-of-contents' }, 'Table of Contents'], + }; + } + + toMarkdown(state, node) { + state.write('[[_TOC_]]'); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js new file mode 100644 index 00000000000..5852502773a --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js @@ -0,0 +1,38 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter +export default class TableRow extends Node { + get name() { + return 'table_row'; + } + + get schema() { + return { + content: 'table_cell+', + parseDOM: [{ tag: 'tr' }], + toDOM: () => ['tr', 0], + }; + } + + toMarkdown(state, node) { + const cellWidths = []; + + state.flushClose(1); + + state.write('| '); + node.forEach((cell, _, i) => { + if (i) state.write(' | '); + + const { length } = state.out; + state.render(cell, node, i); + cellWidths.push(state.out.length - length); + }); + state.write(' |'); + + state.closeBlock(node); + + return cellWidths; + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js new file mode 100644 index 00000000000..ab33bc21502 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js @@ -0,0 +1,28 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter +export default class TaskList extends Node { + get name() { + return 'task_list'; + } + + get schema() { + return { + group: 'block', + content: '(task_list_item|list_item)+', + parseDOM: [ + { + priority: 51, + tag: 'ul.task-list', + }, + ], + toDOM: () => ['ul', { class: 'task-list' }, 0], + }; + } + + toMarkdown(state, node) { + state.renderList(node, ' ', () => '* '); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js new file mode 100644 index 00000000000..d0ee7333d5e --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js @@ -0,0 +1,49 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; + +// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter +export default class TaskListItem extends Node { + get name() { + return 'task_list_item'; + } + + get schema() { + return { + attrs: { + done: { + default: false, + }, + }, + defining: true, + draggable: false, + content: 'paragraph block*', + parseDOM: [ + { + priority: 51, + tag: 'li.task-list-item', + getAttrs: el => { + const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); + return { done: checkbox && checkbox.checked }; + }, + }, + ], + toDOM(node) { + return [ + 'li', + { class: 'task-list-item' }, + [ + 'input', + { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }, + ], + ['div', { class: 'todo-content' }, 0], + ]; + }, + }; + } + + toMarkdown(state, node) { + state.write(`[${node.attrs.done ? 'x' : ' '}] `); + state.renderContent(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js new file mode 100644 index 00000000000..84838c14999 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js @@ -0,0 +1,20 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +export default class Text extends Node { + get name() { + return 'text'; + } + + get schema() { + return { + group: 'inline', + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.text(state, node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js new file mode 100644 index 00000000000..516f983397d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/nodes/video.js @@ -0,0 +1,54 @@ +/* eslint-disable class-methods-use-this */ + +import { Node } from 'tiptap'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown'; + +// Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter +export default class Video extends Node { + get name() { + return 'video'; + } + + get schema() { + return { + attrs: { + src: {}, + alt: { + default: null, + }, + }, + group: 'block', + draggable: true, + parseDOM: [ + { + tag: '.video-container', + skip: true, + }, + { + tag: '.video-container p', + priority: 51, + ignore: true, + }, + { + tag: 'video[src]', + getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }), + }, + ], + toDOM: node => [ + 'video', + { + src: node.attrs.src, + width: '400', + controls: true, + 'data-setup': '{}', + 'data-title': node.attrs.alt, + }, + ], + }; + } + + toMarkdown(state, node) { + defaultMarkdownSerializer.nodes.image(state, node); + state.closeBlock(node); + } +} diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 35380ca49fb..798114b4b0b 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -1,4 +1,5 @@ import flash from '~/flash'; +import { sprintf, __ } from '../../locale'; // Renders diagrams and flowcharts from text using Mermaid in any element with the // `js-render-mermaid` class. @@ -14,6 +15,9 @@ import flash from '~/flash'; // </pre> // +// This is an arbitary number; Can be iterated upon when suitable. +const MAX_CHAR_LIMIT = 5000; + export default function renderMermaid($els) { if (!$els.length) return; @@ -34,6 +38,21 @@ export default function renderMermaid($els) { $els.each((i, el) => { const source = el.textContent; + /** + * Restrict the rendering to a certain amount of character to + * prevent mermaidjs from hanging up the entire thread and + * causing a DoS. + */ + if (source && source.length > MAX_CHAR_LIMIT) { + el.textContent = sprintf( + __( + 'Cannot render the image. Maximum character count (%{charLimit}) has been exceeded.', + ), + { charLimit: MAX_CHAR_LIMIT }, + ); + return; + } + // Remove any extra spans added by the backend syntax highlighting. Object.assign(el, { textContent: source }); diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js new file mode 100644 index 00000000000..163182ab778 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/schema.js @@ -0,0 +1,24 @@ +import { Schema } from 'prosemirror-model'; +import editorExtensions from './editor_extensions'; + +const nodes = editorExtensions + .filter(extension => extension.type === 'node') + .reduce( + (ns, { name, schema }) => ({ + ...ns, + [name]: schema, + }), + {}, + ); + +const marks = editorExtensions + .filter(extension => extension.type === 'mark') + .reduce( + (ms, { name, schema }) => ({ + ...ms, + [name]: schema, + }), + {}, + ); + +export default new Schema({ nodes, marks }); diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js new file mode 100644 index 00000000000..70dbd8bd206 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/serializer.js @@ -0,0 +1,24 @@ +import { MarkdownSerializer } from 'prosemirror-markdown'; +import editorExtensions from './editor_extensions'; + +const nodes = editorExtensions + .filter(extension => extension.type === 'node') + .reduce( + (ns, { name, toMarkdown }) => ({ + ...ns, + [name]: toMarkdown, + }), + {}, + ); + +const marks = editorExtensions + .filter(extension => extension.type === 'mark') + .reduce( + (ms, { name, toMarkdown }) => ({ + ...ms, + [name]: toMarkdown, + }), + {}, + ); + +export default new MarkdownSerializer(nodes, marks); diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 35f1bb6b080..7adccbb062f 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -28,16 +28,13 @@ MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function($form) { var mdText; - var markdownVersion; - var url; var preview = $form.find('.js-md-preview'); + var url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } mdText = $form.find('textarea.markdown-area').val(); - markdownVersion = $form.attr('data-markdown-version'); - url = this.versionedPreviewPath(preview.data('url'), markdownVersion); if (mdText.trim().length === 0) { preview.text(this.emptyMessage); @@ -67,16 +64,6 @@ MarkdownPreview.prototype.showPreview = function($form) { } }; -MarkdownPreview.prototype.versionedPreviewPath = function(markdownPreviewPath, markdownVersion) { - if (typeof markdownVersion === 'undefined') { - return markdownPreviewPath; - } - - return `${markdownPreviewPath}${ - markdownPreviewPath.indexOf('?') === -1 ? '?' : '&' - }markdown_version=${markdownVersion}`; -}; - MarkdownPreview.prototype.fetchMarkdownPreview = function(text, url, success) { if (!url) { return; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 2918e1486a7..680f2031409 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import Mousetrap from 'mousetrap'; -import _ from 'underscore'; import Sidebar from '../../right_sidebar'; import Shortcuts from './shortcuts'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; @@ -63,28 +62,32 @@ export default class ShortcutsIssuable extends Shortcuts { } const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); - const selected = CopyAsGFM.nodeToGFM(el); - - if (selected.trim() === '') { - return false; - } - - const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`); - - // If replyField already has some content, add a newline before our quote - const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; - $replyField - .val((a, current) => `${current}${separator}${quote.join('')}\n`) - .trigger('input') - .trigger('change'); - - // Trigger autosize - const event = document.createEvent('Event'); - event.initEvent('autosize:update', true, false); - $replyField.get(0).dispatchEvent(event); + const blockquoteEl = document.createElement('blockquote'); + blockquoteEl.appendChild(el); + CopyAsGFM.nodeToGFM(blockquoteEl) + .then(text => { + if (text.trim() === '') { + return false; + } + + // If replyField already has some content, add a newline before our quote + const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; + $replyField + .val((a, current) => `${current}${separator}${text}\n\n`) + .trigger('input') + .trigger('change'); + + // Trigger autosize + const event = document.createEvent('Event'); + event.initEvent('autosize:update', true, false); + $replyField.get(0).dispatchEvent(event); + + // Focus the input field + $replyField.focus(); - // Focus the input field - $replyField.focus(); + return false; + }) + .catch(() => {}); return false; } diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index a689dfc3768..f3f341ece5c 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -221,7 +221,7 @@ export default { </script> <template> - <div class="board-list-component d-flex flex-column"> + <div class="board-list-component"> <div v-if="loading" class="board-list-loading text-center" aria-label="Loading issues"> <gl-loading-icon /> </div> diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index b1f992c03ff..6ebd1ad109e 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -6,7 +6,13 @@ import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE } from './constants'; +import { + APPLICATION_STATUS, + REQUEST_SUBMITTED, + REQUEST_FAILURE, + UPGRADE_REQUESTED, + UPGRADE_REQUEST_FAILURE, +} from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; @@ -120,11 +126,17 @@ export default class Clusters { addListeners() { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); + eventHub.$on('upgradeApplication', data => this.upgradeApplication(data)); + eventHub.$on('upgradeFailed', appId => this.upgradeFailed(appId)); + eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId)); } removeListeners() { if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken); eventHub.$off('installApplication', this.installApplication); + eventHub.$off('upgradeApplication', this.upgradeApplication); + eventHub.$off('upgradeFailed', this.upgradeFailed); + eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess); } initPolling() { @@ -231,22 +243,33 @@ export default class Clusters { installApplication(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED); this.store.updateAppProperty(appId, 'requestReason', null); + this.store.updateAppProperty(appId, 'statusReason', null); + + this.service.installApplication(appId, data.params).catch(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin installing failed'), + ); + }); + } + + upgradeApplication(data) { + const appId = data.id; + this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUESTED); + this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING); + this.service.installApplication(appId, data.params).catch(() => this.upgradeFailed(appId)); + } + + upgradeFailed(appId) { + this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUEST_FAILURE); + } - this.service - .installApplication(appId, data.params) - .then(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); - }) - .catch(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin installing failed'), - ); - }); + dismissUpgradeSuccess(appId) { + this.store.updateAppProperty(appId, 'requestStatus', null); } destroy() { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index d4354dcfebd..5952e93b9a7 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -1,20 +1,24 @@ <script> /* eslint-disable vue/require-default-prop */ +import { GlLink } from '@gitlab/ui'; +import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import { s__, sprintf } from '../../locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; import { APPLICATION_STATUS, - REQUEST_LOADING, - REQUEST_SUCCESS, + REQUEST_SUBMITTED, REQUEST_FAILURE, + UPGRADE_REQUESTED, } from '../constants'; export default { components: { loadingButton, identicon, + TimeagoTooltip, + GlLink, }, props: { id: { @@ -59,6 +63,18 @@ export default { type: String, required: false, }, + version: { + type: String, + required: false, + }, + chartRepo: { + type: String, + required: false, + }, + upgradeAvailable: { + type: Boolean, + required: false, + }, installApplicationRequestParams: { type: Object, required: false, @@ -72,11 +88,31 @@ export default { isKnownStatus() { return Object.values(APPLICATION_STATUS).includes(this.status); }, + isInstalling() { + return ( + this.status === APPLICATION_STATUS.SCHEDULED || + this.status === APPLICATION_STATUS.INSTALLING || + (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.isInstalled) + ); + }, isInstalled() { return ( this.status === APPLICATION_STATUS.INSTALLED || this.status === APPLICATION_STATUS.UPDATED || - this.status === APPLICATION_STATUS.UPDATING + this.status === APPLICATION_STATUS.UPDATING || + this.status === APPLICATION_STATUS.UPDATE_ERRORED + ); + }, + canInstall() { + if (this.isInstalling) { + return false; + } + + return ( + this.status === APPLICATION_STATUS.NOT_INSTALLABLE || + this.status === APPLICATION_STATUS.INSTALLABLE || + this.status === APPLICATION_STATUS.ERROR || + this.isUnknownStatus ); }, hasLogo() { @@ -90,12 +126,7 @@ export default { return `js-cluster-application-row-${this.id}`; }, installButtonLoading() { - return ( - !this.status || - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING || - this.requestStatus === REQUEST_LOADING - ); + return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling; }, installButtonDisabled() { // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but @@ -104,30 +135,17 @@ export default { return ( ((this.status !== APPLICATION_STATUS.INSTALLABLE && this.status !== APPLICATION_STATUS.ERROR) || - this.requestStatus === REQUEST_LOADING || - this.requestStatus === REQUEST_SUCCESS) && + this.isInstalling) && this.isKnownStatus ); }, installButtonLabel() { let label; - if ( - this.status === APPLICATION_STATUS.NOT_INSTALLABLE || - this.status === APPLICATION_STATUS.INSTALLABLE || - this.status === APPLICATION_STATUS.ERROR || - this.isUnknownStatus - ) { + if (this.canInstall) { label = s__('ClusterIntegration|Install'); - } else if ( - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING - ) { + } else if (this.isInstalling) { label = s__('ClusterIntegration|Installing'); - } else if ( - this.status === APPLICATION_STATUS.INSTALLED || - this.status === APPLICATION_STATUS.UPDATED || - this.status === APPLICATION_STATUS.UPDATING - ) { + } else if (this.isInstalled) { label = s__('ClusterIntegration|Installed'); } @@ -140,13 +158,79 @@ export default { return s__('ClusterIntegration|Manage'); }, hasError() { - return this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE; + return ( + !this.isInstalling && + (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE) + ); }, generalErrorDescription() { return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), { title: this.title, }); }, + versionLabel() { + if (this.upgradeFailed) { + return s__('ClusterIntegration|Upgrade failed'); + } else if (this.isUpgrading) { + return s__('ClusterIntegration|Upgrading'); + } + + return s__('ClusterIntegration|Upgraded'); + }, + upgradeRequested() { + return this.requestStatus === UPGRADE_REQUESTED; + }, + upgradeSuccessful() { + return this.status === APPLICATION_STATUS.UPDATED; + }, + upgradeFailed() { + if (this.isUpgrading) { + return false; + } + + return this.status === APPLICATION_STATUS.UPDATE_ERRORED; + }, + upgradeFailureDescription() { + return sprintf( + s__( + 'ClusterIntegration|Something went wrong when upgrading %{title}. Please check the logs and try again.', + ), + { + title: this.title, + }, + ); + }, + upgradeSuccessDescription() { + return sprintf(s__('ClusterIntegration|%{title} upgraded successfully.'), { + title: this.title, + }); + }, + upgradeButtonLabel() { + let label; + if (this.upgradeAvailable && !this.upgradeFailed && !this.isUpgrading) { + label = s__('ClusterIntegration|Upgrade'); + } else if (this.isUpgrading) { + label = s__('ClusterIntegration|Upgrading'); + } else if (this.upgradeFailed) { + label = s__('ClusterIntegration|Retry upgrade'); + } + + return label; + }, + isUpgrading() { + // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend + return ( + this.status === APPLICATION_STATUS.UPDATING || + (this.upgradeRequested && !this.upgradeSuccessful) + ); + }, + }, + watch: { + status() { + if (this.status === APPLICATION_STATUS.UPDATE_ERRORED) { + eventHub.$emit('upgradeFailed', this.id); + } + }, }, methods: { installClicked() { @@ -155,6 +239,15 @@ export default { params: this.installApplicationRequestParams, }); }, + upgradeClicked() { + eventHub.$emit('upgradeApplication', { + id: this.id, + params: this.installApplicationRequestParams, + }); + }, + dismissUpgradeSuccess() { + eventHub.$emit('dismissUpgradeSuccess', this.id); + }, }, }; </script> @@ -208,6 +301,51 @@ export default { </li> </ul> </div> + + <div + v-if="(upgradeSuccessful || upgradeFailed) && !upgradeAvailable" + class="form-text text-muted label p-0 js-cluster-application-upgrade-details" + > + {{ versionLabel }} + + <span v-if="upgradeSuccessful"> to</span> + + <gl-link + v-if="upgradeSuccessful" + :href="chartRepo" + target="_blank" + class="js-cluster-application-upgrade-version" + > + chart v{{ version }} + </gl-link> + </div> + + <div + v-if="upgradeFailed && !isUpgrading" + class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" + > + {{ upgradeFailureDescription }} + </div> + + <div + v-if="upgradeRequested && upgradeSuccessful" + class="bs-callout bs-callout-success cluster-application-banner mt-2 mb-0 p-0 pl-3" + > + {{ upgradeSuccessDescription }} + + <button class="close cluster-application-banner-close" @click="dismissUpgradeSuccess"> + × + </button> + </div> + + <loading-button + v-if="upgradeAvailable || upgradeFailed || isUpgrading" + class="btn btn-primary js-cluster-application-upgrade-button mt-2" + :loading="isUpgrading" + :disabled="isUpgrading" + :label="upgradeButtonLabel" + @click="upgradeClicked" + /> </div> <div :class="{ 'section-25': showManageButton, 'section-15': !showManageButton }" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 5d19c79570a..0cf187d4189 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -362,6 +362,9 @@ export default { :status-reason="applications.runner.statusReason" :request-status="applications.runner.requestStatus" :request-reason="applications.runner.requestReason" + :version="applications.runner.version" + :chart-repo="applications.runner.chartRepo" + :upgrade-available="applications.runner.upgradeAvailable" :disabled="!helmInstalled" title-link="https://docs.gitlab.com/runner/" > diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index e31afadf186..39022879d91 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -12,16 +12,19 @@ export const APPLICATION_STATUS = { SCHEDULED: 'scheduled', INSTALLING: 'installing', INSTALLED: 'installed', - UPDATED: 'updated', UPDATING: 'updating', + UPDATED: 'updated', + UPDATE_ERRORED: 'update_errored', ERROR: 'errored', }; // These are only used client-side -export const REQUEST_LOADING = 'request-loading'; -export const REQUEST_SUCCESS = 'request-success'; +export const REQUEST_SUBMITTED = 'request-submitted'; export const REQUEST_FAILURE = 'request-failure'; +export const UPGRADE_REQUESTED = 'upgrade-requested'; +export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure'; export const INGRESS = 'ingress'; export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; +export const RUNNER = 'runner'; export const CERT_MANAGER = 'cert_manager'; diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 8f74be4e0e6..d309678be27 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -1,6 +1,6 @@ import { s__ } from '../../locale'; import { parseBoolean } from '../../lib/utils/common_utils'; -import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER } from '../constants'; +import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER, RUNNER } from '../constants'; export default class ClusterStore { constructor() { @@ -40,6 +40,9 @@ export default class ClusterStore { statusReason: null, requestStatus: null, requestReason: null, + version: null, + chartRepo: 'https://gitlab.com/charts/gitlab-runner', + upgradeAvailable: null, }, prometheus: { title: s__('ClusterIntegration|Prometheus'), @@ -100,7 +103,13 @@ export default class ClusterStore { this.state.statusReason = serverState.status_reason; serverState.applications.forEach(serverAppEntry => { - const { name: appId, status, status_reason: statusReason } = serverAppEntry; + const { + name: appId, + status, + status_reason: statusReason, + version, + update_available: upgradeAvailable, + } = serverAppEntry; this.state.applications[appId] = { ...(this.state.applications[appId] || {}), @@ -124,6 +133,9 @@ export default class ClusterStore { serverAppEntry.hostname || this.state.applications.knative.hostname; this.state.applications.knative.externalIp = serverAppEntry.external_ip || this.state.applications.knative.externalIp; + } else if (appId === RUNNER) { + this.state.applications.runner.version = version; + this.state.applications.runner.upgradeAvailable = upgradeAvailable; } }); } diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js index a7ed175f7a4..009153d0703 100644 --- a/app/assets/javascripts/commons/jquery.js +++ b/app/assets/javascripts/commons/jquery.js @@ -7,4 +7,3 @@ import 'vendor/jquery.caret'; import 'vendor/jquery.atwho'; import 'vendor/jquery.scrollTo'; import 'jquery.waitforimages'; -import 'select2/select2'; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index d4c1b07093d..8f47931d14a 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -4,6 +4,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; import createFlash from '~/flash'; import { GlLoadingIcon } from '@gitlab/ui'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import eventHub from '../../notes/event_hub'; import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; @@ -11,6 +12,13 @@ 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'; +import { + TREE_LIST_WIDTH_STORAGE_KEY, + INITIAL_TREE_WIDTH, + MIN_TREE_WIDTH, + MAX_TREE_WIDTH, + TREE_HIDE_STATS_WIDTH, +} from '../constants'; export default { name: 'DiffsApp', @@ -23,6 +31,7 @@ export default { CommitWidget, TreeList, GlLoadingIcon, + PanelResizer, }, props: { endpoint: { @@ -54,8 +63,12 @@ export default { }, }, data() { + const treeWidth = + parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH; + return { assignedDiscussions: false, + treeWidth, }; }, computed: { @@ -96,6 +109,9 @@ export default { this.startVersion.version_index === this.mergeRequestDiff.version_index) ); }, + hideFileStats() { + return this.treeWidth <= TREE_HIDE_STATS_WIDTH; + }, }, watch: { diffViewType() { @@ -129,6 +145,10 @@ export default { created() { this.adjustView(); eventHub.$once('fetchedNotesData', this.setDiscussions); + eventHub.$once('fetchDiffData', this.fetchData); + }, + beforeDestroy() { + eventHub.$off('fetchDiffData', this.fetchData); }, methods: { ...mapActions(['startTaskList']), @@ -138,6 +158,7 @@ export default { 'startRenderDiffsQueue', 'assignDiscussionsToDiff', 'setHighlightedRow', + 'cacheTreeListWidth', ]), fetchData() { this.fetchDiffFiles() @@ -180,6 +201,8 @@ export default { } }, }, + minTreeWidth: MIN_TREE_WIDTH, + maxTreeWidth: MAX_TREE_WIDTH, }; </script> @@ -205,7 +228,21 @@ export default { :data-can-create-note="getNoteableData.current_user.can_create_note" class="files d-flex prepend-top-default" > - <div v-show="showTreeList" class="diff-tree-list"><tree-list /></div> + <div + v-show="showTreeList" + :style="{ width: `${treeWidth}px` }" + class="diff-tree-list js-diff-tree-list" + > + <panel-resizer + :size.sync="treeWidth" + :start-size="treeWidth" + :min-size="$options.minTreeWidth" + :max-size="$options.maxTreeWidth" + side="right" + @resize-end="cacheTreeListWidth" + /> + <tree-list :hide-file-stats="hideFileStats" /> + </div> <div class="diff-files-holder"> <commit-widget v-if="commit" :commit="commit" /> <template v-if="renderDiffFiles"> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 3770b5c8864..0bf2dde8b96 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -2,10 +2,11 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; -import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; import { polyfillSticky } from '~/lib/utils/sticky'; import Icon from '~/vue_shared/components/icon.vue'; import CompareVersionsDropdown from './compare_versions_dropdown.vue'; +import SettingsDropdown from './settings_dropdown.vue'; +import DiffStats from './diff_stats.vue'; export default { components: { @@ -13,6 +14,8 @@ export default { Icon, GlLink, GlButton, + SettingsDropdown, + DiffStats, }, directives: { GlTooltip: GlTooltipDirective, @@ -34,24 +37,18 @@ export default { }, }, computed: { - ...mapState('diffs', ['commit', 'showTreeList', 'startVersion', 'latestVersionPath']), - ...mapGetters('diffs', ['isInlineView', 'isParallelView', 'hasCollapsedFile']), + ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']), + ...mapState('diffs', [ + 'commit', + 'showTreeList', + 'startVersion', + 'latestVersionPath', + 'addedLines', + 'removedLines', + ]), comparableDiffs() { return this.mergeRequestDiffs.slice(1); }, - 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; }, @@ -75,9 +72,6 @@ export default { 'expandAllFiles', 'toggleShowTreeList', ]), - isWhitespaceVisible() { - return getParameterValues('w')[0] !== '1'; - }, }, }; </script> @@ -118,7 +112,12 @@ export default { {{ __('Viewing commit') }} <gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link> </div> - <div class="inline-parallel-buttons d-none d-lg-flex ml-auto"> + <div class="inline-parallel-buttons d-none d-md-flex ml-auto"> + <diff-stats + :diff-files-length="diffFilesLength" + :added-lines="addedLines" + :removed-lines="removedLines" + /> <gl-button v-if="commit || startVersion" :href="latestVersionPath" @@ -129,31 +128,7 @@ export default { <a v-show="hasCollapsedFile" class="btn btn-default append-right-8" @click="expandAllFiles"> {{ __('Expand all') }} </a> - <a :href="toggleWhitespacePath" class="btn btn-default qa-toggle-whitespace"> - {{ 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> + <settings-dropdown /> </div> </div> </div> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 6dc2f5d3f68..cb92093db32 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,7 +1,8 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; -import EmptyFileViewer from '~/vue_shared/components/diff_viewer/viewers/empty_file.vue'; +import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue'; +import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue'; import InlineDiffView from './inline_diff_view.vue'; import ParallelDiffView from './parallel_diff_view.vue'; import NoteForm from '../../notes/components/note_form.vue'; @@ -9,6 +10,7 @@ import ImageDiffOverlay from './image_diff_overlay.vue'; import DiffDiscussions from './diff_discussions.vue'; import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; import { getDiffMode } from '../store/utils'; +import { diffViewerModes } from '~/ide/constants'; export default { components: { @@ -18,7 +20,8 @@ export default { NoteForm, DiffDiscussions, ImageDiffOverlay, - EmptyFileViewer, + NotDiffableViewer, + NoPreviewViewer, }, props: { diffFile: { @@ -42,11 +45,17 @@ export default { diffMode() { return getDiffMode(this.diffFile); }, + diffViewerMode() { + return this.diffFile.viewer.name; + }, isTextFile() { - return this.diffFile.viewer.name === 'text'; + return this.diffViewerMode === diffViewerModes.text; + }, + noPreview() { + return this.diffViewerMode === diffViewerModes.no_preview; }, - errorMessage() { - return this.diffFile.viewer.error; + notDiffable() { + return this.diffViewerMode === diffViewerModes.not_diffable; }, diffFileCommentForm() { return this.getCommentFormForDiffFile(this.diffFile.file_hash); @@ -78,11 +87,10 @@ export default { <template> <div class="diff-content"> - <div v-if="!errorMessage" class="diff-viewer"> + <div class="diff-viewer"> <template v-if="isTextFile"> - <empty-file-viewer v-if="diffFile.empty" /> <inline-diff-view - v-else-if="isInlineView" + v-if="isInlineView" :diff-file="diffFile" :diff-lines="diffFile.highlighted_diff_lines || []" :help-page-path="helpPagePath" @@ -94,9 +102,12 @@ export default { :help-page-path="helpPagePath" /> </template> + <not-diffable-viewer v-else-if="notDiffable" /> + <no-preview-viewer v-else-if="noPreview" /> <diff-viewer v-else :diff-mode="diffMode" + :diff-viewer-mode="diffViewerMode" :new-path="diffFile.new_path" :new-sha="diffFile.diff_refs.head_sha" :old-path="diffFile.old_path" @@ -132,8 +143,5 @@ export default { </div> </diff-viewer> </div> - <div v-else class="diff-viewer"> - <div class="nothing-here-block" v-html="errorMessage"></div> - </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 449f7007077..1141a197c6a 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -7,6 +7,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import eventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; +import { diffViewerErrors } from '~/ide/constants'; export default { components: { @@ -33,15 +34,13 @@ export default { return { isLoadingCollapsedDiff: false, forkMessageVisible: false, + isCollapsed: this.file.viewer.collapsed || false, }; }, computed: { ...mapState('diffs', ['currentDiffFileId']), ...mapGetters(['isNotesFetched']), ...mapGetters('diffs', ['getDiffFileDiscussions']), - isCollapsed() { - return this.file.collapsed || false; - }, viewBlobLink() { return sprintf( __('You can %{linkStart}view the blob%{linkEnd} instead.'), @@ -52,17 +51,6 @@ export default { false, ); }, - showExpandMessage() { - return ( - this.isCollapsed || - (!this.file.highlighted_diff_lines && - !this.isLoadingCollapsedDiff && - !this.file.too_large && - this.file.text && - !this.file.renamed_file && - !this.file.mode_changed) - ); - }, showLoadingIcon() { return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); }, @@ -73,9 +61,15 @@ export default { this.file.parallel_diff_lines.length > 0 ); }, + isFileTooLarge() { + return this.file.viewer.error === diffViewerErrors.too_large; + }, + errorMessage() { + return this.file.viewer.error_message; + }, }, watch: { - 'file.collapsed': function fileCollapsedWatch(newVal, oldVal) { + isCollapsed: function fileCollapsedWatch(newVal, oldVal) { if (!newVal && oldVal && !this.hasDiffLines) { this.handleLoadCollapsedDiff(); } @@ -85,13 +79,13 @@ export default { eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff); }, methods: { - ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']), + ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff', 'setRenderIt']), handleToggle() { if (!this.hasDiffLines) { this.handleLoadCollapsedDiff(); } else { - this.file.collapsed = !this.file.collapsed; - this.file.renderIt = true; + this.isCollapsed = !this.isCollapsed; + this.setRenderIt(this.file); } }, handleLoadCollapsedDiff() { @@ -100,8 +94,8 @@ export default { this.loadCollapsedDiff(this.file) .then(() => { this.isLoadingCollapsedDiff = false; - this.file.collapsed = false; - this.file.renderIt = true; + this.isCollapsed = false; + this.setRenderIt(this.file); }) .then(() => { requestIdleCallback( @@ -164,21 +158,25 @@ export default { Cancel </button> </div> - - <diff-content - v-if="!isCollapsed && file.renderIt" - :class="{ hidden: isCollapsed || file.too_large }" - :diff-file="file" - :help-page-path="helpPagePath" - /> <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> - <div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed"> - {{ __('This diff is collapsed.') }} - <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ - __('Click to expand it.') - }}</a> - </div> - <div v-if="file.too_large" class="nothing-here-block diff-collapsed js-too-large-diff"> + <template v-else> + <div v-if="errorMessage" class="diff-viewer"> + <div class="nothing-here-block" v-html="errorMessage"></div> + </div> + <div v-else-if="isCollapsed" class="nothing-here-block diff-collapsed"> + {{ __('This diff is collapsed.') }} + <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ + __('Click to expand it.') + }}</a> + </div> + <diff-content + v-else + :class="{ hidden: isCollapsed || isFileTooLarge }" + :diff-file="file" + :help-page-path="helpPagePath" + /> + </template> + <div v-if="isFileTooLarge" class="nothing-here-block diff-collapsed js-too-large-diff"> {{ __('This source diff could not be displayed because it is too large.') }} <span v-html="viewBlobLink"></span> </div> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index b58f704bebb..2b801898345 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -8,7 +8,9 @@ import FileIcon from '~/vue_shared/components/file_icon.vue'; import { GlTooltipDirective } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; import { __, s__, sprintf } from '~/locale'; +import { diffViewerModes } from '~/ide/constants'; import EditButton from './edit_button.vue'; +import DiffStats from './diff_stats.vue'; export default { components: { @@ -16,6 +18,7 @@ export default { EditButton, Icon, FileIcon, + DiffStats, }, directives: { GlTooltip: GlTooltipDirective, @@ -116,6 +119,12 @@ export default { gfmCopyText() { return `\`${this.diffFile.file_path}\``; }, + isFileRenamed() { + return this.diffFile.viewer.name === diffViewerModes.renamed; + }, + isModeChanged() { + return this.diffFile.viewer.name === diffViewerModes.mode_changed; + }, }, mounted() { polyfillSticky(this.$refs.header); @@ -163,7 +172,7 @@ export default { aria-hidden="true" css-classes="js-file-icon append-right-5" /> - <span v-if="diffFile.renamed_file"> + <span v-if="isFileRenamed"> <strong v-gl-tooltip :title="diffFile.old_path" @@ -191,7 +200,7 @@ export default { css-class="btn-default btn-transparent btn-clipboard" /> - <small v-if="diffFile.mode_changed" ref="fileMode"> + <small v-if="isModeChanged" ref="fileMode"> {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> @@ -202,6 +211,7 @@ export default { v-if="!diffFile.submodule && addMergeRequestButtons" class="file-actions d-none d-sm-block" > + <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <template v-if="diffFile.blob && diffFile.blob.readable_text"> <button :disabled="!diffHasDiscussions(diffFile)" diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue new file mode 100644 index 00000000000..2e5855380af --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -0,0 +1,52 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { n__ } from '~/locale'; + +export default { + components: { Icon }, + props: { + addedLines: { + type: Number, + required: true, + }, + removedLines: { + type: Number, + required: true, + }, + diffFilesLength: { + type: Number, + required: false, + default: null, + }, + }, + computed: { + filesText() { + return n__('File', 'Files', this.diffFilesLength); + }, + isCompareVersionsHeader() { + return Boolean(this.diffFilesLength); + }, + }, +}; +</script> + +<template> + <div + class="diff-stats" + :class="{ + 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader, + 'd-inline-flex': !isCompareVersionsHeader, + }" + > + <div v-if="diffFilesLength !== null" class="diff-stats-group"> + <icon name="doc-code" class="diff-stats-icon text-secondary" /> + <strong>{{ diffFilesLength }} {{ filesText }}</strong> + </div> + <div class="diff-stats-group cgreen"> + <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong> + </div> + <div class="diff-stats-group cred"> + <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue new file mode 100644 index 00000000000..0129763161a --- /dev/null +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -0,0 +1,92 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + GlButton, + Icon, + }, + computed: { + ...mapGetters('diffs', ['isInlineView', 'isParallelView']), + ...mapState('diffs', ['renderTreeList', 'showWhitespace']), + }, + methods: { + ...mapActions('diffs', [ + 'setInlineDiffViewType', + 'setParallelDiffViewType', + 'setRenderTreeList', + 'setShowWhitespace', + ]), + }, +}; +</script> + +<template> + <div class="dropdown"> + <button + type="button" + class="btn btn-default js-show-diff-settings" + data-toggle="dropdown" + data-display="static" + > + <icon name="settings" /> <icon name="arrow-down" /> + </button> + <div class="dropdown-menu dropdown-menu-right p-2 pt-3 pb-3"> + <div> + <span class="bold d-block mb-1">{{ __('File browser') }}</span> + <div class="btn-group d-flex"> + <gl-button + :class="{ active: !renderTreeList }" + class="w-100 js-list-view" + @click="setRenderTreeList(false)" + > + {{ __('List view') }} + </gl-button> + <gl-button + :class="{ active: renderTreeList }" + class="w-100 js-tree-view" + @click="setRenderTreeList(true)" + > + {{ __('Tree view') }} + </gl-button> + </div> + </div> + <div class="mt-2"> + <span class="bold d-block mb-1">{{ __('Compare changes') }}</span> + <div class="btn-group d-flex js-diff-view-buttons"> + <gl-button + id="inline-diff-btn" + :class="{ active: isInlineView }" + class="w-100 js-inline-diff-button" + data-view-type="inline" + @click="setInlineDiffViewType" + > + {{ __('Inline') }} + </gl-button> + <gl-button + id="parallel-diff-btn" + :class="{ active: isParallelView }" + class="w-100 js-parallel-diff-button" + data-view-type="parallel" + @click="setParallelDiffViewType" + > + {{ __('Side-by-side') }} + </gl-button> + </div> + </div> + <div class="mt-2"> + <label class="mb-0"> + <input + id="show-whitespace" + type="checkbox" + :checked="showWhitespace" + @change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })" + /> + {{ __('Show whitespace changes') }} + </label> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 097587c5ac1..8fc3af15bea 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -1,13 +1,10 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; -import { parseBoolean } from '~/lib/utils/common_utils'; import Icon from '~/vue_shared/components/icon.vue'; import FileRow from '~/vue_shared/components/file_row.vue'; import FileRowStats from './file_row_stats.vue'; -const treeListStorageKey = 'mr_diff_tree_list'; - export default { directives: { GlTooltip: GlTooltipDirective, @@ -16,23 +13,25 @@ export default { Icon, FileRow, }, + props: { + hideFileStats: { + type: Boolean, + required: true, + }, + }, data() { - const treeListStored = localStorage.getItem(treeListStorageKey); - const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true; - return { search: '', - renderTreeList, - focusSearch: false, }; }, computed: { - ...mapState('diffs', ['tree', 'addedLines', 'removedLines']), - ...mapGetters('diffs', ['allBlobs', 'diffFilesLength']), + ...mapState('diffs', ['tree', 'renderTreeList']), + ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { const search = this.search.toLowerCase().trim(); - if (search === '') return this.renderTreeList ? this.tree : this.allBlobs; + if (search === '' || this.$options.fuzzyFileFinderEnabled) + return this.renderTreeList ? this.tree : this.allBlobs; return this.allBlobs.reduce((acc, folder) => { const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0); @@ -47,27 +46,18 @@ export default { return acc; }, []); }, + fileRowExtraComponent() { + return this.hideFileStats ? null : FileRowStats; + }, }, methods: { - ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), + ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile', 'toggleFileFinder']), clearSearch() { this.search = ''; - this.toggleFocusSearch(false); - }, - toggleRenderTreeList(toggle) { - this.renderTreeList = toggle; - localStorage.setItem(treeListStorageKey, this.renderTreeList); - }, - toggleFocusSearch(toggle) { - this.focusSearch = toggle; - }, - blurSearch() { - if (this.search.trim() === '') { - this.toggleFocusSearch(false); - } }, }, - FileRowStats, + shortcutKeyCharacter: `${/Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl'}+P`, + diffTreeFiltering: gon.features && gon.features.diffTreeFiltering, }; </script> @@ -76,51 +66,36 @@ export default { <div class="append-bottom-8 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> <icon name="search" class="position-absolute tree-list-icon" /> - <input - v-model="search" - :placeholder="s__('MergeRequest|Filter files')" - type="search" - class="form-control" - @focus="toggleFocusSearch(true)" - @blur="blurSearch" - /> - <button - v-show="search" - :aria-label="__('Clear search')" - type="button" - class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" - @click="clearSearch" - > - <icon name="close" /> - </button> - </div> - <div v-show="!focusSearch" class="btn-group prepend-left-8 tree-list-view-toggle"> - <button - v-gl-tooltip.hover - :aria-label="__('List view')" - :title="__('List view')" - :class="{ - active: !renderTreeList, - }" - class="btn btn-default pt-0 pb-0 d-flex align-items-center" - type="button" - @click="toggleRenderTreeList(false)" - > - <icon name="hamburger" /> - </button> - <button - v-gl-tooltip.hover - :aria-label="__('Tree view')" - :title="__('Tree view')" - :class="{ - active: renderTreeList, - }" - class="btn btn-default pt-0 pb-0 d-flex align-items-center" - type="button" - @click="toggleRenderTreeList(true)" - > - <icon name="file-tree" /> - </button> + <template v-if="$options.diffTreeFiltering"> + <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 bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0" + @click="clearSearch" + > + <icon name="close" /> + </button> + </template> + <template v-else> + <button + type="button" + class="form-control text-left text-secondary" + @click="toggleFileFinder(true)" + > + {{ s__('MergeRequest|Search files') }} + </button> + <span + class="position-absolute text-secondary diff-tree-search-shortcut" + v-html="$options.shortcutKeyCharacter" + ></span> + </template> </div> </div> <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> @@ -131,7 +106,7 @@ export default { :file="file" :level="0" :hide-extra-on-tree="true" - :extra-component="$options.FileRowStats" + :extra-component="fileRowExtraComponent" :show-changed-icon="true" @toggleTreeOpen="toggleTreeOpen" @clickFile="scrollToFile" @@ -141,13 +116,6 @@ export default { {{ 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> @@ -155,4 +123,15 @@ export default { .tree-list-blobs .file-row-name { margin-left: 12px; } + +.diff-tree-search-shortcut { + top: 50%; + right: 10px; + transform: translateY(-50%); + pointer-events: none; +} + +.tree-list-icon:not(button) { + pointer-events: none; +} </style> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 0af1ba13d36..7002655ea49 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -34,3 +34,11 @@ export const MAX_LINES_TO_BE_RENDERED = 2000; export const MR_TREE_SHOW_KEY = 'mr_tree_show'; export const TREE_TYPE = 'tree'; +export const TREE_LIST_STORAGE_KEY = 'mr_diff_tree_list'; +export const WHITESPACE_STORAGE_KEY = 'mr_show_whitespace'; +export const TREE_LIST_WIDTH_STORAGE_KEY = 'mr_tree_list_width'; + +export const INITIAL_TREE_WIDTH = 320; +export const MIN_TREE_WIDTH = 240; +export const MAX_TREE_WIDTH = 400; +export const TREE_HIDE_STATS_WIDTH = 260; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index b130cedc24c..63954d9d412 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,8 +1,60 @@ import Vue from 'vue'; -import { mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import FindFile from '~/vue_shared/components/file_finder/index.vue'; +import eventHub from '../notes/event_hub'; import diffsApp from './components/app.vue'; +import { TREE_LIST_STORAGE_KEY } from './constants'; export default function initDiffsApp(store) { + const fileFinderEl = document.getElementById('js-diff-file-finder'); + + if (fileFinderEl) { + // eslint-disable-next-line no-new + new Vue({ + el: fileFinderEl, + store, + computed: { + ...mapState('diffs', ['fileFinderVisible', 'isLoading']), + ...mapGetters('diffs', ['flatBlobsList']), + }, + watch: { + fileFinderVisible(newVal, oldVal) { + if (newVal && !oldVal && !this.flatBlobsList.length) { + eventHub.$emit('fetchDiffData'); + } + }, + }, + methods: { + ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']), + openFile(file) { + window.mrTabs.tabShown('diffs'); + this.scrollToFile(file.path); + }, + }, + render(createElement) { + return createElement(FindFile, { + props: { + files: this.flatBlobsList, + visible: this.fileFinderVisible, + loading: this.isLoading, + showDiffStats: true, + clearSearchOnClose: false, + }, + on: { + toggle: this.toggleFileFinder, + click: this.openFile, + }, + class: ['diff-file-finder'], + style: { + display: this.fileFinderVisible ? '' : 'none', + }, + }); + }, + }); + } + return new Vue({ el: '#js-diffs-app', name: 'MergeRequestDiffs', @@ -26,6 +78,16 @@ export default function initDiffsApp(store) { activeTab: state => state.page.activeTab, }), }, + created() { + const treeListStored = localStorage.getItem(TREE_LIST_STORAGE_KEY); + const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true; + + this.setRenderTreeList(renderTreeList); + this.setShowWhitespace({ showWhitespace: getParameterValues('w')[0] !== '1' }); + }, + methods: { + ...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']), + }, render(createElement) { return createElement('diffs-app', { props: { diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 196c9dfb1c2..82ff2e3be76 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -14,7 +14,11 @@ import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME, MR_TREE_SHOW_KEY, + TREE_LIST_STORAGE_KEY, + WHITESPACE_STORAGE_KEY, + TREE_LIST_WIDTH_STORAGE_KEY, } from '../constants'; +import { diffViewerModes } from '~/ide/constants'; export const setBaseConfig = ({ commit }, options) => { const { endpoint, projectPath } = options; @@ -33,7 +37,7 @@ export const fetchDiffFiles = ({ state, commit }) => { }); return axios - .get(state.endpoint) + .get(state.endpoint, { params: { w: state.showWhitespace ? null : '1' } }) .then(res => { commit(types.SET_LOADING, false); commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []); @@ -89,7 +93,7 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi commit(types.RENDER_FILE, file); } - if (file.collapsed) { + if (file.viewer.collapsed) { eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`); scrollToElement(document.getElementById(file.file_hash)); } else { @@ -103,7 +107,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => { const checkItem = () => new Promise(resolve => { const nextFile = state.diffFiles.find( - file => !file.renderIt && (!file.collapsed || !file.text), + file => + !file.renderIt && (!file.viewer.collapsed || !file.viewer.name === diffViewerModes.text), ); if (nextFile) { @@ -126,6 +131,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => { return checkItem(); }; +export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file); + export const setInlineDiffViewType = ({ commit }) => { commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE); @@ -278,5 +285,29 @@ export const closeDiffFileCommentForm = ({ commit }, fileHash) => { commit(types.CLOSE_DIFF_FILE_COMMENT_FORM, fileHash); }; +export const setRenderTreeList = ({ commit }, renderTreeList) => { + commit(types.SET_RENDER_TREE_LIST, renderTreeList); + + localStorage.setItem(TREE_LIST_STORAGE_KEY, renderTreeList); +}; + +export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => { + commit(types.SET_SHOW_WHITESPACE, showWhitespace); + + localStorage.setItem(WHITESPACE_STORAGE_KEY, showWhitespace); + + if (pushState) { + historyPushState(showWhitespace ? '?w=0' : '?w=1'); + } +}; + +export const toggleFileFinder = ({ commit }, visible) => { + commit(types.TOGGLE_FILE_FINDER_VISIBLE, visible); +}; + +export const cacheTreeListWidth = (_, size) => { + localStorage.setItem(TREE_LIST_WIDTH_STORAGE_KEY, size); +}; + // 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 86c0c7190f9..4e7e5306995 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -4,7 +4,8 @@ export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE; -export const hasCollapsedFile = state => state.diffFiles.some(file => file.collapsed); +export const hasCollapsedFile = state => + state.diffFiles.some(file => file.viewer && file.viewer.collapsed); export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null); @@ -74,24 +75,25 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = export const getDiffFileByHash = state => fileHash => state.diffFiles.find(file => file.file_hash === fileHash); -export const allBlobs = state => - Object.values(state.treeEntries) - .filter(f => f.type === 'blob') - .reduce((acc, file) => { - const { parentPath } = file; +export const flatBlobsList = state => + Object.values(state.treeEntries).filter(f => f.type === 'blob'); - if (parentPath && !acc.some(f => f.path === parentPath)) { - acc.push({ - path: parentPath, - isHeader: true, - tree: [], - }); - } +export const allBlobs = (state, getters) => + getters.flatBlobsList.reduce((acc, file) => { + const { parentPath } = file; - acc.find(f => f.path === parentPath).tree.push(file); + if (parentPath && !acc.some(f => f.path === parentPath)) { + acc.push({ + path: parentPath, + isHeader: true, + tree: [], + }); + } - return acc; - }, []); + acc.find(f => f.path === parentPath).tree.push(file); + + return acc; + }, []); export const diffFilesLength = state => state.diffFiles.length; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 98e57d52d77..47f78a5db54 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -11,6 +11,8 @@ const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY); export default () => ({ isLoading: true, + addedLines: null, + removedLines: null, endpoint: '', basePath: '', commit: null, @@ -27,4 +29,7 @@ export default () => ({ projectPath: '', commentForms: [], highlightedRow: null, + renderTreeList: true, + showWhitespace: true, + fileFinderVisible: false, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 6ed8c5709a8..71ad108ce88 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -20,3 +20,6 @@ export const CLOSE_DIFF_FILE_COMMENT_FORM = 'CLOSE_DIFF_FILE_COMMENT_FORM'; export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW'; export const SET_TREE_DATA = 'SET_TREE_DATA'; +export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST'; +export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE'; +export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 00095997ba3..5a27388863c 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -144,6 +144,7 @@ export default { if (left || right) { return { + ...line, left: line.left ? mapDiscussions(line.left) : null, right: line.right ? mapDiscussions(line.right, () => !left) : null, }; @@ -238,4 +239,13 @@ export default { state.treeEntries = treeEntries; state.tree = tree; }, + [types.SET_RENDER_TREE_LIST](state, renderTreeList) { + state.renderTreeList = renderTreeList; + }, + [types.SET_SHOW_WHITESPACE](state, showWhitespace) { + state.showWhitespace = showWhitespace; + }, + [types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) { + state.fileFinderVisible = visible; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 09afacc24df..247d1e65fea 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -1,5 +1,6 @@ import _ from 'underscore'; -import { diffModes } from '~/ide/constants'; +import { truncatePathMiddleToLength } from '~/lib/utils/text_utility'; +import { diffModes, diffViewerModes } from '~/ide/constants'; import { LINE_POSITION_LEFT, LINE_POSITION_RIGHT, @@ -160,6 +161,7 @@ export function addContextLines(options) { const normalizedParallelLines = contextLines.map(line => ({ left: line, right: line, + line_code: line.line_code, })); if (options.bottom) { @@ -246,7 +248,8 @@ export function prepareDiffData(diffData) { Object.assign(file, { renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, - collapsed: file.text && showingLines > MAX_LINES_TO_BE_RENDERED, + collapsed: + file.viewer.name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED, discussions: [], }); } @@ -306,7 +309,7 @@ export const getLowestSingleFolder = folder => { if (shouldGetFolder) { const firstFolder = getFolder(file); - path.push(firstFolder.path); + path.push(...firstFolder.path); tree.push(...firstFolder.tree); } @@ -321,7 +324,7 @@ export const getLowestSingleFolder = folder => { const { path, tree } = getFolder(folder, [folder.name]); return { - path: path.join('/'), + path: truncatePathMiddleToLength(path.join('/'), 40), treeAcc: tree.length ? tree[tree.length - 1].tree : null, }; }; @@ -402,7 +405,9 @@ export const getDiffMode = diffFile => { const diffModeKey = Object.keys(diffModes).find(key => diffFile[`${key}_file`]); return ( diffModes[diffModeKey] || - (diffFile.mode_changed && diffModes.mode_changed) || + (diffFile.viewer && + diffFile.viewer.name === diffViewerModes.mode_changed && + diffViewerModes.mode_changed) || diffModes.replaced ); }; diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index dbfcf8cc921..cb1b1173190 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -64,6 +64,7 @@ class DueDateSelect { this.saveDueDate(true); } }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($dueDateInput.val())); @@ -183,6 +184,7 @@ export default class DueDateSelectors { onSelect(dateText) { $datePicker.val(calendar.toString(dateText)); }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate(datePickerVal)); diff --git a/app/assets/javascripts/emoji/no_emoji_validator.js b/app/assets/javascripts/emoji/no_emoji_validator.js new file mode 100644 index 00000000000..0fd4dd74953 --- /dev/null +++ b/app/assets/javascripts/emoji/no_emoji_validator.js @@ -0,0 +1,63 @@ +import { __ } from '~/locale'; +import emojiRegex from 'emoji-regex'; + +const invalidInputClass = 'gl-field-error-outline'; + +export default class NoEmojiValidator { + constructor(opts = {}) { + const container = opts.container || ''; + this.noEmojiEmelents = document.querySelectorAll(`${container} .js-block-emoji`); + + this.noEmojiEmelents.forEach(element => + element.addEventListener('input', this.eventHandler.bind(this)), + ); + } + + eventHandler(event) { + this.inputDomElement = event.target; + this.inputErrorMessage = this.inputDomElement.nextSibling; + + const { value } = this.inputDomElement; + + this.validatePattern(value); + this.setValidationStateAndMessage(); + } + + validatePattern(value) { + const pattern = emojiRegex(); + this.hasEmojis = new RegExp(pattern).test(value); + + if (this.hasEmojis) { + this.inputDomElement.setCustomValidity(__('Invalid input, please avoid emojis')); + } else { + this.inputDomElement.setCustomValidity(''); + } + } + + setValidationStateAndMessage() { + if (!this.inputDomElement.checkValidity()) { + this.setInvalidState(); + } else { + this.clearFieldValidationState(); + } + } + + clearFieldValidationState() { + this.inputDomElement.classList.remove(invalidInputClass); + this.inputErrorMessage.classList.add('hide'); + } + + setInvalidState() { + this.inputDomElement.classList.add(invalidInputClass); + this.setErrorMessage(); + } + + setErrorMessage() { + if (this.hasEmojis) { + this.inputErrorMessage.innerHTML = this.inputDomElement.validationMessage; + } else { + this.inputErrorMessage.innerHTML = this.inputDomElement.title; + } + this.inputErrorMessage.classList.remove('hide'); + } +} diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index bd402c0eea5..6ece8b92a30 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -22,10 +22,6 @@ export default { type: Object, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -51,11 +47,7 @@ export default { <slot name="emptyState"></slot> <div v-if="!isLoading && environments.length > 0" class="table-holder"> - <environment-table - :environments="environments" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - /> + <environment-table :environments="environments" :can-read-environment="canReadEnvironment" /> <table-pagination v-if="pagination && pagination.totalPages > 1" diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index f44806d82a6..503c1b38f71 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -47,12 +47,6 @@ export default { default: () => ({}), }, - canCreateDeployment: { - type: Boolean, - required: false, - default: false, - }, - canReadEnvironment: { type: Boolean, required: false, @@ -151,7 +145,7 @@ export default { }, actions() { - if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) { + if (!this.model || !this.model.last_deployment) { return []; } @@ -561,7 +555,7 @@ export default { /> <rollback-component - v-if="canRetry && canCreateDeployment" + v-if="canRetry" :is-last-deployment="isLastDeployment" :retry-url="retryUrl" /> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 87c1d44dd40..aa2417d3194 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -24,10 +24,6 @@ export default { type: Boolean, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -106,7 +102,6 @@ export default { :is-loading="isLoading" :environments="state.environments" :pagination="state.paginationInformation" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" @onChangePage="onChangePage" > diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 75bdf87137f..eef141a07ba 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -3,6 +3,7 @@ * Render environments table. */ import { GlLoadingIcon } from '@gitlab/ui'; +import _ from 'underscore'; import environmentItem from './environment_item.vue'; export default { @@ -23,11 +24,14 @@ export default { required: false, default: false, }, - - canCreateDeployment: { - type: Boolean, - required: false, - default: false, + }, + computed: { + sortedEnvironments() { + return this.sortEnvironments(this.environments).map(env => + this.shouldRenderFolderContent(env) + ? { ...env, children: this.sortEnvironments(env.children) } + : env, + ); }, }, methods: { @@ -37,6 +41,30 @@ export default { shouldRenderFolderContent(env) { return env.isFolder && env.isOpen && env.children && env.children.length > 0; }, + sortEnvironments(environments) { + /* + * The sorting algorithm should sort in the following priorities: + * + * 1. folders first, + * 2. last updated descending, + * 3. by name ascending, + * + * the sorting algorithm must: + * + * 1. Sort by name ascending, + * 2. Reverse (sort by name descending), + * 3. Sort by last deployment ascending, + * 4. Reverse (last deployment descending, name ascending), + * 5. Put folders first. + */ + return _.chain(environments) + .sortBy(env => (env.isFolder ? env.folderName : env.name)) + .reverse() + .sortBy(env => (env.last_deployment ? env.last_deployment.created_at : '0000')) + .reverse() + .sortBy(env => (env.isFolder ? -1 : 1)) + .value(); + }, }, }; </script> @@ -59,12 +87,11 @@ export default { {{ s__('Environments|Updated') }} </div> </div> - <template v-for="(model, i) in environments" :model="model"> + <template v-for="(model, i) in sortedEnvironments" :model="model"> <div is="environment-item" :key="`environment-item-${i}`" :model="model" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" /> @@ -79,7 +106,6 @@ export default { v-for="(children, index) in model.children" :key="`env-item-${i}-${index}`" :model="children" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" /> diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 982e550e73c..56e7f69cad6 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -18,7 +18,6 @@ export default () => endpoint: environmentsData.environmentsDataEndpoint, folderName: environmentsData.environmentsDataFolderName, cssContainerClass: environmentsData.cssClass, - canCreateDeployment: parseBoolean(environmentsData.environmentsDataCanCreateDeployment), canReadEnvironment: parseBoolean(environmentsData.environmentsDataCanReadEnvironment), }; }, @@ -28,7 +27,6 @@ export default () => endpoint: this.endpoint, folderName: this.folderName, cssContainerClass: this.cssContainerClass, - canCreateDeployment: this.canCreateDeployment, canReadEnvironment: this.canReadEnvironment, }, }); diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index d6f0b6115a6..80f0e00400b 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -23,10 +23,6 @@ export default { type: String, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -55,7 +51,6 @@ export default { :is-loading="isLoading" :environments="state.environments" :pagination="state.paginationInformation" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" @onChangePage="onChangePage" /> diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index d366e7550b7..6af66d0f86e 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -20,7 +20,6 @@ export default () => helpPagePath: environmentsData.helpPagePath, cssContainerClass: environmentsData.cssClass, canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment), - canCreateDeployment: parseBoolean(environmentsData.canCreateDeployment), canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment), }; }, @@ -32,7 +31,6 @@ export default () => helpPagePath: this.helpPagePath, cssContainerClass: this.cssContainerClass, canCreateEnvironment: this.canCreateEnvironment, - canCreateDeployment: this.canCreateDeployment, canReadEnvironment: this.canReadEnvironment, }, }); diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 96dc1f07cb9..e81a1525df0 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -143,7 +143,7 @@ export default { */ created() { this.service = new EnvironmentsService(this.endpoint); - this.requestData = { page: this.page, scope: this.scope }; + this.requestData = { page: this.page, scope: this.scope, nested: true }; this.poll = new Poll({ resource: this.service, diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index 4e07ccba91a..cb4ff6856db 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -7,8 +7,8 @@ export default class EnvironmentsService { } fetchEnvironments(options = {}) { - const { scope, page } = options; - return axios.get(this.environmentsEndpoint, { params: { scope, page } }); + const { scope, page, nested } = options; + return axios.get(this.environmentsEndpoint, { params: { scope, page, nested } }); } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 5808a2d4afa..ac9a31c202c 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -20,7 +20,8 @@ export default class EnvironmentsStore { * * Stores the received environments. * - * In the main environments endpoint, each environment has the following schema + * In the main environments endpoint (with { nested: true } in params), each folder + * has the following schema: * { name: String, size: Number, latest: Object } * In the endpoint to retrieve environments from each folder, the environment does * not have the `latest` key and the data is all in the root level. diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js index 2e192c958ba..11aec312368 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -2,7 +2,7 @@ import Service from '../services'; import * as types from './mutation_types'; import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; let eTagPoll; @@ -19,9 +19,17 @@ export function startPolling({ commit }, endpoint) { commit(types.SET_EXTERNAL_URL, data.external_url); commit(types.SET_LOADING, false); }, - errorCallback: () => { + errorCallback: response => { + let errorMessage = ''; + if (response && response.data && response.data.message) { + errorMessage = response.data.message; + } commit(types.SET_LOADING, false); - createFlash(__('Failed to load errors from Sentry')); + createFlash( + sprintf(__(`Failed to load errors from Sentry. Error message: %{errorMessage}`), { + errorMessage, + }), + ); }, }); diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js index 173fe7c69de..be55e6923c6 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight.js @@ -31,12 +31,14 @@ export function setupFeatureHighlightPopover(id, debounceTimeout = 300) { .removeAttr('disabled'); } +const getPriority = e => parseInt(e.dataset.highlightPriority, 10) || 0; + export function findHighestPriorityFeature() { let priorityFeature; const sortedFeatureEls = [].slice .call(document.querySelectorAll('.js-feature-highlight')) - .sort((a, b) => (a.dataset.highlightPriority || 0) < (b.dataset.highlightPriority || 0)); + .sort((a, b) => getPriority(b) - getPriority(a)); const [priorityFeatureEl] = sortedFeatureEls; if (priorityFeatureEl) { 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 57ec6603d80..4d05f46ed17 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -96,6 +96,11 @@ export default class FilteredSearchDropdownManager { gl: DropdownNonUser, element: this.container.querySelector('#js-dropdown-wip'), }, + confidential: { + reference: null, + gl: DropdownNonUser, + element: this.container.querySelector('#js-dropdown-confidential'), + }, status: { reference: null, gl: NullDropdown, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 4a2af02b40a..33c82778c79 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -593,7 +593,7 @@ export default class FilteredSearchManager { tokens.forEach(token => { const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue( token.key, - token.value.toLowerCase(), + token.value, ); const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; const { param } = tokenConfig; 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 e01dedbb57c..48534bdf815 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -65,11 +65,30 @@ export default class FilteredSearchTokenKeys { searchByConditionKeyValue(key, value) { return ( - this.conditions.find(condition => condition.tokenKey === key && condition.value === value) || - null + this.conditions.find( + condition => + condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(), + ) || null ); } + addExtraTokensForIssues() { + const confidentialToken = { + key: 'confidential', + type: 'string', + param: '', + symbol: '', + icon: 'eye-slash', + tag: 'Yes or No', + lowercaseValueOnSubmit: true, + uppercaseTokenName: false, + capitalizeTokenValue: true, + }; + + this.tokenKeys.push(confidentialToken); + this.tokenKeysWithAlternative.push(confidentialToken); + } + addExtraTokensForMergeRequests() { const wipToken = { key: 'wip', 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 fba31f16d65..5090b0bdc3c 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -163,7 +163,7 @@ export default class FilteredSearchVisualTokens { const tokenValueElement = tokenValueContainer.querySelector('.value'); tokenValueElement.innerText = tokenValue; - if (tokenValue === 'none' || tokenValue === 'any') { + if (['none', 'any'].includes(tokenValue.toLowerCase())) { return; } diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index b494b7e2de0..fd61030eb13 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -60,52 +60,52 @@ export const conditions = [ { url: 'assignee_id=None', tokenKey: 'assignee', - value: 'none', + value: 'None', }, { url: 'assignee_id=Any', tokenKey: 'assignee', - value: 'any', + value: 'Any', }, { url: 'milestone_title=None', tokenKey: 'milestone', - value: 'none', + value: 'None', }, { url: 'milestone_title=Any', tokenKey: 'milestone', - value: 'any', + value: 'Any', }, { url: 'milestone_title=%23upcoming', tokenKey: 'milestone', - value: 'upcoming', + value: 'Upcoming', }, { url: 'milestone_title=%23started', tokenKey: 'milestone', - value: 'started', + value: 'Started', }, { url: 'label_name[]=None', tokenKey: 'label', - value: 'none', + value: 'None', }, { url: 'label_name[]=Any', - tokenKey: 'any', - value: 'any', + tokenKey: 'label', + value: 'Any', }, { url: 'my_reaction_emoji=None', tokenKey: 'my-reaction', - value: 'none', + value: 'None', }, { url: 'my_reaction_emoji=Any', tokenKey: 'my-reaction', - value: 'any', + value: 'Any', }, ]; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 2cbc7c7077b..42d14b65b3a 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -82,8 +82,14 @@ export default { <li class="frequent-items-list-item-container"> <a :href="webUrl" class="clearfix"> <div class="frequent-items-item-avatar-container"> - <img v-if="hasAvatar" :src="avatarUrl" class="avatar s32" /> - <identicon v-else :entity-id="itemId" :entity-name="itemName" size-class="s32" /> + <img v-if="hasAvatar" :src="avatarUrl" class="avatar rect-avatar s32" /> + <identicon + v-else + :entity-id="itemId" + :entity-name="itemName" + size-class="s32" + class="rect-avatar" + /> </div> <div class="frequent-items-item-metadata-container"> <div :title="itemName" class="frequent-items-item-title" v-html="highlightedItemName"></div> diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 570d3b712e0..c81e754df4c 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -195,11 +195,15 @@ class GfmAutoComplete { title += ` (${m.count})`; } + const GROUP_TYPE = 'Group'; + const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase(); + + const rectAvatarClass = m.type === GROUP_TYPE ? 'rect-avatar' : ''; const imgAvatar = `<img src="${m.avatar_url}" alt="${ m.username - }" class="avatar avatar-inline center s26"/>`; - const txtAvatar = `<div class="avatar center avatar-inline s26">${autoCompleteAvatar}</div>`; + }" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`; + const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`; return { username: m.username, diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 688bd37cc56..d5130cd331d 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -88,7 +88,7 @@ export default { </div> <div :class="{ 'content-loading': group.isChildrenLoading }" - class="avatar-container s24 d-none d-sm-flex" + class="avatar-container rect-avatar s24 d-none d-sm-flex" > <a :href="group.relativePath" class="no-expand"> <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s24" /> diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 2049760fe29..bdadbb1bb2a 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -4,93 +4,97 @@ import Api from './api'; import { normalizeHeaders } from './lib/utils/common_utils'; export default function groupsSelect() { - // Needs to be accessible in rspec - window.GROUP_SELECT_PER_PAGE = 20; - $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { - const $select = $(this); - const allAvailable = $select.data('allAvailable'); - const skipGroups = $select.data('skipGroups') || []; - const parentGroupID = $select.data('parentId'); - const groupsPath = parentGroupID - ? Api.subgroupsPath.replace(':id', parentGroupID) - : Api.groupsPath; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + // Needs to be accessible in rspec + window.GROUP_SELECT_PER_PAGE = 20; + $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { + const $select = $(this); + const allAvailable = $select.data('allAvailable'); + const skipGroups = $select.data('skipGroups') || []; + const parentGroupID = $select.data('parentId'); + const groupsPath = parentGroupID + ? Api.subgroupsPath.replace(':id', parentGroupID) + : Api.groupsPath; - $select.select2({ - placeholder: 'Search for a group', - allowClear: $select.hasClass('allowClear'), - multiple: $select.hasClass('multiselect'), - minimumInputLength: 0, - ajax: { - url: Api.buildUrl(groupsPath), - dataType: 'json', - quietMillis: 250, - transport(params) { - axios[params.type.toLowerCase()](params.url, { - params: params.data, - }) - .then(res => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const currentPage = parseInt(headers['X-PAGE'], 10) || 0; - const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; - const more = currentPage < totalPages; + $select.select2({ + placeholder: 'Search for a group', + allowClear: $select.hasClass('allowClear'), + multiple: $select.hasClass('multiselect'), + minimumInputLength: 0, + ajax: { + url: Api.buildUrl(groupsPath), + dataType: 'json', + quietMillis: 250, + transport(params) { + axios[params.type.toLowerCase()](params.url, { + params: params.data, + }) + .then(res => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); + const currentPage = parseInt(headers['X-PAGE'], 10) || 0; + const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; + const more = currentPage < totalPages; - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); - }, - data(search, page) { - return { - search, - page, - per_page: window.GROUP_SELECT_PER_PAGE, - all_available: allAvailable, - }; - }, - results(data, page) { - if (data.length) return { results: [] }; + params.success({ + results, + pagination: { + more, + }, + }); + }) + .catch(params.error); + }, + data(search, page) { + return { + search, + page, + per_page: window.GROUP_SELECT_PER_PAGE, + all_available: allAvailable, + }; + }, + results(data, page) { + if (data.length) return { results: [] }; - const groups = data.length ? data : data.results || []; - const more = data.pagination ? data.pagination.more : false; - const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); + const groups = data.length ? data : data.results || []; + const more = data.pagination ? data.pagination.more : false; + const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); - return { - results, - page, - more, - }; - }, - }, - // eslint-disable-next-line consistent-return - initSelection(element, callback) { - const id = $(element).val(); - if (id !== '') { - return Api.group(id, callback); - } - }, - formatResult(object) { - return `<div class='group-result'> <div class='group-name'>${ - object.full_name - }</div> <div class='group-path'>${object.full_path}</div> </div>`; - }, - formatSelection(object) { - return object.full_name; - }, - dropdownCssClass: 'ajax-groups-dropdown select2-infinite', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); + return { + results, + page, + more, + }; + }, + }, + // eslint-disable-next-line consistent-return + initSelection(element, callback) { + const id = $(element).val(); + if (id !== '') { + return Api.group(id, callback); + } + }, + formatResult(object) { + return `<div class='group-result'> <div class='group-name'>${ + object.full_name + }</div> <div class='group-path'>${object.full_path}</div> </div>`; + }, + formatSelection(object) { + return object.full_name; + }, + dropdownCssClass: 'ajax-groups-dropdown select2-infinite', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, + }); - $select.on('select2-loaded', () => { - const dropdown = document.querySelector('.select2-infinite .select2-results'); - dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; - }); - }); + $select.on('select2-loaded', () => { + const dropdown = document.querySelector('.select2-infinite .select2-results'); + dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; + }); + }); + }) + .catch(() => {}); } 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 5119dbf32eb..11d5d9639b6 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -44,7 +44,7 @@ export default { <div class="d-flex ide-commit-editor-header align-items-center"> <file-icon :file-name="activeFile.name" :size="16" class="mr-2" /> <strong class="mr-2"> {{ activeFile.path }} </strong> - <changed-file-icon :file="activeFile" class="ml-0" /> + <changed-file-icon :file="activeFile" :is-centered="false" /> <div class="ml-auto"> <button v-if="!isStaged" diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index caec8779cac..9894ebb0624 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,20 +1,17 @@ <script> import Vue from 'vue'; -import Mousetrap from 'mousetrap'; import { mapActions, mapState, mapGetters } from 'vuex'; import { __ } from '~/locale'; +import FindFile from '~/vue_shared/components/file_finder/index.vue'; import NewModal from './new_dropdown/modal.vue'; import IdeSidebar from './ide_side_bar.vue'; import RepoTabs from './repo_tabs.vue'; import IdeStatusBar from './ide_status_bar.vue'; import RepoEditor from './repo_editor.vue'; -import FindFile from './file_finder/index.vue'; import RightPane from './panes/right.vue'; import ErrorMessage from './error_message.vue'; import CommitEditorHeader from './commit_sidebar/editor_header.vue'; -const originalStopCallback = Mousetrap.stopCallback; - export default { components: { NewModal, @@ -42,21 +39,18 @@ export default { 'emptyStateSvgPath', 'currentProjectId', 'errorMessage', + 'loading', + ]), + ...mapGetters([ + 'activeFile', + 'hasChanges', + 'someUncommittedChanges', + 'isCommitModeActive', + 'allBlobs', ]), - ...mapGetters(['activeFile', 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive']), }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); - - Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { - if (e.preventDefault) { - e.preventDefault(); - } - - this.toggleFileFinder(!this.fileFindVisible); - }); - - Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); }, methods: { ...mapActions(['toggleFileFinder']), @@ -70,17 +64,8 @@ export default { }); return returnValue; }, - mousetrapStopCallback(e, el, combo) { - if ( - (combo === 't' && el.classList.contains('dropdown-input-field')) || - el.classList.contains('inputarea') - ) { - return true; - } else if (combo === 'command+p' || combo === 'ctrl+p') { - return false; - } - - return originalStopCallback(e, el, combo); + openFile(file) { + this.$router.push(`/project${file.url}`); }, }, }; @@ -90,7 +75,14 @@ export default { <article class="ide position-relative d-flex flex-column align-items-stretch"> <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> - <find-file v-show="fileFindVisible" /> + <find-file + v-show="fileFindVisible" + :files="allBlobs" + :visible="fileFindVisible" + :loading="loading" + @toggle="toggleFileFinder" + @click="openFile" + /> <ide-sidebar /> <div class="multi-file-edit-pane"> <template v-if="activeFile"> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index f1d40586903..ce577ae85b0 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -107,16 +107,23 @@ export default { class="commit-sha" >{{ lastCommit.short_id }}</a > - by {{ lastCommit.author_name }} + by + <user-avatar-image + css-classes="ide-status-avatar" + :size="18" + :img-src="latestPipeline && latestPipeline.commit.author_gravatar_url" + :img-alt="lastCommit.author_name" + :tooltip-text="lastCommit.author_name" + /> + {{ lastCommit.author_name }} <time v-tooltip :datetime="lastCommit.committed_date" :title="tooltipTitle(lastCommit.committed_date)" data-placement="top" data-container="body" + >{{ lastCommitFormatedAge }}</time > - {{ lastCommitFormatedAge }} - </time> </div> <div v-if="file" class="ide-status-file">{{ file.name }}</div> <div v-if="file" class="ide-status-file">{{ file.eol }}</div> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 04ecd4ba4e7..c9c4e9e86f8 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -51,8 +51,11 @@ export default { return __('Create file'); }, - isCreatingNew() { - return this.entryModal.type !== modalTypes.rename; + isCreatingNewFile() { + return this.entryModal.type === 'blob'; + }, + placeholder() { + return this.isCreatingNewFile ? 'dir/file_name' : 'dir/'; }, }, methods: { @@ -107,9 +110,12 @@ export default { v-model="entryName" type="text" class="form-control qa-full-file-path" - placeholder="/dir/file_name" + :placeholder="placeholder" /> - <ul v-if="isCreatingNew" class="prepend-top-default list-inline qa-template-list"> + <ul + v-if="isCreatingNewFile" + class="file-templates prepend-top-default list-inline qa-template-list" + > <li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item"> <button type="button" diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 09245ed0296..7c560c89695 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -1,8 +1,3 @@ -// Fuzzy file finder -export const MAX_FILE_FINDER_RESULTS = 40; -export const FILE_FINDER_ROW_HEIGHT = 55; -export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; - export const MAX_WINDOW_HEIGHT_COMPACT = 750; // Commit message textarea @@ -29,6 +24,22 @@ export const diffModes = { mode_changed: 'mode_changed', }; +export const diffViewerModes = Object.freeze({ + not_diffable: 'not_diffable', + no_preview: 'no_preview', + added: 'added', + deleted: 'deleted', + renamed: 'renamed', + mode_changed: 'mode_changed', + text: 'text', + image: 'image', +}); + +export const diffViewerErrors = Object.freeze({ + too_large: 'too_large', + stored_externally: 'server_side_but_stored_externally', +}); + export const rightSidebarViews = { pipelines: { name: 'pipelines-list', keepAlive: true }, jobsDetail: { name: 'jobs-detail', keepAlive: false }, diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 5a2b680c2f7..cdfebd19fa4 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -6,6 +6,7 @@ import ide from './components/ide.vue'; import store from './stores'; import router from './ide_router'; import { parseBoolean } from '../lib/utils/common_utils'; +import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; Vue.use(Translate); @@ -60,16 +61,6 @@ export function initIde(el, options = {}) { }); } -// tell webpack to load assets from origin so that web workers don't break -export function resetServiceWorkersPublicPath() { - // __webpack_public_path__ is a global variable that can be used to adjust - // the webpack publicPath setting at runtime. - // see: https://webpack.js.org/guides/public-path/ - const relativeRootPath = (gon && gon.relative_url_root) || ''; - const webpackAssetPath = `${relativeRootPath}/assets/webpack/`; - __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase -} - /** * Start the IDE. * diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 60bddb34977..a15f04075d9 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -13,12 +13,12 @@ export default class Model { (this.originalModel = monacoEditor.createModel( head ? head.content : this.file.raw, undefined, - new Uri(false, false, `original/${this.path}`), + new Uri('gitlab', false, `original/${this.path}`), )), (this.model = monacoEditor.createModel( this.content, undefined, - new Uri(false, false, this.path), + new Uri('gitlab', false, this.path), )), ); if (this.file.mrChange) { @@ -26,7 +26,7 @@ export default class Model { (this.baseModel = monacoEditor.createModel( this.file.baseRaw, undefined, - new Uri(false, false, `target/${this.path}`), + new Uri('gitlab', false, `target/${this.path}`), )), ); } diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index d97a950a8b2..24c2f71ae2b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -202,7 +202,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo dispatch( 'setErrorMessage', { - text: __('An error accured whilst committing your changes.'), + text: __('An error occurred whilst committing your changes.'), action: () => dispatch('commitChanges').then(() => dispatch('setErrorMessage', null, { root: true }), diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue new file mode 100644 index 00000000000..777f8fa6691 --- /dev/null +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -0,0 +1,101 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { __, sprintf } from '~/locale'; +import ImportedProjectTableRow from './imported_project_table_row.vue'; +import ProviderRepoTableRow from './provider_repo_table_row.vue'; +import eventHub from '../event_hub'; + +export default { + name: 'ImportProjectsTable', + components: { + ImportedProjectTableRow, + ProviderRepoTableRow, + LoadingButton, + GlLoadingIcon, + }, + props: { + providerTitle: { + type: String, + required: true, + }, + }, + + computed: { + ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos']), + ...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']), + + emptyStateText() { + return sprintf(__('No %{providerTitle} repositories available to import'), { + providerTitle: this.providerTitle, + }); + }, + + fromHeaderText() { + return sprintf(__('From %{providerTitle}'), { providerTitle: this.providerTitle }); + }, + }, + + mounted() { + return this.fetchRepos(); + }, + + beforeDestroy() { + this.stopJobsPolling(); + this.clearJobsEtagPoll(); + }, + + methods: { + ...mapActions(['fetchRepos', 'fetchJobs', 'stopJobsPolling', 'clearJobsEtagPoll']), + + importAll() { + eventHub.$emit('importAll'); + }, + }, +}; +</script> + +<template> + <div> + <div class="d-flex justify-content-between align-items-end flex-wrap mb-3"> + <p class="light text-nowrap mt-2 my-sm-0"> + {{ s__('ImportProjects|Select the projects you want to import') }} + </p> + <loading-button + container-class="btn btn-success js-import-all" + :loading="isImportingAnyRepo" + :label="__('Import all repositories')" + :disabled="!hasProviderRepos" + type="button" + @click="importAll" + /> + </div> + <gl-loading-icon + v-if="isLoadingRepos" + class="js-loading-button-icon import-projects-loading-icon" + :size="4" + /> + <div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive"> + <table class="table import-table"> + <thead> + <th class="import-jobs-from-col">{{ fromHeaderText }}</th> + <th class="import-jobs-to-col">{{ __('To GitLab') }}</th> + <th class="import-jobs-status-col">{{ __('Status') }}</th> + <th class="import-jobs-cta-col"></th> + </thead> + <tbody> + <imported-project-table-row + v-for="project in importedProjects" + :key="project.id" + :project="project" + /> + <provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" /> + </tbody> + </table> + </div> + <div v-else class="text-center"> + <strong>{{ emptyStateText }}</strong> + </div> + </div> +</template> diff --git a/app/assets/javascripts/import_projects/components/import_status.vue b/app/assets/javascripts/import_projects/components/import_status.vue new file mode 100644 index 00000000000..9e3347a657f --- /dev/null +++ b/app/assets/javascripts/import_projects/components/import_status.vue @@ -0,0 +1,47 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import STATUS_MAP from '../constants'; + +export default { + name: 'ImportStatus', + components: { + CiIcon, + GlLoadingIcon, + }, + props: { + status: { + type: String, + required: true, + }, + }, + + computed: { + mappedStatus() { + return STATUS_MAP[this.status]; + }, + + ciIconStatus() { + const { icon } = this.mappedStatus; + + return { + icon: `status_${icon}`, + group: icon, + }; + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon + v-if="mappedStatus.loadingIcon" + :inline="true" + :class="mappedStatus.textClass" + class="align-middle mr-2" + /> + <ci-icon v-else css-classes="align-middle mr-2" :status="ciIconStatus" /> + <span :class="mappedStatus.textClass">{{ mappedStatus.text }}</span> + </div> +</template> diff --git a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue new file mode 100644 index 00000000000..ab2bd87ee9f --- /dev/null +++ b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue @@ -0,0 +1,55 @@ +<script> +import ImportStatus from './import_status.vue'; +import { STATUSES } from '../constants'; + +export default { + name: 'ImportedProjectTableRow', + components: { + ImportStatus, + }, + props: { + project: { + type: Object, + required: true, + }, + }, + + computed: { + displayFullPath() { + return this.project.fullPath.replace(/^\//, ''); + }, + + isFinished() { + return this.project.importStatus === STATUSES.FINISHED; + }, + }, +}; +</script> + +<template> + <tr class="js-imported-project import-row"> + <td> + <a + :href="project.providerLink" + rel="noreferrer noopener" + target="_blank" + class="js-provider-link" + > + {{ project.importSource }} + </a> + </td> + <td class="js-full-path">{{ displayFullPath }}</td> + <td><import-status :status="project.importStatus" /></td> + <td> + <a + v-if="isFinished" + class="btn btn-default js-go-to-project" + :href="project.fullPath" + rel="noreferrer noopener" + target="_blank" + > + {{ __('Go to project') }} + </a> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue new file mode 100644 index 00000000000..7cc29fa1b91 --- /dev/null +++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue @@ -0,0 +1,110 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import Select2Select from '~/vue_shared/components/select2_select.vue'; +import { __ } from '~/locale'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import eventHub from '../event_hub'; +import { STATUSES } from '../constants'; +import ImportStatus from './import_status.vue'; + +export default { + name: 'ProviderRepoTableRow', + components: { + Select2Select, + LoadingButton, + ImportStatus, + }, + props: { + repo: { + type: Object, + required: true, + }, + }, + + data() { + return { + targetNamespace: this.$store.state.defaultTargetNamespace, + newName: this.repo.sanitizedName, + }; + }, + + computed: { + ...mapState(['namespaces', 'reposBeingImported', 'ciCdOnly']), + + ...mapGetters(['namespaceSelectOptions']), + + importButtonText() { + return this.ciCdOnly ? __('Connect') : __('Import'); + }, + + select2Options() { + return { + data: this.namespaceSelectOptions, + containerCssClass: + 'import-namespace-select js-namespace-select qa-project-namespace-select', + }; + }, + + isLoadingImport() { + return this.reposBeingImported.includes(this.repo.id); + }, + + status() { + return this.isLoadingImport ? STATUSES.SCHEDULING : STATUSES.NONE; + }, + }, + + created() { + eventHub.$on('importAll', () => this.importRepo()); + }, + + methods: { + ...mapActions(['fetchImport']), + + importRepo() { + return this.fetchImport({ + newName: this.newName, + targetNamespace: this.targetNamespace, + repo: this.repo, + }); + }, + }, +}; +</script> + +<template> + <tr class="qa-project-import-row js-provider-repo import-row"> + <td> + <a + :href="repo.providerLink" + rel="noreferrer noopener" + target="_blank" + class="js-provider-link" + > + {{ repo.fullName }} + </a> + </td> + <td class="d-flex flex-wrap flex-lg-nowrap"> + <select2-select v-model="targetNamespace" :options="select2Options" /> + <span class="px-2 import-slash-divider d-flex justify-content-center align-items-center" + >/</span + > + <input + v-model="newName" + type="text" + class="form-control import-project-name-input js-new-name qa-project-path-field" + /> + </td> + <td><import-status :status="status" /></td> + <td> + <button + v-if="!isLoadingImport" + type="button" + class="qa-import-button js-import-button btn btn-default" + @click="importRepo" + > + {{ importButtonText }} + </button> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/import_projects/constants.js b/app/assets/javascripts/import_projects/constants.js new file mode 100644 index 00000000000..ad33ca158d2 --- /dev/null +++ b/app/assets/javascripts/import_projects/constants.js @@ -0,0 +1,48 @@ +import { __ } from '../locale'; + +// The `scheduling` status is only present on the client-side, +// it is used as the status when we are requesting to start an import. + +export const STATUSES = { + FINISHED: 'finished', + FAILED: 'failed', + SCHEDULED: 'scheduled', + STARTED: 'started', + NONE: 'none', + SCHEDULING: 'scheduling', +}; + +const STATUS_MAP = { + [STATUSES.FINISHED]: { + icon: 'success', + text: __('Done'), + textClass: 'text-success', + }, + [STATUSES.FAILED]: { + icon: 'failed', + text: __('Failed'), + textClass: 'text-danger', + }, + [STATUSES.SCHEDULED]: { + icon: 'pending', + text: __('Scheduled'), + textClass: 'text-warning', + }, + [STATUSES.STARTED]: { + icon: 'running', + text: __('Running…'), + textClass: 'text-info', + }, + [STATUSES.NONE]: { + icon: 'created', + text: __('Not started'), + textClass: 'text-muted', + }, + [STATUSES.SCHEDULING]: { + loadingIcon: true, + text: __('Scheduling'), + textClass: 'text-warning', + }, +}; + +export default STATUS_MAP; diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/import_projects/event_hub.js index 0948c2e5352..0948c2e5352 100644 --- a/app/assets/javascripts/monitoring/event_hub.js +++ b/app/assets/javascripts/import_projects/event_hub.js diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js new file mode 100644 index 00000000000..5c77484aee1 --- /dev/null +++ b/app/assets/javascripts/import_projects/index.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import { mapActions } from 'vuex'; +import Translate from '../vue_shared/translate'; +import ImportProjectsTable from './components/import_projects_table.vue'; +import { parseBoolean } from '../lib/utils/common_utils'; +import store from './store'; + +Vue.use(Translate); + +export default function mountImportProjectsTable(mountElement) { + if (!mountElement) return undefined; + + const { + reposPath, + provider, + providerTitle, + canSelectNamespace, + jobsPath, + importPath, + ciCdOnly, + } = mountElement.dataset; + + return new Vue({ + el: mountElement, + store, + + created() { + this.setInitialData({ + reposPath, + provider, + jobsPath, + importPath, + defaultTargetNamespace: gon.current_username, + ciCdOnly: parseBoolean(ciCdOnly), + canSelectNamespace: parseBoolean(canSelectNamespace), + }); + }, + + methods: { + ...mapActions(['setInitialData']), + }, + + render(createElement) { + return createElement(ImportProjectsTable, { props: { providerTitle } }); + }, + }); +} diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js new file mode 100644 index 00000000000..c44500937cc --- /dev/null +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -0,0 +1,106 @@ +import Visibility from 'visibilityjs'; +import * as types from './mutation_types'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import Poll from '~/lib/utils/poll'; +import createFlash from '~/flash'; +import { s__, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; + +let eTagPoll; + +export const clearJobsEtagPoll = () => { + eTagPoll = null; +}; +export const stopJobsPolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; +export const restartJobsPolling = () => { + if (eTagPoll) eTagPoll.restart(); +}; + +export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); + +export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos); +export const receiveReposSuccess = ({ commit }, repos) => + commit(types.RECEIVE_REPOS_SUCCESS, repos); +export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR); +export const fetchRepos = ({ state, dispatch }) => { + dispatch('requestRepos'); + + return axios + .get(state.reposPath) + .then(({ data }) => + dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })), + ) + .then(() => dispatch('fetchJobs')) + .catch(() => { + createFlash( + sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { + provider: state.provider, + }), + ); + + dispatch('receiveReposError'); + }); +}; + +export const requestImport = ({ commit, state }, repoId) => { + if (!state.reposBeingImported.includes(repoId)) commit(types.REQUEST_IMPORT, repoId); +}; +export const receiveImportSuccess = ({ commit }, { importedProject, repoId }) => + commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject, repoId }); +export const receiveImportError = ({ commit }, repoId) => + commit(types.RECEIVE_IMPORT_ERROR, repoId); +export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, repo }) => { + dispatch('requestImport', repo.id); + + return axios + .post(state.importPath, { + ci_cd_only: state.ciCdOnly, + new_name: newName, + repo_id: repo.id, + target_namespace: targetNamespace, + }) + .then(({ data }) => + dispatch('receiveImportSuccess', { + importedProject: convertObjectPropsToCamelCase(data, { deep: true }), + repoId: repo.id, + }), + ) + .catch(() => { + createFlash(s__('ImportProjects|Importing the project failed')); + + dispatch('receiveImportError', { repoId: repo.id }); + }); +}; + +export const receiveJobsSuccess = ({ commit }, updatedProjects) => + commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects); +export const fetchJobs = ({ state, dispatch }) => { + if (eTagPoll) return; + + eTagPoll = new Poll({ + resource: { + fetchJobs: () => axios.get(state.jobsPath), + }, + method: 'fetchJobs', + successCallback: ({ data }) => + dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })), + errorCallback: () => createFlash(s__('ImportProjects|Updating the imported projects failed')), + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + dispatch('restartJobsPolling'); + } else { + dispatch('stopJobsPolling'); + } + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js new file mode 100644 index 00000000000..f03474a8404 --- /dev/null +++ b/app/assets/javascripts/import_projects/store/getters.js @@ -0,0 +1,20 @@ +export const namespaceSelectOptions = state => { + const serializedNamespaces = state.namespaces.map(({ fullPath }) => ({ + id: fullPath, + text: fullPath, + })); + + return [ + { text: 'Groups', children: serializedNamespaces }, + { + text: 'Users', + children: [{ id: state.defaultTargetNamespace, text: state.defaultTargetNamespace }], + }, + ]; +}; + +export const isImportingAnyRepo = state => state.reposBeingImported.length > 0; + +export const hasProviderRepos = state => state.providerRepos.length > 0; + +export const hasImportedProjects = state => state.importedProjects.length > 0; diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js new file mode 100644 index 00000000000..6ac9bfd8189 --- /dev/null +++ b/app/assets/javascripts/import_projects/store/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: state(), + actions, + mutations, + getters, +}); diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js new file mode 100644 index 00000000000..6ba3fd6f29e --- /dev/null +++ b/app/assets/javascripts/import_projects/store/mutation_types.js @@ -0,0 +1,11 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; + +export const REQUEST_REPOS = 'REQUEST_REPOS'; +export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS'; +export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR'; + +export const REQUEST_IMPORT = 'REQUEST_IMPORT'; +export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS'; +export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR'; + +export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js new file mode 100644 index 00000000000..b88de0268e7 --- /dev/null +++ b/app/assets/javascripts/import_projects/store/mutations.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_DATA](state, data) { + Object.assign(state, data); + }, + + [types.REQUEST_REPOS](state) { + state.isLoadingRepos = true; + }, + + [types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) { + state.isLoadingRepos = false; + + state.importedProjects = importedProjects; + state.providerRepos = providerRepos; + state.namespaces = namespaces; + }, + + [types.RECEIVE_REPOS_ERROR](state) { + state.isLoadingRepos = false; + }, + + [types.REQUEST_IMPORT](state, repoId) { + state.reposBeingImported.push(repoId); + }, + + [types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }) { + const existingRepoIndex = state.reposBeingImported.indexOf(repoId); + if (state.reposBeingImported.includes(repoId)) + state.reposBeingImported.splice(existingRepoIndex, 1); + + const providerRepoIndex = state.providerRepos.findIndex( + providerRepo => providerRepo.id === repoId, + ); + state.providerRepos.splice(providerRepoIndex, 1); + state.importedProjects.unshift(importedProject); + }, + + [types.RECEIVE_IMPORT_ERROR](state, repoId) { + const repoIndex = state.reposBeingImported.indexOf(repoId); + if (state.reposBeingImported.includes(repoId)) state.reposBeingImported.splice(repoIndex, 1); + }, + + [types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) { + updatedProjects.forEach(updatedProject => { + const existingProject = state.importedProjects.find( + importedProject => importedProject.id === updatedProject.id, + ); + + Vue.set(existingProject, 'importStatus', updatedProject.importStatus); + }); + }, +}; diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js new file mode 100644 index 00000000000..637fef6e53c --- /dev/null +++ b/app/assets/javascripts/import_projects/store/state.js @@ -0,0 +1,15 @@ +export default () => ({ + reposPath: '', + importPath: '', + jobsPath: '', + currentProjectId: '', + provider: '', + currentUsername: '', + importedProjects: [], + providerRepos: [], + namespaces: [], + reposBeingImported: [], + isLoadingRepos: false, + canSelectNamespace: false, + ciCdOnly: false, +}); diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js index 612c524ca1c..e0fb58ef195 100644 --- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -11,10 +11,14 @@ class AutoWidthDropdownSelect { init() { const { dropdownClass } = this; - this.$selectElement.select2({ - dropdownCssClass: dropdownClass, - ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), - }); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), + }); + }) + .catch(() => {}); return this; } diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index f3d722409b0..48e7ed1318d 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -7,10 +7,14 @@ export default class IssuableContext { constructor(currentUser) { this.userSelect = new UsersSelect(currentUser); - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + }) + .catch(() => {}); $('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() { return $(this).submit(); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index c81a2230310..9336b71cfd7 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -44,6 +44,7 @@ export default class IssuableForm { parse: dateString => parsePikadayDate(dateString), toString: date => pikadayToString(date), onSelect: dateText => $issuableDueDate.val(calendar.toString(dateText)), + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($issuableDueDate.val())); } @@ -120,35 +121,39 @@ export default class IssuableForm { } initTargetBranchDropdown() { - this.$targetBranchSelect.select2({ - ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), - ajax: { - url: this.$targetBranchSelect.data('endpoint'), - dataType: 'JSON', - quietMillis: 250, - data(search) { - return { - search, - }; - }, - results(data) { - return { - // `data` keys are translated so we can't just access them with a string based key - results: data[Object.keys(data)[0]].map(name => ({ - id: name, - text: name, - })), - }; - }, - }, - initSelection(el, callback) { - const val = el.val(); - - callback({ - id: val, - text: val, + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + this.$targetBranchSelect.select2({ + ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), + ajax: { + url: this.$targetBranchSelect.data('endpoint'), + dataType: 'JSON', + quietMillis: 250, + data(search) { + return { + search, + }; + }, + results(data) { + return { + // `data` keys are translated so we can't just access them with a string based key + results: data[Object.keys(data)[0]].map(name => ({ + id: name, + text: name, + })), + }; + }, + }, + initSelection(el, callback) { + const val = el.val(); + + callback({ + id: val, + text: val, + }); + }, }); - }, - }); + }) + .catch(() => {}); } } diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js index 2c80cf1797a..40916c9d27f 100644 --- a/app/assets/javascripts/issuable_suggestions/index.js +++ b/app/assets/javascripts/issuable_suggestions/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import defaultClient from '~/lib/graphql'; +import createDefaultClient from '~/lib/graphql'; import App from './components/app.vue'; Vue.use(VueApollo); @@ -10,7 +10,7 @@ export default function() { const issueTitle = document.getElementById('issue_title'); const { projectPath } = el.dataset; const apolloProvider = new VueApollo({ - defaultClient, + defaultClient: createDefaultClient(), }); return new Vue({ diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index e4e2eab2acd..bd757a76ee7 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -1,5 +1,7 @@ <script> import Visibility from 'visibilityjs'; +import { __, s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; import { visitUrl } from '../../lib/utils/url_utility'; import Poll from '../../lib/utils/poll'; import eventHub from '../event_hub'; @@ -106,11 +108,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, projectPath: { type: String, required: true, @@ -129,6 +126,11 @@ export default { required: false, default: true, }, + lockVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { const store = new Store({ @@ -140,6 +142,7 @@ export default { updatedByName: this.updatedByName, updatedByPath: this.updatedByPath, taskStatus: this.initialTaskStatus, + lock_version: this.lockVersion, }); return { @@ -160,6 +163,9 @@ export default { const titleChanged = this.initialTitleText !== this.store.formState.title; return descriptionChanged || titleChanged; }, + defaultErrorMessage() { + return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType }); + }, }, created() { this.service = new Service(this.endpoint); @@ -201,11 +207,22 @@ export default { methods: { handleBeforeUnloadEvent(e) { const event = e; - if (this.showForm && this.issueChanged) { - event.returnValue = 'Are you sure you want to lose your issue information?'; + if (this.showForm && this.issueChanged && !this.showRecaptcha) { + event.returnValue = __('Are you sure you want to lose your issue information?'); } return undefined; }, + updateStoreState() { + return this.service + .getData() + .then(res => res.data) + .then(data => { + this.store.updateState(data); + }) + .catch(() => { + createFlash(this.defaultErrorMessage); + }); + }, openForm() { if (!this.showForm) { @@ -213,6 +230,7 @@ export default { this.store.setFormState({ title: this.state.titleText, description: this.state.descriptionText, + lock_version: this.state.lock_version, lockedWarningVisible: false, updateLoading: false, }); @@ -231,20 +249,24 @@ export default { if (window.location.pathname !== data.web_url) { visitUrl(data.web_url); } - - return this.service.getData(); }) - .then(res => res.data) - .then(data => { - this.store.updateState(data); + .then(this.updateStoreState) + .then(() => { eventHub.$emit('close.form'); }) - .catch(error => { - if (error && error.name === 'SpamError') { + .catch((error = {}) => { + const { name, response = {} } = error; + + if (name === 'SpamError') { this.openRecaptcha(); } else { - eventHub.$emit('close.form'); - window.Flash(`Error updating ${this.issuableType}`); + let errMsg = this.defaultErrorMessage; + + if (response.data && response.data.errors) { + errMsg += `. ${response.data.errors.join(' ')}`; + } + + createFlash(errMsg); } }); }, @@ -268,8 +290,9 @@ export default { visitUrl(data.web_url); }) .catch(() => { - eventHub.$emit('close.form'); - window.Flash(`Error deleting ${this.issuableType}`); + createFlash( + sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }), + ); }); }, }, @@ -285,7 +308,6 @@ export default { :issuable-templates="issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" - :markdown-version="markdownVersion" :project-path="projectPath" :project-namespace="projectNamespace" :show-delete-button="showDeleteButton" @@ -313,6 +335,8 @@ export default { :task-status="state.taskStatus" :issuable-type="issuableType" :update-url="updateEndpoint" + :lock-version="state.lock_version" + @taskListUpdateFailed="updateStoreState" /> <edited-component v-if="hasUpdated" diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 5ca88d75063..58f14bac8c8 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -1,5 +1,7 @@ <script> import $ from 'jquery'; +import { s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; import animateMixin from '../mixins/animate'; import TaskList from '../../task_list'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; @@ -35,6 +37,11 @@ export default { required: false, default: null, }, + lockVersion: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -67,8 +74,10 @@ export default { new TaskList({ dataType: this.issuableType, fieldName: 'description', + lockVersion: this.lockVersion, selector: '.detail-page-description', onSuccess: this.taskListUpdateSuccess.bind(this), + onError: this.taskListUpdateError.bind(this), }); } }, @@ -82,6 +91,21 @@ export default { } }, + taskListUpdateError() { + createFlash( + sprintf( + s__( + 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.', + ), + { + issueType: this.issuableType, + }, + ), + ); + + this.$emit('taskListUpdateFailed'); + }, + updateTaskStatusText() { const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); const $issuableHeader = $('.issuable-meta'); diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 90258c0e044..299130e56ae 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -20,11 +20,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, canAttachFile: { type: Boolean, required: false, @@ -48,7 +43,6 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" > diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 45b60bc3392..eade31f1d14 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -39,11 +39,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, projectPath: { type: String, required: true, @@ -101,7 +96,6 @@ export default { :form-state="formState" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" /> diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 32044d6da25..3c17e73ccec 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -1,3 +1,5 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + export default class Store { constructor(initialState) { this.state = initialState; @@ -6,6 +8,7 @@ export default class Store { description: '', lockedWarningVisible: false, updateLoading: false, + lock_version: 0, }; } @@ -14,14 +17,10 @@ export default class Store { this.formState.lockedWarningVisible = true; } + Object.assign(this.state, convertObjectPropsToCamelCase(data)); this.state.titleHtml = data.title; - this.state.titleText = data.title_text; this.state.descriptionHtml = data.description; - this.state.descriptionText = data.description_text; - this.state.taskStatus = data.task_status; - this.state.updatedAt = data.updated_at; - this.state.updatedByName = data.updated_by_name; - this.state.updatedByPath = data.updated_by_path; + this.state.lock_version = data.lock_version; } stateShouldUpdate(data) { diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index a2141dc3760..1691ac62100 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -110,7 +110,7 @@ export default { <div class="sidebar-container"> <div class="blocks-container"> <div class="block d-flex flex-nowrap align-items-center"> - <h4 class="my-0 mr-2">{{ job.name }}</h4> + <h4 class="my-0 mr-2 text-break-word">{{ job.name }}</h4> <div class="flex-grow-1 flex-shrink-0 text-right"> <gl-link v-if="job.retry_path" diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 91332c21b52..c5076d65ff9 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -39,7 +39,9 @@ export default { <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> <span class="font-weight-bold">{{ __('Pipeline') }}</span> - <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a> + <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path" + >#{{ pipeline.id }}</a + > <template v-if="hasRef"> {{ __('from') }} <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a> diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index 062501d1d04..f134a54dd53 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -70,7 +70,18 @@ export default class LabelManager { const $detachedLabel = $label.detach(); this.toggleLabelPriorityBadge($detachedLabel, action); - $detachedLabel.appendTo($target); + + const $labelEls = $target.find('li.label-list-item'); + + /* + * If there is a label element in the target, we'd want to + * append the new label just right next to it. + */ + if ($labelEls.length) { + $labelEls.last().after($detachedLabel); + } else { + $detachedLabel.appendTo($target); + } if ($from.find('li').length) { $from.find('.empty-message').removeClass('hidden'); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 20a0f142d9e..64e4e899f44 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,9 +1,11 @@ import ApolloClient from 'apollo-boost'; import csrf from '~/lib/utils/csrf'; -export default new ApolloClient({ - uri: `${gon.relative_url_root}/api/graphql`, - headers: { - [csrf.headerKey]: csrf.token, - }, -}); +export default (clientState = {}) => + new ApolloClient({ + uri: `${gon.relative_url_root}/api/graphql`, + headers: { + [csrf.headerKey]: csrf.token, + }, + clientState, + }); diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js new file mode 100644 index 00000000000..0f78756aac8 --- /dev/null +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -0,0 +1,83 @@ +const commonTooltips = () => ({ + mode: 'x', + intersect: false, +}); + +const adjustedFontScale = () => ({ + fontSize: 8, +}); + +const yAxesConfig = (shouldAdjustFontSize = false) => ({ + yAxes: [ + { + ticks: { + beginAtZero: true, + ...(shouldAdjustFontSize ? adjustedFontScale() : {}), + }, + }, + ], +}); + +const xAxesConfig = (shouldAdjustFontSize = false) => ({ + xAxes: [ + { + ticks: { + ...(shouldAdjustFontSize ? adjustedFontScale() : {}), + }, + }, + ], +}); + +const commonChartOptions = () => ({ + responsive: true, + maintainAspectRatio: false, + legend: false, +}); + +export const barChartOptions = shouldAdjustFontSize => ({ + ...commonChartOptions(), + scales: { + ...yAxesConfig(shouldAdjustFontSize), + ...xAxesConfig(shouldAdjustFontSize), + }, + tooltips: { + ...commonTooltips(), + displayColors: false, + callbacks: { + title() { + return ''; + }, + label({ xLabel, yLabel }) { + return `${xLabel}: ${yLabel}`; + }, + }, + }, +}); + +export const pieChartOptions = commonChartOptions; + +export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize }) => ({ + ...commonChartOptions(), + scales: { + ...yAxesConfig(shouldAdjustFontSize), + ...xAxesConfig(shouldAdjustFontSize), + }, + elements: { + point: { + hitRadius: width / (numberOfPoints * 2), + }, + }, + tooltips: { + ...commonTooltips(), + caretSize: 0, + multiKeyBackground: 'rgba(0,0,0,0)', + callbacks: { + labelColor({ datasetIndex }, { config }) { + return { + backgroundColor: config.data.datasets[datasetIndex].backgroundColor, + borderColor: 'rgba(0,0,0,0)', + }; + }, + }, + }, +}); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 3b6a57dad44..a73cdb73690 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -130,7 +130,7 @@ export const isInViewport = (el, offset = {}) => { rect.top >= (top || 0) && rect.left >= (left || 0) && rect.bottom <= window.innerHeight && - rect.right <= window.innerWidth + parseInt(rect.right, 10) <= window.innerWidth ); }; @@ -221,6 +221,22 @@ export const scrollToElement = element => { }; /** + * Returns a function that can only be invoked once between + * each browser screen repaint. + * @param {Function} fn + */ +export const debounceByAnimationFrame = fn => { + let requestId; + + return function debounced(...args) { + if (requestId) { + window.cancelAnimationFrame(requestId); + } + requestId = window.requestAnimationFrame(() => fn.apply(this, args)); + }; +}; + +/** this will take in the `name` of the param you want to parse in the url if the name does not exist this function will return `null` otherwise it will return the value of the param key provided @@ -440,21 +456,6 @@ export const historyPushState = newUrl => { export const parseBoolean = value => (value && value.toString()) === 'true'; /** - * Converts permission provided as strings to booleans. - * - * @param {String} string - * @returns {Boolean} - */ -export const convertPermissionToBoolean = permission => { - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.warn('convertPermissionToBoolean is deprecated! Please use parseBoolean instead.'); - } - - return parseBoolean(permission); -}; - -/** * @callback backOffCallback * @param {Function} next * @param {Function} stop @@ -614,10 +615,18 @@ export const spriteIcon = (icon, className = '') => { /** * This method takes in object with snake_case property names - * and returns new object with camelCase property names + * and returns a new object with camelCase property names * * Reasoning for this method is to ensure consistent property * naming conventions across JS code. + * + * This method also supports additional params in `options` object + * + * @param {Object} obj - Object to be converted. + * @param {Object} options - Object containing additional options. + * @param {boolean} options.deep - FLag to allow deep object converting + * @param {Array[]} dropKeys - List of properties to discard while building new object + * @param {Array[]} ignoreKeyNames - List of properties to leave intact (as snake_case) while building new object */ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => { if (obj === null) { @@ -625,12 +634,26 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => { } const initial = Array.isArray(obj) ? [] : {}; + const { deep = false, dropKeys = [], ignoreKeyNames = [] } = options; return Object.keys(obj).reduce((acc, prop) => { const result = acc; const val = obj[prop]; - if (options.deep && (isObject(val) || Array.isArray(val))) { + // Drop properties from new object if + // there are any mentioned in options + if (dropKeys.indexOf(prop) > -1) { + return acc; + } + + // Skip converting properties in new object + // if there are any mentioned in options + if (ignoreKeyNames.indexOf(prop) > -1) { + result[prop] = obj[prop]; + return acc; + } + + if (deep && (isObject(val) || Array.isArray(val))) { result[convertToCamelCase(prop)] = convertObjectPropsToCamelCase(val, options); } else { result[convertToCamelCase(prop)] = obj[prop]; diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js index b41ffb44971..82ee83e4348 100644 --- a/app/assets/javascripts/lib/utils/file_upload.js +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -1,6 +1,9 @@ export default (buttonSelector, fileSelector) => { const btn = document.querySelector(buttonSelector); const fileInput = document.querySelector(fileSelector); + + if (!btn || !fileInput) return; + const form = btn.closest('form'); btn.addEventListener('click', () => { diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js new file mode 100644 index 00000000000..18f9e2ed846 --- /dev/null +++ b/app/assets/javascripts/lib/utils/grammar.js @@ -0,0 +1,40 @@ +import { sprintf, s__ } from '~/locale'; + +/** + * Combines each given item into a noun series sentence fragment. It does this + * in a way that supports i18n by giving context and punctuation to the locale + * functions. + * + * **Examples:** + * + * - `["A", "B"] => "A and B"` + * - `["A", "B", "C"] => "A, B, and C"` + * + * **Why only nouns?** + * + * Some languages need a bit more context to translate other series. + * + * @param {String[]} items + */ +export const toNounSeriesText = items => { + if (items.length === 0) { + return ''; + } else if (items.length === 1) { + return items[0]; + } else if (items.length === 2) { + return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), { + firstItem: items[0], + lastItem: items[1], + }); + } + + return items.reduce((item, nextItem, idx) => + idx === items.length - 1 + ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }) + : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }), + ); +}; + +export default { + toNounSeriesText, +}; diff --git a/app/assets/javascripts/lib/utils/icon_utils.js b/app/assets/javascripts/lib/utils/icon_utils.js new file mode 100644 index 00000000000..7b8dd9bbef7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/icon_utils.js @@ -0,0 +1,18 @@ +/* eslint-disable import/prefer-default-export */ + +import axios from '~/lib/utils/axios_utils'; + +/** + * Retrieve SVG icon path content from gitlab/svg sprite icons + * @param {String} name + */ +export const getSvgIconPathContent = name => + axios + .get(gon.sprite_icons) + .then(({ data: svgs }) => + new DOMParser() + .parseFromString(svgs, 'text/xml') + .querySelector(`#${name} path`) + .getAttribute('d'), + ) + .catch(() => null); diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js index 198711cf427..a900ff34bf5 100644 --- a/app/assets/javascripts/lib/utils/poll.js +++ b/app/assets/javascripts/lib/utils/poll.js @@ -63,6 +63,10 @@ export default class Poll { const headers = normalizeHeaders(response.headers); const pollInterval = parseInt(headers[this.intervalHeader], 10); if (pollInterval > 0 && successCodes.indexOf(response.status) !== -1 && this.canPoll) { + if (this.timeoutID) { + clearTimeout(this.timeoutID); + } + this.timeoutID = setTimeout(() => { this.makeRequest(); }, pollInterval); @@ -101,15 +105,25 @@ export default class Poll { } /** - * Restarts polling after it has been stoped + * Enables polling after it has been stopped */ - restart(options) { - // update data + enable(options) { if (options && options.data) { this.options.data = options.data; } this.canPoll = true; + + if (options && options.response) { + this.checkConditions(options.response); + } + } + + /** + * Restarts polling after it has been stopped and makes a request + */ + restart(options) { + this.enable(options); this.makeRequest(); } } diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js new file mode 100644 index 00000000000..308ad9784e4 --- /dev/null +++ b/app/assets/javascripts/lib/utils/webpack.js @@ -0,0 +1,10 @@ +// tell webpack to load assets from origin so that web workers don't break +// eslint-disable-next-line import/prefer-default-export +export function resetServiceWorkersPublicPath() { + // __webpack_public_path__ is a global variable that can be used to adjust + // the webpack publicPath setting at runtime. + // see: https://webpack.js.org/guides/public-path/ + const relativeRootPath = (gon && gon.relative_url_root) || ''; + const webpackAssetPath = `${relativeRootPath}/assets/webpack/`; + __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 4ba3543f9b2..1b722c0505a 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -78,7 +78,6 @@ function deferredInitialisation() { initUserPopovers(); if (document.querySelector('.search')) initSearchAutocomplete(); - if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); addSelectOnFocusBehaviour('.js-select-on-focus'); @@ -100,27 +99,30 @@ function deferredInitialisation() { }); // Initialize select2 selects - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); - - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - setTimeout(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }, 1); - }); + if ($('select.select2').length) { + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + + // Close select2 on escape + $('.js-select2').on('select2-close', () => { + setTimeout(() => { + $('.select2-container-active').removeClass('select2-container-active'); + $(':focus').blur(); + }, 1); + }); + }) + .catch(() => {}); + } // Initialize tooltips $body.tooltip({ selector: '.has-tooltip, [data-toggle="tooltip"]', trigger: 'hover', boundary: 'viewport', - placement(tip, el) { - return $(el).data('placement') || 'bottom'; - }, }); // Initialize popovers @@ -142,6 +144,8 @@ document.addEventListener('DOMContentLoaded', () => { const $sidebarGutterToggle = $('.js-sidebar-toggle'); let bootstrapBreakpoint = bp.getBreakpointSize(); + if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); + initLayoutNav(); // Set the default path for all cookies to GitLab's root directory diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js index 0beedcacf33..0dabb28ea66 100644 --- a/app/assets/javascripts/member_expiration_date.js +++ b/app/assets/javascripts/member_expiration_date.js @@ -33,6 +33,7 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d toggleClearInput.call($input); }, + firstDay: gon.first_day_of_week, }); calendar.setDate(parsePikadayDate($input.val())); diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 0deae478deb..3b42a154af8 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { __ } from '~/locale'; +import createFlash from '~/flash'; import TaskList from './task_list'; import MergeRequestTabs from './merge_request_tabs'; import IssuablesHelper from './helpers/issuables_helper'; @@ -35,10 +36,18 @@ function MergeRequest(opts) { dataType: 'merge_request', fieldName: 'description', selector: '.detail-page-description', + lockVersion: this.$el.data('lockVersion'), onSuccess: result => { document.querySelector('#task_status').innerText = result.task_status; document.querySelector('#task_status_short').innerText = result.task_status_short; }, + onError: () => { + createFlash( + __( + 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.', + ), + ); + }, }); } } diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index b0dc5697018..2f15da42271 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -428,7 +428,7 @@ export default class MergeRequestTabs { } diffViewType() { - return $('.inline-parallel-buttons button.active').data('viewType'); + return $('.js-diff-view-buttons button.active').data('viewType'); } isDiffAction(action) { diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index e2cffe0b4b4..9e031b03579 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -1,10 +1,16 @@ <script> -import { GlAreaChart } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import Icon from '~/vue_shared/components/icon.vue'; + +let debouncedResize; export default { components: { GlAreaChart, + Icon, }, inheritAttrs: false, props: { @@ -26,22 +32,38 @@ export default { ); }, }, + containerWidth: { + type: Number, + required: true, + }, + deploymentData: { + type: Array, + required: false, + default: () => [], + }, alertData: { type: Object, required: false, default: () => ({}), }, }, + data() { + return { + tooltip: { + title: '', + content: '', + isDeployment: false, + sha: '', + }, + width: 0, + height: 0, + scatterSymbol: undefined, + }; + }, computed: { chartData() { return this.graphData.queries.reduce((accumulator, query) => { - const xLabel = `${query.unit}`; - accumulator[xLabel] = {}; - query.result.forEach(res => - res.values.forEach(v => { - accumulator[xLabel][v.time.toISOString()] = v.value; - }), - ); + accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []); return accumulator; }, {}); }, @@ -51,14 +73,17 @@ export default { name: 'Time', type: 'time', axisLabel: { - formatter: date => dateFormat(date, 'h:MMtt'), + formatter: date => dateFormat(date, 'h:MM TT'), + }, + axisPointer: { + snap: true, }, nameTextStyle: { padding: [18, 0, 0, 0], }, }, yAxis: { - name: this.graphData.y_label, + name: this.yAxisLabel, axisLabel: { formatter: value => value.toFixed(3), }, @@ -69,33 +94,128 @@ export default { legend: { formatter: this.xAxisLabel, }, + series: this.scatterSeries, + }; + }, + earliestDatapoint() { + return Object.values(this.chartData).reduce((acc, data) => { + const [[timestamp]] = data.sort(([a], [b]) => { + if (a < b) { + return -1; + } + return a > b ? 1 : 0; + }); + + return timestamp < acc || acc === null ? timestamp : acc; + }, null); + }, + recentDeployments() { + return this.deploymentData.reduce((acc, deployment) => { + if (deployment.created_at >= this.earliestDatapoint) { + acc.push({ + id: deployment.id, + createdAt: deployment.created_at, + sha: deployment.sha, + commitUrl: `${this.projectPath}/commit/${deployment.sha}`, + tag: deployment.tag, + tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null, + ref: deployment.ref.name, + showDeploymentFlag: false, + }); + } + + return acc; + }, []); + }, + scatterSeries() { + return { + type: 'scatter', + data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]), + symbol: this.scatterSymbol, + symbolSize: 14, }; }, xAxisLabel() { return this.graphData.queries.map(query => query.label).join(', '); }, + yAxisLabel() { + return `${this.graphData.y_label}`; + }, + }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', debouncedResize); + }, + created() { + debouncedResize = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', debouncedResize); + this.getScatterSymbol(); }, methods: { formatTooltipText(params) { - const [date, value] = params; - return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)]; + const [seriesData] = params.seriesData; + this.tooltip.isDeployment = seriesData.componentSubType === 'scatter'; + this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT'); + if (this.tooltip.isDeployment) { + const [deploy] = this.recentDeployments.filter( + deployment => deployment.createdAt === seriesData.value[0], + ); + this.tooltip.sha = deploy.sha.substring(0, 8); + } else { + this.tooltip.content = `${this.yAxisLabel} ${seriesData.value[1].toFixed(3)}`; + } + }, + getScatterSymbol() { + getSvgIconPathContent('rocket') + .then(path => { + if (path) { + this.scatterSymbol = `path://${path}`; + } + }) + .catch(() => {}); + }, + onResize() { + const { width, height } = this.$refs.areaChart.$el.getBoundingClientRect(); + this.width = width; + this.height = height; }, }, }; </script> <template> - <div class="prometheus-graph"> + <div class="prometheus-graph col-12 col-lg-6"> <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> - <div class="prometheus-graph-widgets"><slot></slot></div> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> </div> <gl-area-chart + ref="areaChart" v-bind="$attrs" :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" :thresholds="alertData" - /> + :width="width" + :height="height" + > + <template slot="tooltipTitle"> + <div v-if="tooltip.isDeployment"> + {{ __('Deployed') }} + </div> + {{ tooltip.title }} + </template> + <template slot="tooltipContent"> + <div v-if="tooltip.isDeployment" class="d-flex align-items-center"> + <icon name="commit" class="mr-2" /> + {{ tooltip.sha }} + </div> + <template v-else> + {{ tooltip.content }} + </template> + </template> + </gl-area-chart> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index cea5c1a56ca..895a57785bc 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,20 +1,19 @@ <script> -import _ from 'underscore'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Flash from '../../flash'; import MonitoringService from '../services/monitoring_service'; import MonitorAreaChart from './charts/area.vue'; import GraphGroup from './graph_group.vue'; -import Graph from './graph.vue'; import EmptyState from './empty_state.vue'; import MonitoringStore from '../stores/monitoring_store'; -import eventHub from '../event_hub'; + +const sidebarAnimationDuration = 150; +let sidebarMutationObserver; export default { components: { MonitorAreaChart, - Graph, GraphGroup, EmptyState, Icon, @@ -25,21 +24,11 @@ export default { required: false, default: true, }, - showLegend: { - type: Boolean, - required: false, - default: true, - }, showPanels: { type: Boolean, required: false, default: true, }, - forceSmallGraph: { - type: Boolean, - required: false, - default: false, - }, documentationPath: { type: String, required: true, @@ -99,48 +88,32 @@ export default { store: new MonitoringStore(), state: 'gettingStarted', showEmptyState: true, - hoverData: {}, elWidth: 0, }; }, - computed: { - graphComponent() { - return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph; - }, - forceRedraw() { - return this.elWidth; - }, - }, created() { this.service = new MonitoringService({ metricsEndpoint: this.metricsEndpoint, deploymentEndpoint: this.deploymentEndpoint, environmentsEndpoint: this.environmentsEndpoint, }); - this.mutationObserverConfig = { - attributes: true, - childList: false, - subtree: false, - }; - eventHub.$on('hoverChanged', this.hoverChanged); }, beforeDestroy() { - eventHub.$off('hoverChanged', this.hoverChanged); - window.removeEventListener('resize', this.resizeThrottled, false); - this.sidebarMutationObserver.disconnect(); + if (sidebarMutationObserver) { + sidebarMutationObserver.disconnect(); + } }, mounted() { - this.resizeThrottled = _.debounce(this.resize, 100); if (!this.hasMetrics) { this.state = 'gettingStarted'; } else { this.getGraphsData(); - window.addEventListener('resize', this.resizeThrottled, false); - - const sidebarEl = document.querySelector('.nav-sidebar'); - // The sidebar listener - this.sidebarMutationObserver = new MutationObserver(this.resizeThrottled); - this.sidebarMutationObserver.observe(sidebarEl, this.mutationObserverConfig); + sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); + sidebarMutationObserver.observe(document.querySelector('.layout-page'), { + attributes: true, + childList: false, + subtree: false, + }); } }, methods: { @@ -168,42 +141,40 @@ export default { this.showEmptyState = false; }) - .then(this.resize) .catch(() => { this.state = 'unableToConnect'; }); }, - resize() { - this.elWidth = this.$el.clientWidth; - }, - hoverChanged(data) { - this.hoverData = data; + onSidebarMutation() { + setTimeout(() => { + this.elWidth = this.$el.clientWidth; + }, sidebarAnimationDuration); }, }, }; </script> <template> - <div v-if="!showEmptyState" :key="forceRedraw" class="prometheus-graphs prepend-top-default"> + <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default"> <div class="environments d-flex align-items-center"> {{ s__('Metrics|Environment') }} <div class="dropdown prepend-left-10"> <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> - <span> {{ currentEnvironmentName }} </span> <icon name="chevron-down" /> + <span>{{ currentEnvironmentName }}</span> + <icon name="chevron-down" /> </button> <div v-if="store.environmentsData.length > 0" class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up" > <ul> - <li v-for="environment in store.environmentsData" :key="environment.latest.id"> + <li v-for="environment in store.environmentsData" :key="environment.id"> <a - :href="environment.latest.metrics_path" - :class="{ 'is-active': environment.latest.name == currentEnvironmentName }" + :href="environment.metrics_path" + :class="{ 'is-active': environment.name == currentEnvironmentName }" class="dropdown-item" + >{{ environment.name }}</a > - {{ environment.latest.name }} - </a> </li> </ul> </div> @@ -215,23 +186,15 @@ export default { :name="groupData.group" :show-panels="showPanels" > - <component - :is="graphComponent" + <monitor-area-chart v-for="(graphData, graphIndex) in groupData.metrics" :key="graphIndex" :graph-data="graphData" - :hover-data="hoverData" :deployment-data="store.deploymentData" - :project-path="projectPath" - :tags-path="tagsPath" - :show-legend="showLegend" - :small-graph="forceSmallGraph" :alert-data="getGraphAlerts(graphData.id)" + :container-width="elWidth" group-id="monitor-area-chart" - > - <!-- EE content --> - {{ null }} - </component> + /> </graph-group> </div> <empty-state diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue deleted file mode 100644 index 309b73f5a4d..00000000000 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ /dev/null @@ -1,329 +0,0 @@ -<script> -import { scaleLinear, scaleTime } from 'd3-scale'; -import { axisLeft, axisBottom } from 'd3-axis'; -import _ from 'underscore'; -import { max, extent } from 'd3-array'; -import { select } from 'd3-selection'; -import GraphAxis from './graph/axis.vue'; -import GraphLegend from './graph/legend.vue'; -import GraphFlag from './graph/flag.vue'; -import GraphDeployment from './graph/deployment.vue'; -import GraphPath from './graph/path.vue'; -import MonitoringMixin from '../mixins/monitoring_mixins'; -import eventHub from '../event_hub'; -import measurements from '../utils/measurements'; -import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters'; -import createTimeSeries from '../utils/multiple_time_series'; -import bp from '../../breakpoints'; - -const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }; - -export default { - components: { - GraphAxis, - GraphFlag, - GraphDeployment, - GraphPath, - GraphLegend, - }, - mixins: [MonitoringMixin], - props: { - graphData: { - type: Object, - required: true, - }, - deploymentData: { - type: Array, - required: true, - }, - hoverData: { - type: Object, - required: false, - default: () => ({}), - }, - projectPath: { - type: String, - required: true, - }, - tagsPath: { - type: String, - required: true, - }, - showLegend: { - type: Boolean, - required: false, - default: true, - }, - smallGraph: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - baseGraphHeight: 450, - baseGraphWidth: 600, - graphHeight: 450, - graphWidth: 600, - graphHeightOffset: 120, - margin: {}, - unitOfDisplay: '', - yAxisLabel: '', - legendTitle: '', - reducedDeploymentData: [], - measurements: measurements.large, - currentData: { - time: new Date(), - value: 0, - }, - currentXCoordinate: 0, - currentCoordinates: {}, - showFlag: false, - showFlagContent: false, - timeSeries: [], - graphDrawData: {}, - realPixelRatio: 1, - seriesUnderMouse: [], - }; - }, - computed: { - outerViewBox() { - return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`; - }, - innerViewBox() { - return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`; - }, - axisTransform() { - return `translate(70, ${this.graphHeight - 100})`; - }, - paddingBottomRootSvg() { - return { - paddingBottom: `${Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth || 0}%`, - }; - }, - deploymentFlagData() { - return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag); - }, - shouldRenderData() { - return this.graphData.queries.filter(s => s.result.length > 0).length > 0; - }, - }, - watch: { - hoverData() { - this.positionFlag(); - }, - }, - mounted() { - this.draw(); - }, - methods: { - showDot(path) { - return this.showFlagContent && this.seriesUnderMouse.includes(path); - }, - draw() { - const breakpointSize = bp.getBreakpointSize(); - const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width; - - this.margin = measurements.large.margin; - - if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') { - this.graphHeight = 300; - this.margin = measurements.small.margin; - this.measurements = measurements.small; - } - - this.yAxisLabel = this.graphData.y_label || 'Values'; - this.graphWidth = svgWidth - this.margin.left - this.margin.right; - this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; - this.baseGraphHeight = this.graphHeight - 50; - this.baseGraphWidth = this.graphWidth; - - // pixel offsets inside the svg and outside are not 1:1 - this.realPixelRatio = svgWidth / this.baseGraphWidth; - - // set the legends on the axes - const [query] = this.graphData.queries; - this.legendTitle = query ? query.label : 'Average'; - this.unitOfDisplay = query ? query.unit : ''; - - if (this.shouldRenderData) { - this.renderAxesPaths(); - this.formatDeployments(); - } - }, - handleMouseOverGraph(e) { - let point = this.$refs.graphData.createSVGPoint(); - point.x = e.clientX; - point.y = e.clientY; - point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); - point.x += 7; - - this.seriesUnderMouse = this.timeSeries.filter(series => { - const mouseX = series.timeSeriesScaleX.invert(point.x); - let minDistance = Infinity; - - const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => { - const distance = Math.abs(Number(new Date(x)) - Number(mouseX)); - if (distance < minDistance) { - minDistance = distance; - return x; - } - return closest; - }); - - return series.values.find(v => v.time.toString() === closestTickMark); - }); - - const firstTimeSeries = this.seriesUnderMouse[0]; - const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); - const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); - const d0 = firstTimeSeries.values[overlayIndex - 1]; - const d1 = firstTimeSeries.values[overlayIndex]; - if (d0 === undefined || d1 === undefined) return; - const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; - const hoveredDataIndex = evalTime ? overlayIndex : overlayIndex - 1; - const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time; - const currentDeployXPos = this.mouseOverDeployInfo(point.x); - - eventHub.$emit('hoverChanged', { - hoveredDate, - currentDeployXPos, - }); - }, - renderAxesPaths() { - ({ timeSeries: this.timeSeries, graphDrawData: this.graphDrawData } = createTimeSeries( - this.graphData.queries, - this.graphWidth, - this.graphHeight, - this.graphHeightOffset, - )); - - if (_.findWhere(this.timeSeries, { renderCanary: true })) { - this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true })); - } - - const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]); - const axisYScale = d3.scaleLinear().range([this.graphHeight - this.graphHeightOffset, 0]); - - const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []); - axisXScale.domain(d3.extent(allValues, d => d.time)); - axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); - - this.allXAxisValues = this.timeSeries.reduce((obj, series) => { - const seriesKeys = {}; - series.values.forEach(v => { - seriesKeys[v.time] = true; - }); - return { - ...obj, - ...seriesKeys, - }; - }, {}); - - const xAxis = d3 - .axisBottom() - .scale(axisXScale) - .ticks(this.graphWidth / 120) - .tickFormat(timeScaleFormat); - - const yAxis = d3 - .axisLeft() - .scale(axisYScale) - .ticks(measurements.yTicks); - - d3.select(this.$refs.baseSvg) - .select('.x-axis') - .call(xAxis); - - const width = this.graphWidth; - d3.select(this.$refs.baseSvg) - .select('.y-axis') - .call(yAxis) - .selectAll('.tick') - .each(function createTickLines(d, i) { - if (i > 0) { - d3.select(this) - .select('line') - .attr('x2', width) - .attr('class', 'axis-tick'); - } // Avoid adding the class to the first tick, to prevent coloring - }); // This will select all of the ticks once they're rendered - }, - }, -}; -</script> - -<template> - <div - class="prometheus-graph" - @mouseover="showFlagContent = true" - @mouseleave="showFlagContent = false" - > - <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title">{{ graphData.title }}</h5> - <div class="prometheus-graph-widgets"><slot></slot></div> - </div> - <div :style="paddingBottomRootSvg" class="prometheus-svg-container"> - <svg ref="baseSvg" :viewBox="outerViewBox"> - <g :transform="axisTransform" class="x-axis" /> - <g class="y-axis" transform="translate(70, 20)" /> - <graph-axis - :graph-width="graphWidth" - :graph-height="graphHeight" - :margin="margin" - :measurements="measurements" - :y-axis-label="yAxisLabel" - :unit-of-display="unitOfDisplay" - /> - <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data"> - <slot name="additionalSvgContent" :graphDrawData="graphDrawData" /> - <graph-path - v-for="(path, index) in timeSeries" - :key="index" - :generated-line-path="path.linePath" - :generated-area-path="path.areaPath" - :line-style="path.lineStyle" - :line-color="path.lineColor" - :area-color="path.areaColor" - :current-coordinates="currentCoordinates[path.metricTag]" - :show-dot="showDot(path)" - /> - <graph-deployment - :deployment-data="reducedDeploymentData" - :graph-height="graphHeight" - :graph-height-offset="graphHeightOffset" - /> - <rect - ref="graphOverlay" - :width="graphWidth - 70" - :height="graphHeight - 100" - class="prometheus-graph-overlay" - transform="translate(-5, 20)" - @mousemove="handleMouseOverGraph($event)" - /> - </svg> - <svg v-else :viewBox="innerViewBox" class="js-no-data-to-display"> - <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle"> - {{ s__('Metrics|No data to display') }} - </text> - </svg> - </svg> - <graph-flag - v-if="shouldRenderData" - :real-pixel-ratio="realPixelRatio" - :current-x-coordinate="currentXCoordinate" - :current-data="currentData" - :graph-height="graphHeight" - :graph-height-offset="graphHeightOffset" - :show-flag-content="showFlagContent" - :time-series="seriesUnderMouse" - :unit-of-display="unitOfDisplay" - :legend-title="legendTitle" - :deployment-flag-data="deploymentFlagData" - :current-coordinates="currentCoordinates" - /> - </div> - <graph-legend v-if="showLegend" :legend-title="legendTitle" :time-series="timeSeries" /> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue deleted file mode 100644 index 8f046857a20..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/axis.vue +++ /dev/null @@ -1,118 +0,0 @@ -<script> -import { convertToSentenceCase } from '~/lib/utils/text_utility'; -import { s__ } from '~/locale'; - -export default { - props: { - graphWidth: { - type: Number, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - margin: { - type: Object, - required: true, - }, - measurements: { - type: Object, - required: true, - }, - yAxisLabel: { - type: String, - required: true, - }, - unitOfDisplay: { - type: String, - required: true, - }, - }, - data() { - return { - yLabelWidth: 0, - yLabelHeight: 0, - }; - }, - computed: { - textTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0; - - return `translate(15, ${yCoordinate}) rotate(-90)`; - }, - - rectTransform() { - const yCoordinate = - (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 + - this.yLabelWidth / 2 || 0; - - return `translate(0, ${yCoordinate}) rotate(-90)`; - }, - - xPosition() { - return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0; - }, - - yPosition() { - return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0; - }, - - yAxisLabelSentenceCase() { - return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`; - }, - - timeString() { - return s__('PrometheusDashboard|Time'); - }, - }, - mounted() { - this.$nextTick(() => { - const bbox = this.$refs.ylabel.getBBox(); - this.yLabelWidth = bbox.width + 10; // Added some padding - this.yLabelHeight = bbox.height + 5; - }); - }, -}; -</script> -<template> - <g class="axis-label-container"> - <line - :y1="yPosition" - :x2="graphWidth + 20" - :y2="yPosition" - class="label-x-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - /> - <line - :x2="10" - :y2="yPosition" - class="label-y-axis-line" - stroke="#000000" - stroke-width="1" - x1="10" - y1="0" - /> - <rect - :transform="rectTransform" - :width="yLabelWidth" - :height="yLabelHeight" - class="rect-axis-text" - /> - <text - ref="ylabel" - :transform="textTransform" - class="label-axis-text y-label-text" - text-anchor="middle" - > - {{ yAxisLabelSentenceCase }} - </text> - <rect :x="xPosition + 60" :y="graphHeight - 80" class="rect-axis-text" width="35" height="50" /> - <text :x="xPosition + 60" :y="yPosition" class="label-axis-text x-label-text" dy=".35em"> - {{ timeString }} - </text> - </g> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue deleted file mode 100644 index bee9784692c..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/deployment.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -export default { - props: { - deploymentData: { - type: Array, - required: true, - }, - graphHeight: { - type: Number, - required: true, - }, - graphHeightOffset: { - type: Number, - required: true, - }, - }, - computed: { - calculatedHeight() { - return this.graphHeight - this.graphHeightOffset; - }, - }, - methods: { - transformDeploymentGroup(deployment) { - return `translate(${Math.floor(deployment.xPos) - 5}, 20)`; - }, - }, -}; -</script> -<template> - <g class="deploy-info"> - <g - v-for="(deployment, index) in deploymentData" - :key="index" - :transform="transformDeploymentGroup(deployment)" - > - <rect :height="calculatedHeight" x="0" y="0" width="3" fill="url(#shadow-gradient)" /> - <line :y2="calculatedHeight" class="deployment-line" x1="0" y1="0" x2="0" stroke="#000" /> - </g> - <svg height="0" width="0"> - <defs> - <linearGradient id="shadow-gradient"> - <stop offset="0%" stop-color="#000" stop-opacity="0.4" /> - <stop offset="100%" stop-color="#000" stop-opacity="0" /> - </linearGradient> - </defs> - </svg> - </g> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue deleted file mode 100644 index 9d6d1caef80..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ /dev/null @@ -1,151 +0,0 @@ -<script> -import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; -import { formatRelevantDigits } from '../../../lib/utils/number_utils'; -import Icon from '../../../vue_shared/components/icon.vue'; -import TrackLine from './track_line.vue'; - -export default { - components: { - Icon, - TrackLine, - }, - props: { - currentXCoordinate: { - type: Number, - required: true, - }, - currentData: { - type: Object, - required: true, - }, - deploymentFlagData: { - type: Object, - required: false, - default: null, - }, - graphHeight: { - type: Number, - required: true, - }, - graphHeightOffset: { - type: Number, - required: true, - }, - realPixelRatio: { - type: Number, - required: true, - }, - showFlagContent: { - type: Boolean, - required: true, - }, - timeSeries: { - type: Array, - required: true, - }, - unitOfDisplay: { - type: String, - required: true, - }, - legendTitle: { - type: String, - required: true, - }, - currentCoordinates: { - type: Object, - required: true, - }, - }, - computed: { - formatTime() { - return this.deploymentFlagData - ? timeFormat(this.deploymentFlagData.time) - : timeFormat(this.currentData.time); - }, - formatDate() { - return this.deploymentFlagData - ? dateFormat(this.deploymentFlagData.time) - : dateFormat(this.currentData.time); - }, - cursorStyle() { - const xCoordinate = this.deploymentFlagData - ? this.deploymentFlagData.xPos - : this.currentXCoordinate; - - const offsetTop = 20 * this.realPixelRatio; - const offsetLeft = (70 + xCoordinate) * this.realPixelRatio; - const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio; - - return { - top: `${offsetTop}px`, - left: `${offsetLeft}px`, - height: `${height}px`, - }; - }, - flagOrientation() { - if (this.currentXCoordinate * this.realPixelRatio > 120) { - return 'left'; - } - return 'right'; - }, - }, - methods: { - seriesMetricValue(seriesIndex, series) { - const indexFromCoordinates = this.currentCoordinates[series.metricTag] - ? this.currentCoordinates[series.metricTag].currentDataIndex - : 0; - const index = this.deploymentFlagData - ? this.deploymentFlagData.seriesIndex - : indexFromCoordinates; - const value = series.values[index] && series.values[index].value; - if (Number.isNaN(value)) { - return '-'; - } - return `${formatRelevantDigits(value)}${this.unitOfDisplay}`; - }, - seriesMetricLabel(index, series) { - if (this.timeSeries.length < 2) { - return this.legendTitle; - } - if (series.metricTag) { - return series.metricTag; - } - return `series ${index + 1}`; - }, - }, -}; -</script> - -<template> - <div :style="cursorStyle" class="prometheus-graph-cursor"> - <div v-if="showFlagContent" :class="flagOrientation" class="prometheus-graph-flag popover"> - <div class="arrow-shadow"></div> - <div class="arrow"></div> - <div class="popover-title"> - <h5 v-if="deploymentFlagData">Deployed</h5> - {{ formatDate }} <strong>{{ formatTime }}</strong> - </div> - <div v-if="deploymentFlagData" class="popover-content deploy-meta-content"> - <div> - <icon :size="12" name="commit" /> - <a :href="deploymentFlagData.commitUrl"> {{ deploymentFlagData.sha.slice(0, 8) }} </a> - </div> - <div v-if="deploymentFlagData.tag"> - <icon :size="12" name="label" /> - <a :href="deploymentFlagData.tagUrl"> {{ deploymentFlagData.ref }} </a> - </div> - </div> - <div class="popover-content"> - <table class="prometheus-table"> - <tr v-for="(series, index) in timeSeries" :key="index"> - <track-line :track="series" /> - <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td> - <td> - <strong>{{ seriesMetricValue(index, series) }}</strong> - </td> - </tr> - </table> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue deleted file mode 100644 index b5211c306a3..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ /dev/null @@ -1,62 +0,0 @@ -<script> -import TrackLine from './track_line.vue'; -import TrackInfo from './track_info.vue'; - -export default { - components: { - TrackLine, - TrackInfo, - }, - props: { - legendTitle: { - type: String, - required: true, - }, - timeSeries: { - type: Array, - required: true, - }, - }, - methods: { - isStable(track) { - return { - 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary, - }; - }, - }, -}; -</script> -<template> - <div class="prometheus-graph-legends prepend-left-10"> - <table class="prometheus-table"> - <tr - v-for="(series, index) in timeSeries" - v-if="series.shouldRenderLegend" - :key="index" - :class="isStable(series)" - > - <td> - <strong v-if="series.renderCanary">{{ series.trackName }}</strong> - </td> - <track-line :track="series" /> - <td v-if="timeSeries.length > 1" class="legend-metric-title"> - <track-info v-if="series.metricTag" :track="series" /> - <track-info v-else :track="series"> - <strong>{{ legendTitle }}</strong> series {{ index + 1 }} - </track-info> - </td> - <td v-else> - <track-info :track="series"> - <strong>{{ legendTitle }}</strong> - </track-info> - </td> - <template v-for="(track, trackIndex) in series.tracksLegend"> - <track-line :key="`track-line-${trackIndex}`" :track="track" /> - <td :key="`track-info-${trackIndex}`"> - <track-info :track="track" class="legend-metric-title" /> - </td> - </template> - </tr> - </table> - </div> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue deleted file mode 100644 index f2c237ec391..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/path.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> -export default { - props: { - generatedLinePath: { - type: String, - required: true, - }, - generatedAreaPath: { - type: String, - required: true, - }, - lineStyle: { - type: String, - required: false, - default: '', - }, - lineColor: { - type: String, - required: true, - }, - areaColor: { - type: String, - required: true, - }, - currentCoordinates: { - type: Object, - required: false, - default: () => ({ currentX: 0, currentY: 0 }), - }, - showDot: { - type: Boolean, - required: true, - }, - }, - computed: { - strokeDashArray() { - if (this.lineStyle === 'dashed') return '3, 1'; - if (this.lineStyle === 'dotted') return '1, 1'; - return null; - }, - }, -}; -</script> -<template> - <g transform="translate(-5, 20)"> - <circle - v-if="showDot" - :cx="currentCoordinates.currentX" - :cy="currentCoordinates.currentY" - :fill="lineColor" - :stroke="lineColor" - class="circle-path" - r="3" - /> - <path :d="generatedAreaPath" :fill="areaColor" class="metric-area" /> - <path - :d="generatedLinePath" - :stroke="lineColor" - :stroke-dasharray="strokeDashArray" - class="metric-line" - fill="none" - stroke-width="1" - /> - </g> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue deleted file mode 100644 index 3464067834f..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/track_info.vue +++ /dev/null @@ -1,28 +0,0 @@ -<script> -import { formatRelevantDigits } from '~/lib/utils/number_utils'; - -export default { - name: 'TrackInfo', - props: { - track: { - type: Object, - required: true, - }, - }, - computed: { - summaryMetrics() { - return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits( - this.track.max, - )}`; - }, - }, -}; -</script> -<template> - <span> - <slot> - <strong> {{ track.metricTag }} </strong> - </slot> - {{ summaryMetrics }} - </span> -</template> diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue deleted file mode 100644 index d2ed1ba113e..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/track_line.vue +++ /dev/null @@ -1,33 +0,0 @@ -<script> -export default { - name: 'TrackLine', - props: { - track: { - type: Object, - required: true, - }, - }, - computed: { - stylizedLine() { - if (this.track.lineStyle === 'dashed') return '6, 3'; - if (this.track.lineStyle === 'dotted') return '3, 3'; - return null; - }, - }, -}; -</script> -<template> - <td> - <svg width="16" height="8"> - <line - :stroke-dasharray="stylizedLine" - :stroke="track.lineColor" - :x1="0" - :x2="16" - :y1="4" - :y2="4" - stroke-width="4" - /> - </svg> - </td> -</template> diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js deleted file mode 100644 index 87c3d969de4..00000000000 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ /dev/null @@ -1,86 +0,0 @@ -import { bisectDate } from '../utils/date_time_formatters'; - -const mixins = { - methods: { - mouseOverDeployInfo(mouseXPos) { - if (!this.reducedDeploymentData) return false; - - let dataFound = false; - this.reducedDeploymentData = this.reducedDeploymentData.map(d => { - const deployment = d; - if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) { - dataFound = d.xPos + 1; - - deployment.showDeploymentFlag = true; - } else { - deployment.showDeploymentFlag = false; - } - return deployment; - }); - - return dataFound; - }, - - formatDeployments() { - this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => { - const time = new Date(deployment.created_at); - const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time)); - - time.setSeconds(this.timeSeries[0].values[0].time.getSeconds()); - - if (xPos >= 0) { - const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1); - - deploymentDataArray.push({ - id: deployment.id, - time, - sha: deployment.sha, - commitUrl: `${this.projectPath}/commit/${deployment.sha}`, - tag: deployment.tag, - tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null, - ref: deployment.ref.name, - xPos, - seriesIndex, - showDeploymentFlag: false, - }); - } - - return deploymentDataArray; - }, []); - }, - - positionFlag() { - const timeSeries = this.seriesUnderMouse[0]; - if (!timeSeries) { - return; - } - const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate); - - this.currentData = timeSeries.values[hoveredDataIndex]; - this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time)); - - this.currentCoordinates = {}; - - this.seriesUnderMouse.forEach(series => { - const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate); - const currentData = series.values[currentDataIndex]; - const currentX = Math.floor(series.timeSeriesScaleX(currentData.time)); - const currentY = Math.floor(series.timeSeriesScaleY(currentData.value)); - - this.currentCoordinates[series.metricTag] = { - currentX, - currentY, - currentDataIndex, - }; - }); - - if (this.hoverData.currentDeployXPos) { - this.showFlag = false; - } else { - this.showFlag = true; - } - }, - }, -}; - -export default mixins; diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 8692c873a41..70635059bd9 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -13,7 +13,7 @@ function checkQueryEmptyData(query) { result: query.result.filter(timeSeries => { const newTimeSeries = timeSeries; const hasValue = series => - !Number.isNaN(series.value) && (series.value !== null || series.value !== undefined); + !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined); const hasNonNullValue = timeSeries.values.find(hasValue); newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : []; @@ -33,10 +33,10 @@ function normalizeMetrics(metrics) { ...query, result: query.result.map(result => ({ ...result, - values: result.values.map(([timestamp, value]) => ({ - time: new Date(timestamp * 1000), - value: Number(value), - })), + values: result.values.map(([timestamp, value]) => [ + new Date(timestamp * 1000).toISOString(), + Number(value), + ]), })), })); @@ -66,9 +66,7 @@ export default class MonitoringStore { } storeEnvironmentsData(environmentsData = []) { - this.environmentsData = environmentsData.filter( - environment => !!environment.latest.last_deployment, - ); + this.environmentsData = environmentsData.filter(environment => !!environment.last_deployment); } getMetricsCount() { diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js deleted file mode 100644 index d88c13609dc..00000000000 --- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js +++ /dev/null @@ -1,42 +0,0 @@ -import { timeFormat as time } from 'd3-time-format'; -import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time'; -import { bisector } from 'd3-array'; - -const d3 = { - time, - bisector, - timeSecond, - timeMinute, - timeHour, - timeDay, - timeWeek, - timeMonth, - timeYear, -}; - -export const dateFormat = d3.time('%d %b %Y, '); -export const timeFormat = d3.time('%-I:%M%p'); -export const dateFormatWithName = d3.time('%a, %b %-d'); -export const bisectDate = d3.bisector(d => d.time).left; - -export function timeScaleFormat(date) { - let formatFunction; - if (d3.timeSecond(date) < date) { - formatFunction = d3.time('.%L'); - } else if (d3.timeMinute(date) < date) { - formatFunction = d3.time(':%S'); - } else if (d3.timeHour(date) < date) { - formatFunction = d3.time('%-I:%M'); - } else if (d3.timeDay(date) < date) { - formatFunction = d3.time('%-I %p'); - } else if (d3.timeWeek(date) < date) { - formatFunction = d3.time('%a %d'); - } else if (d3.timeMonth(date) < date) { - formatFunction = d3.time('%b %d'); - } else if (d3.timeYear(date) < date) { - formatFunction = d3.time('%B'); - } else { - formatFunction = d3.time('%Y'); - } - return formatFunction(date); -} diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js deleted file mode 100644 index 7c771f43eee..00000000000 --- a/app/assets/javascripts/monitoring/utils/measurements.js +++ /dev/null @@ -1,44 +0,0 @@ -export default { - small: { - // Covers both xs and sm screen sizes - margin: { - top: 40, - right: 40, - bottom: 50, - left: 40, - }, - legends: { - width: 15, - height: 3, - offsetX: 20, - offsetY: 32, - }, - backgroundLegend: { - width: 30, - height: 50, - }, - axisLabelLineOffset: -20, - }, - large: { - // This covers both md and lg screen sizes - margin: { - top: 80, - right: 80, - bottom: 100, - left: 80, - }, - legends: { - width: 15, - height: 3, - offsetX: 20, - offsetY: 34, - }, - backgroundLegend: { - width: 30, - height: 150, - }, - axisLabelLineOffset: 20, - }, - xTicks: 8, - yTicks: 3, -}; diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js deleted file mode 100644 index 50ba14dfb2e..00000000000 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ /dev/null @@ -1,223 +0,0 @@ -import _ from 'underscore'; -import { scaleLinear, scaleTime } from 'd3-scale'; -import { line, area, curveLinear } from 'd3-shape'; -import { extent, max, sum } from 'd3-array'; -import { timeMinute, timeSecond } from 'd3-time'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; - -const d3 = { - scaleLinear, - scaleTime, - line, - area, - curveLinear, - extent, - max, - timeMinute, - timeSecond, - sum, -}; - -const defaultColorPalette = { - blue: ['#1f78d1', '#8fbce8'], - orange: ['#fc9403', '#feca81'], - red: ['#db3b21', '#ed9d90'], - green: ['#1aaa55', '#8dd5aa'], - purple: ['#6666c4', '#d1d1f0'], -}; - -const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple']; - -const defaultStyleOrder = ['solid', 'dashed', 'dotted']; - -function queryTimeSeries(query, graphDrawData, lineStyle) { - let usedColors = []; - let renderCanary = false; - const timeSeriesParsed = []; - - function pickColor(name) { - let pick; - if (name && defaultColorPalette[name]) { - pick = name; - } else { - const unusedColors = _.difference(defaultColorOrder, usedColors); - if (unusedColors.length > 0) { - [pick] = unusedColors; - } else { - usedColors = []; - [pick] = defaultColorOrder; - } - } - usedColors.push(pick); - return defaultColorPalette[pick]; - } - - function findByDate(series, time) { - const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60); - if (val) { - return val.value; - } - return NaN; - } - - // The timeseries data may have gaps in it - // but we need a regularly-spaced set of time/value pairs - // this gives us a complete range of one minute intervals - // offset the same amount as the original data - const [minX, maxX] = graphDrawData.xDom; - const offset = d3.timeMinute(minX) - Number(minX); - const datesWithoutGaps = d3.timeSecond - .every(60) - .range(d3.timeMinute.offset(minX, -1), maxX) - .map(d => d - offset); - - query.result.forEach((timeSeries, timeSeriesNumber) => { - let metricTag = ''; - let lineColor = ''; - let areaColor = ''; - let shouldRenderLegend = true; - const timeSeriesValues = timeSeries.values.map(d => d.value); - const maximumValue = d3.max(timeSeriesValues); - const accum = d3.sum(timeSeriesValues); - const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable'); - - if (trackName === 'Canary') { - renderCanary = true; - } - - const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; - const seriesCustomizationData = - query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); - - if (seriesCustomizationData) { - metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; - [lineColor, areaColor] = pickColor(seriesCustomizationData.color); - if (timeSeriesParsed.length > 0) { - shouldRenderLegend = false; - } else { - shouldRenderLegend = true; - } - } else { - metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; - [lineColor, areaColor] = pickColor(); - if (timeSeriesParsed.length > 1) { - shouldRenderLegend = false; - } - } - - const values = datesWithoutGaps.map(time => ({ - time, - value: findByDate(timeSeries.values, time), - })); - - timeSeriesParsed.push({ - linePath: graphDrawData.lineFunction(values), - areaPath: graphDrawData.areaBelowLine(values), - timeSeriesScaleX: graphDrawData.timeSeriesScaleX, - timeSeriesScaleY: graphDrawData.timeSeriesScaleY, - values: timeSeries.values, - max: maximumValue, - average: accum / timeSeries.values.length, - lineStyle, - lineColor, - areaColor, - metricTag, - trackName, - shouldRenderLegend, - renderCanary, - }); - - if (!shouldRenderLegend) { - if (!timeSeriesParsed[0].tracksLegend) { - timeSeriesParsed[0].tracksLegend = []; - } - timeSeriesParsed[0].tracksLegend.push({ - max: maximumValue, - average: accum / timeSeries.values.length, - lineStyle, - lineColor, - metricTag, - }); - } - }); - - return timeSeriesParsed; -} - -function xyDomain(queries) { - const allValues = queries.reduce( - (allQueryResults, query) => - allQueryResults.concat( - query.result.reduce((allResults, result) => allResults.concat(result.values), []), - ), - [], - ); - - const xDom = d3.extent(allValues, d => d.time); - const yDom = [0, d3.max(allValues.map(d => d.value))]; - - return { - xDom, - yDom, - }; -} - -export function generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset) { - const { xDom, yDom } = xyDomain(queries); - - const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]); - const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]); - - timeSeriesScaleX.domain(xDom); - timeSeriesScaleX.ticks(d3.timeMinute, 60); - timeSeriesScaleY.domain(yDom); - - const defined = d => !Number.isNaN(d.value) && d.value != null; - - const lineFunction = d3 - .line() - .defined(defined) - .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate - .x(d => timeSeriesScaleX(d.time)) - .y(d => timeSeriesScaleY(d.value)); - - const areaBelowLine = d3 - .area() - .defined(defined) - .curve(d3.curveLinear) - .x(d => timeSeriesScaleX(d.time)) - .y0(graphHeight - graphHeightOffset) - .y1(d => timeSeriesScaleY(d.value)); - - const areaAboveLine = d3 - .area() - .defined(defined) - .curve(d3.curveLinear) - .x(d => timeSeriesScaleX(d.time)) - .y0(0) - .y1(d => timeSeriesScaleY(d.value)); - - return { - lineFunction, - areaBelowLine, - areaAboveLine, - xDom, - yDom, - timeSeriesScaleX, - timeSeriesScaleY, - }; -} - -export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) { - const graphDrawData = generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset); - - const timeSeries = queries.reduce((series, query, index) => { - const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length]; - return series.concat(queryTimeSeries(query, graphDrawData, lineStyle)); - }, []); - - return { - timeSeries, - graphDrawData, - }; -} diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index e4d72eb8318..9e99aa4f724 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -7,8 +7,11 @@ import discussionCounter from '../notes/components/discussion_counter.vue'; import initDiscussionFilters from '../notes/discussion_filters'; import store from './stores'; import MergeRequest from '../merge_request'; +import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; export default function initMrNotes() { + resetServiceWorkersPublicPath(); + const mrShowNode = document.querySelector('.merge-request'); // eslint-disable-next-line no-new new MergeRequest({ diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index c3443c300e3..c9c01354333 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1239,15 +1239,13 @@ export default class Notes { var postUrl = $originalContentEl.data('postUrl'); var targetId = $originalContentEl.data('targetId'); var targetType = $originalContentEl.data('targetType'); - var markdownVersion = $originalContentEl.data('markdownVersion'); this.glForm = new GLForm($editForm.find('form'), this.enableGFM); $editForm .find('form') .attr('action', `${postUrl}?html=true`) - .attr('data-remote', 'true') - .attr('data-markdown-version', markdownVersion); + .attr('data-remote', 'true'); $editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-type').val(targetType); $editForm diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index d669ba5a8fa..1d6cb9485f7 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -39,11 +39,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, }, data() { return { @@ -342,7 +337,6 @@ Please check your network connection and try again.`; :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" - :markdown-version="markdownVersion" :add-spacing-classes="false" > <textarea diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 376d4114efd..d8947e8ca50 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -5,6 +5,7 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { GlSkeletonLoading } from '@gitlab/ui'; import { getDiffMode } from '~/diffs/store/utils'; +import { diffViewerModes } from '~/ide/constants'; export default { components: { @@ -31,6 +32,12 @@ export default { diffMode() { return getDiffMode(this.discussion.diff_file); }, + diffViewerMode() { + return this.discussion.diff_file.viewer.name; + }, + isTextFile() { + return this.diffViewerMode === diffViewerModes.text; + }, hasTruncatedDiffLines() { return ( this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0 @@ -58,18 +65,14 @@ export default { </script> <template> - <div :class="{ 'text-file': discussion.diff_file.text }" class="diff-file file-holder"> + <div :class="{ 'text-file': isTextFile }" class="diff-file file-holder"> <diff-file-header :discussion-path="discussion.discussion_path" :diff-file="discussion.diff_file" :can-current-user-fork="false" - :expanded="!discussion.diff_file.collapsed" + :expanded="!discussion.diff_file.viewer.collapsed" /> - <div - v-if="discussion.diff_file.text" - :class="$options.userColorSchemeClass" - class="diff-content code" - > + <div v-if="isTextFile" :class="$options.userColorSchemeClass" class="diff-content code"> <table> <template v-if="hasTruncatedDiffLines"> <tr @@ -109,6 +112,7 @@ export default { <div v-else> <diff-viewer :diff-mode="diffMode" + :diff-viewer-mode="diffViewerMode" :new-path="discussion.diff_file.new_path" :new-sha="discussion.diff_file.diff_refs.head_sha" :old-path="discussion.diff_file.old_path" diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index e03d6e9cd02..31164f74201 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -7,7 +7,9 @@ import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE, DISCUSSION_TAB_LABEL, + DISCUSSION_FILTER_TYPES, } from '../constants'; +import notesEventHub from '../event_hub'; export default { components: { @@ -46,6 +48,7 @@ export default { this.toggleFilters(currentTab); } + notesEventHub.$on('dropdownSelect', this.selectFilter); window.addEventListener('hashchange', this.handleLocationHash); this.handleLocationHash(); }, @@ -53,6 +56,7 @@ export default { this.toggleCommentsForm(); }, destroyed() { + notesEventHub.$off('dropdownSelect', this.selectFilter); window.removeEventListener('hashchange', this.handleLocationHash); }, methods: { @@ -86,12 +90,23 @@ export default { this.setTargetNoteHash(hash); } }, + filterType(value) { + if (value === 0) { + return DISCUSSION_FILTER_TYPES.ALL; + } else if (value === 1) { + return DISCUSSION_FILTER_TYPES.COMMENTS; + } + return DISCUSSION_FILTER_TYPES.HISTORY; + }, }, }; </script> <template> - <div v-if="displayFilters" class="discussion-filter-container d-inline-block align-bottom"> + <div + v-if="displayFilters" + class="discussion-filter-container js-discussion-filter-container d-inline-block align-bottom" + > <button id="discussion-filter-dropdown" ref="dropdownToggle" @@ -102,12 +117,17 @@ export default { {{ currentFilter.title }} <icon name="chevron-down" /> </button> <div + ref="dropdownMenu" class="dropdown-menu dropdown-menu-selectable dropdown-menu-right" aria-labelledby="discussion-filter-dropdown" > <div class="dropdown-content"> <ul> - <li v-for="filter in filters" :key="filter.value"> + <li + v-for="filter in filters" + :key="filter.value" + :data-filter-type="filterType(filter.value)" + > <button :class="{ 'is-active': filter.value === currentValue }" class="qa-filter-options" diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue new file mode 100644 index 00000000000..46661e06f6d --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -0,0 +1,52 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __, sprintf } from '~/locale'; + +import notesEventHub from '../event_hub'; + +export default { + components: { + GlButton, + Icon, + }, + computed: { + timelineContent() { + return sprintf( + __( + "You're only seeing %{startTag}other activity%{endTag} in the feed. To add a comment, switch to one of the following options.", + ), + { + startTag: `<b>`, + endTag: `</b>`, + }, + false, + ); + }, + }, + methods: { + selectFilter(value) { + notesEventHub.$emit('dropdownSelect', value); + }, + }, +}; +</script> + +<template> + <li class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note"> + <div class="timeline-icon"> + <icon name="comment" /> + </div> + <div class="timeline-content"> + <div v-html="timelineContent"></div> + <div class="discussion-filter-actions mt-2"> + <gl-button variant="default" @click="selectFilter(0)"> + {{ __('Show all activity') }} + </gl-button> + <gl-button variant="default" @click="selectFilter(1)"> + {{ __('Show comments only') }} + </gl-button> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue new file mode 100644 index 00000000000..07a5bda6bcb --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue @@ -0,0 +1,28 @@ +<script> +import icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective } from '@gitlab/ui'; + +export default { + name: 'JumpToNextDiscussionButton', + components: { + icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, +}; +</script> + +<template> + <div class="btn-group" role="group"> + <button + ref="button" + v-gl-tooltip + class="btn btn-default discussion-next-btn" + :title="s__('MergeRequests|Jump to next unresolved discussion')" + @click="$emit('onClick')" + > + <icon name="comment-next" /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue new file mode 100644 index 00000000000..ea590905e3c --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue @@ -0,0 +1,17 @@ +<script> +export default { + name: 'ReplyPlaceholder', +}; +</script> + +<template> + <button + ref="button" + type="button" + class="js-vue-discussion-reply btn btn-text-field" + :title="s__('MergeRequests|Add a reply')" + @click="$emit('onClick')" + > + {{ s__('MergeRequests|Reply...') }} + </button> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_resolve_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_button.vue new file mode 100644 index 00000000000..2b29d710236 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_resolve_button.vue @@ -0,0 +1,28 @@ +<script> +export default { + name: 'ResolveDiscussionButton', + props: { + isResolving: { + type: Boolean, + required: false, + default: false, + }, + buttonTitle: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <button ref="button" type="button" class="btn btn-default ml-sm-2" @click="$emit('onClick')"> + <i + v-if="isResolving" + ref="isResolvingIcon" + aria-hidden="true" + class="fa fa-spinner fa-spin" + ></i> + {{ buttonTitle }} + </button> +</template> diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue new file mode 100644 index 00000000000..e413398696a --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -0,0 +1,34 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; + +export default { + name: 'ResolveWithIssueButton', + components: { + Icon, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + url: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="btn-group" role="group"> + <gl-button + v-gl-tooltip + :href="url" + :title="s__('MergeRequests|Resolve this discussion in a new issue')" + class="new-issue-for-discussion discussion-create-issue-btn" + > + <icon name="issue-new" /> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index d99694b06e9..de1ea0f58d6 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -2,11 +2,13 @@ import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import ReplyButton from './note_actions/reply_button.vue'; export default { name: 'NoteActions', components: { Icon, + ReplyButton, GlLoadingIcon, }, directives: { @@ -36,6 +38,10 @@ export default { required: false, default: null, }, + showReply: { + type: Boolean, + required: true, + }, canEdit: { type: Boolean, required: true, @@ -80,6 +86,9 @@ export default { }, computed: { ...mapGetters(['getUserDataByProp']), + showReplyButton() { + return gon.features && gon.features.replyToIndividualNotes && this.showReply; + }, shouldShowActionsDropdown() { return this.currentUserId && (this.canEdit || this.canReportAsAbuse); }, @@ -112,6 +121,11 @@ export default { onResolve() { this.$emit('handleResolve'); }, + closeTooltip() { + this.$nextTick(() => { + this.$root.$emit('bv::hide::tooltip'); + }); + }, }, }; </script> @@ -121,6 +135,7 @@ export default { <span v-if="accessLevel" class="note-role user-access-role">{{ accessLevel }}</span> <div v-if="canResolve" class="note-actions-item"> <button + ref="resolveButton" v-gl-tooltip :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" :title="resolveButtonTitle" @@ -137,10 +152,9 @@ export default { </div> <div v-if="canAwardEmoji" class="note-actions-item"> <a - v-gl-tooltip.bottom + v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByCurrentUser }" class="note-action-button note-emoji-button js-add-award js-note-emoji" - data-position="right" href="#" title="Add reaction" > @@ -153,9 +167,15 @@ export default { <icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" /> </a> </div> + <reply-button + v-if="showReplyButton" + ref="replyButton" + class="js-reply-button" + @startReplying="$emit('startReplying')" + /> <div v-if="canEdit" class="note-actions-item"> <button - v-gl-tooltip.bottom + v-gl-tooltip type="button" title="Edit comment" class="note-action-button js-note-edit btn btn-transparent" @@ -166,7 +186,7 @@ export default { </div> <div v-if="showDeleteAction" class="note-actions-item"> <button - v-gl-tooltip.bottom + v-gl-tooltip type="button" title="Delete comment" class="note-action-button js-note-delete btn btn-transparent" @@ -177,11 +197,12 @@ export default { </div> <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item"> <button - v-gl-tooltip.bottom + v-gl-tooltip type="button" title="More actions" class="note-action-button more-actions-toggle btn btn-transparent" data-toggle="dropdown" + @click="closeTooltip" > <icon css-classes="icon" name="ellipsis_v" /> </button> diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue new file mode 100644 index 00000000000..f50cab81efe --- /dev/null +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -0,0 +1,30 @@ +<script> +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReplyButton', + components: { + Icon, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, +}; +</script> + +<template> + <div class="note-actions-item"> + <gl-button + ref="button" + v-gl-tooltip.bottom + class="note-action-button" + variant="transparent" + :title="__('Reply to comment')" + @click="$emit('startReplying')" + > + <icon name="comment" css-classes="link-highlight" /> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 3efdd1c5c17..17e5fcab5b7 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -171,7 +171,6 @@ export default { :class="getAwardClassBindings(awardList)" :title="awardTitle(awardList)" data-boundary="viewport" - data-placement="bottom" class="btn award-control" type="button" @click="handleAward(awardName)" @@ -187,7 +186,6 @@ export default { title="Add reaction" aria-label="Add reaction" data-boundary="viewport" - data-placement="bottom" type="button" > <span class="award-control-icon award-control-icon-neutral"> diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index bcf5d334da4..fb1d98355b3 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -95,6 +95,7 @@ export default { <div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body"> <suggestions v-if="hasSuggestion && !isEditing" + class="note-text md" :suggestions="note.suggestions" :note-html="note.note_html" :line-type="lineType" @@ -111,7 +112,6 @@ export default { :line="line" :note="note" :help-page-path="helpPagePath" - :markdown-version="note.cached_markdown_version" @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" /> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 269b4a4b117..92258a25438 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -26,11 +26,6 @@ export default { required: false, default: '', }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, saveButtonTitle: { type: String, required: false, @@ -202,7 +197,6 @@ export default { <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" :line="line" :note="discussionNote" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 1a116161e3c..3894dc8c677 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -12,6 +12,7 @@ import { SYSTEM_NOTE } from '../constants'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteableNote from './noteable_note.vue'; import noteHeader from './note_header.vue'; +import resolveDiscussionButton from './discussion_resolve_button.vue'; import toggleRepliesWidget from './toggle_replies_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteEditedText from './note_edited_text.vue'; @@ -23,6 +24,10 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; +import ReplyPlaceholder from './discussion_reply_placeholder.vue'; +import ResolveWithIssueButton from './discussion_resolve_with_issue_button.vue'; +import jumpToNextDiscussionButton from './discussion_jump_to_next_button.vue'; +import eventHub from '../event_hub'; export default { name: 'NoteableDiscussion', @@ -34,9 +39,13 @@ export default { noteSignedOutWidget, noteEditedText, noteForm, + resolveDiscussionButton, + jumpToNextDiscussionButton, toggleRepliesWidget, + ReplyPlaceholder, placeholderNote, placeholderSystemNote, + ResolveWithIssueButton, systemNote, TimelineEntryItem, }, @@ -87,6 +96,7 @@ export default { }, computed: { ...mapGetters([ + 'convertedDisscussionIds', 'getNoteableData', 'nextUnresolvedDiscussionId', 'unresolvedDiscussionsCount', @@ -216,6 +226,19 @@ export default { return null; }, + commit() { + if (!this.discussion.for_commit) { + return null; + } + + return { + id: this.discussion.commit_id, + url: this.discussion.discussion_path, + }; + }, + resolveWithIssuePath() { + return !this.discussionResolved && this.discussion.resolve_with_issue_path; + }, }, watch: { isReplying() { @@ -229,6 +252,12 @@ export default { } }, }, + created() { + eventHub.$on('startReplying', this.onStartReplying); + }, + beforeDestroy() { + eventHub.$off('startReplying', this.onStartReplying); + }, methods: { ...mapActions([ 'saveNote', @@ -236,6 +265,7 @@ export default { 'removePlaceholderNotes', 'toggleResolveNote', 'expandDiscussion', + 'removeConvertedDiscussion', ]), truncateSha, componentName(note) { @@ -275,6 +305,10 @@ export default { } } + if (this.convertedDisscussionIds.includes(this.discussion.id)) { + this.removeConvertedDiscussion(this.discussion.id); + } + this.isReplying = false; this.resetAutoSave(); }, @@ -285,6 +319,10 @@ export default { note: { note: noteText }, }; + if (this.convertedDisscussionIds.includes(this.discussion.id)) { + postData.return_discussion = true; + } + if (this.discussion.for_commit) { postData.note_project_id = this.discussion.project_id; } @@ -324,6 +362,11 @@ Please check your network connection and try again.`; deleteNoteHandler(note) { this.$emit('noteDeleted', this.discussion, note); }, + onStartReplying(discussionId) { + if (this.discussion.id === discussionId) { + this.showReplyForm(); + } + }, }, }; </script> @@ -342,30 +385,32 @@ Please check your network connection and try again.`; :img-size="40" /> </div> - <note-header - :author="author" - :created-at="initialDiscussion.created_at" - :note-id="initialDiscussion.id" - :include-toggle="true" - :expanded="discussion.expanded" - @toggleHandler="toggleDiscussionHandler" - > - <span v-html="actionText"></span> - </note-header> - <note-edited-text - v-if="discussion.resolved" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline" - /> - <note-edited-text - v-else-if="lastUpdatedAt" - :edited-at="lastUpdatedAt" - :edited-by="lastUpdatedBy" - action-text="Last updated" - class-name="discussion-headline-light js-discussion-headline" - /> + <div class="timeline-content"> + <note-header + :author="author" + :created-at="initialDiscussion.created_at" + :note-id="initialDiscussion.id" + :include-toggle="true" + :expanded="discussion.expanded" + @toggleHandler="toggleDiscussionHandler" + > + <span v-html="actionText"></span> + </note-header> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline" + /> + <note-edited-text + v-else-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + action-text="Last updated" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> </div> <div v-if="shouldShowDiscussions" class="discussion-body"> <component @@ -380,8 +425,11 @@ Please check your network connection and try again.`; :is="componentName(initialDiscussion)" :note="componentData(initialDiscussion)" :line="line" + :commit="commit" :help-page-path="helpPagePath" + :show-reply-button="canReply" @handleDeleteNote="deleteNoteHandler" + @startReplying="showReplyForm" > <note-edited-text v-if="discussion.resolved" @@ -432,49 +480,26 @@ Please check your network connection and try again.`; > <template v-if="!isReplying && canReply"> <div class="discussion-with-resolve-btn"> - <button - type="button" - class="js-vue-discussion-reply btn btn-text-field qa-discussion-reply" - title="Add a reply" - @click="showReplyForm" - > - Reply... - </button> - <div v-if="discussion.resolvable"> - <button - type="button" - class="btn btn-default ml-sm-2" - @click="resolveHandler()" - > - <i v-if="isResolving" aria-hidden="true" class="fa fa-spinner fa-spin"></i> - {{ resolveButtonTitle }} - </button> - </div> + <reply-placeholder class="qa-discussion-reply" @onClick="showReplyForm" /> + <resolve-discussion-button + v-if="discussion.resolvable" + :is-resolving="isResolving" + :button-title="resolveButtonTitle" + @onClick="resolveHandler" + /> <div v-if="discussion.resolvable" class="btn-group discussion-actions ml-sm-2" role="group" > - <div v-if="!discussionResolved" class="btn-group" role="group"> - <a - v-gl-tooltip - :href="discussion.resolve_with_issue_path" - :title="s__('MergeRequests|Resolve this discussion in a new issue')" - class="new-issue-for-discussion btn btn-default discussion-create-issue-btn" - > - <icon name="issue-new" /> - </a> - </div> - <div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group"> - <button - v-gl-tooltip - class="btn btn-default discussion-next-btn" - title="Jump to next unresolved discussion" - @click="jumpToNextDiscussion" - > - <icon name="comment-next" /> - </button> - </div> + <resolve-with-issue-button + v-if="resolveWithIssuePath" + :url="resolveWithIssuePath" + /> + <jump-to-next-discussion-button + v-if="shouldShowJumpToNextDiscussion" + @onClick="jumpToNextDiscussion" + /> </div> </div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 4c02588127e..04e74a43acc 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -2,7 +2,9 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { escape } from 'underscore'; +import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import { s__, sprintf } from '../../locale'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; @@ -37,6 +39,16 @@ export default { required: false, default: '', }, + commit: { + type: Object, + required: false, + default: () => null, + }, + showReplyButton: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -47,7 +59,7 @@ export default { }; }, computed: { - ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData']), + ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']), author() { return this.note.author; }, @@ -73,6 +85,27 @@ export default { isTarget() { return this.targetNoteHash === this.noteAnchorId; }, + discussionId() { + if (this.discussion) { + return this.discussion.id; + } + return ''; + }, + actionText() { + if (!this.commit) { + return ''; + } + + // We need to do this to ensure we have the currect sentence order + // when translating this as the sentence order may change from one + // language to the next. See: + // https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24427#note_133713771 + const { id, url } = this.commit; + const commitLink = `<a class="commit-sha monospace" href="${escape(url)}">${truncateSha( + id, + )}</a>`; + return sprintf(s__('MergeRequests|commented on commit %{commitLink}'), { commitLink }, false); + }, }, created() { @@ -200,18 +233,16 @@ export default { </div> <div class="timeline-content"> <div class="note-header"> - <note-header - v-once - :author="author" - :created-at="note.created_at" - :note-id="note.id" - action-text="commented" - /> + <note-header v-once :author="author" :created-at="note.created_at" :note-id="note.id"> + <span v-if="commit" v-html="actionText"></span> + <span v-else class="d-none d-sm-inline">·</span> + </note-header> <note-actions :author-id="author.id" :note-id="note.id" :note-url="note.noteable_note_url" :access-level="note.human_access" + :show-reply="showReplyButton" :can-edit="note.current_user.can_edit" :can-award-emoji="note.current_user.can_award_emoji" :can-delete="note.current_user.can_edit" @@ -225,6 +256,7 @@ export default { @handleEdit="editHandler" @handleDelete="deleteHandler" @handleResolve="resolveHandler" + @startReplying="$emit('startReplying')" /> </div> <div class="timeline-discussion-body"> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index f3fcfdfda05..e2bd59f7631 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -6,6 +6,7 @@ import * as constants from '../constants'; import eventHub from '../event_hub'; import noteableNote from './noteable_note.vue'; import noteableDiscussion from './noteable_discussion.vue'; +import discussionFilterNote from './discussion_filter_note.vue'; import systemNote from '../../vue_shared/components/notes/system_note.vue'; import commentForm from './comment_form.vue'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; @@ -24,6 +25,7 @@ export default { placeholderNote, placeholderSystemNote, skeletonLoadingContainer, + discussionFilterNote, }, props: { noteableData: { @@ -44,11 +46,6 @@ export default { required: false, default: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, helpPagePath: { type: String, required: false, @@ -65,9 +62,11 @@ export default { ...mapGetters([ 'isNotesFetched', 'discussions', + 'convertedDisscussionIds', 'getNotesDataByProp', 'isLoading', 'commentsDisabled', + 'getNoteableData', ]), noteableType() { return this.noteableData.noteableType; @@ -83,6 +82,9 @@ export default { return this.discussions; }, + canReply() { + return this.getNoteableData.current_user.can_create_note && !this.commentsDisabled; + }, }, watch: { shouldShow() { @@ -90,8 +92,15 @@ export default { this.fetchNotes(); } }, + allDiscussions() { + if (this.discussonsCount) { + this.discussonsCount.textContent = this.allDiscussions.length; + } + }, }, created() { + this.discussonsCount = document.querySelector('.js-discussions-count'); + this.setNotesData(this.notesData); this.setNoteableData(this.noteableData); this.setUserData(this.userData); @@ -133,6 +142,7 @@ export default { 'setNotesFetchedState', 'expandDiscussion', 'startTaskList', + 'convertToDiscussion', ]), fetchNotes() { if (this.isFetching) return null; @@ -180,6 +190,11 @@ export default { } } }, + startReplying(discussionId) { + return this.convertToDiscussion(discussionId) + .then(() => this.$nextTick()) + .then(() => eventHub.$emit('startReplying', discussionId)); + }, }, systemNote: constants.SYSTEM_NOTE, }; @@ -198,13 +213,21 @@ export default { /> <placeholder-note v-else :key="discussion.id" :note="discussion.notes[0]" /> </template> - <template v-else-if="discussion.individual_note"> + <template + v-else-if="discussion.individual_note && !convertedDisscussionIds.includes(discussion.id)" + > <system-note v-if="discussion.notes[0].system" :key="discussion.id" :note="discussion.notes[0]" /> - <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" /> + <noteable-note + v-else + :key="discussion.id" + :note="discussion.notes[0]" + :show-reply-button="canReply" + @startReplying="startReplying(discussion.id)" + /> </template> <noteable-discussion v-else @@ -214,12 +237,9 @@ export default { :help-page-path="helpPagePath" /> </template> + <discussion-filter-note v-show="commentsDisabled" /> </ul> - <comment-form - v-if="!commentsDisabled" - :noteable-type="noteableType" - :markdown-version="markdownVersion" - /> + <comment-form v-if="!commentsDisabled" :noteable-type="noteableType" /> </div> </template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 78d365fe94b..fba3db8542c 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -24,3 +24,9 @@ export const NOTEABLE_TYPE_MAPPING = { MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE, Epic: EPIC_NOTEABLE_TYPE, }; + +export const DISCUSSION_FILTER_TYPES = { + ALL: 'all', + COMMENTS: 'comments', + HISTORY: 'history', +}; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 2f715c85fa6..4883266dae5 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -18,7 +18,6 @@ document.addEventListener('DOMContentLoaded', () => { const notesDataset = document.getElementById('js-vue-notes').dataset; const parsedUserData = JSON.parse(notesDataset.currentUserData); const noteableData = JSON.parse(notesDataset.noteableData); - const markdownVersion = parseInt(notesDataset.markdownVersion, 10); let currentUserData = {}; noteableData.noteableType = notesDataset.noteableType; @@ -37,7 +36,6 @@ document.addEventListener('DOMContentLoaded', () => { return { noteableData, currentUserData, - markdownVersion, notesData: JSON.parse(notesDataset.notesData), }; }, @@ -47,7 +45,6 @@ document.addEventListener('DOMContentLoaded', () => { noteableData: this.noteableData, notesData: this.notesData, userData: this.currentUserData, - markdownVersion: this.markdownVersion, }, }); }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 65f85314fa0..1a0dba69a7c 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -83,12 +83,44 @@ export const updateNote = ({ commit, dispatch }, { endpoint, note }) => dispatch('startTaskList'); }); -export const replyToDiscussion = ({ commit }, { endpoint, data }) => +export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes) => { + const { notesById } = getters; + + notes.forEach(note => { + if (notesById[note.id]) { + commit(types.UPDATE_NOTE, note); + } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { + const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id); + + if (discussion) { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); + } else if (note.type === constants.DIFF_NOTE) { + dispatch('fetchDiscussions', { path: state.notesData.discussionsPath }); + } else { + commit(types.ADD_NEW_NOTE, note); + } + } else { + commit(types.ADD_NEW_NOTE, note); + } + }); +}; + +export const replyToDiscussion = ({ commit, state, getters, dispatch }, { endpoint, data }) => service .replyToDiscussion(endpoint, data) .then(res => res.json()) .then(res => { - commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); + if (res.discussion) { + commit(types.UPDATE_DISCUSSION, res.discussion); + + updateOrCreateNotes({ commit, state, getters, dispatch }, res.discussion.notes); + + dispatch('updateMergeRequestWidget'); + dispatch('startTaskList'); + dispatch('updateResolvableDiscussonsCounts'); + } else { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); + } return res; }); @@ -262,25 +294,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { if (resp.notes && resp.notes.length) { - const { notesById } = getters; - - resp.notes.forEach(note => { - if (notesById[note.id]) { - commit(types.UPDATE_NOTE, note); - } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { - const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id); - - if (discussion) { - commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); - } else if (note.type === constants.DIFF_NOTE) { - dispatch('fetchDiscussions', { path: state.notesData.discussionsPath }); - } else { - commit(types.ADD_NEW_NOTE, note); - } - } else { - commit(types.ADD_NEW_NOTE, note); - } - }); + updateOrCreateNotes({ commit, state, getters, dispatch }, resp.notes); dispatch('startTaskList'); } @@ -415,15 +429,22 @@ export const submitSuggestion = ( commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }); callback(); }) - .catch(() => { - Flash( - __('Something went wrong while applying the suggestion. Please try again.'), - 'alert', - flashContainer, + .catch(err => { + const defaultMessage = __( + 'Something went wrong while applying the suggestion. Please try again.', ); + const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage; + + Flash(__(flashMessage), 'alert', flashContainer); callback(); }); }; +export const convertToDiscussion = ({ commit }, noteId) => + commit(types.CONVERT_TO_DISCUSSION, noteId); + +export const removeConvertedDiscussion = ({ commit }, noteId) => + commit(types.REMOVE_CONVERTED_DISCUSSION, noteId); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 0ffc0cb2593..5026c13dab5 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -4,6 +4,8 @@ import { collapseSystemNotes } from './collapse_utils'; export const discussions = state => collapseSystemNotes(state.discussions); +export const convertedDisscussionIds = state => state.convertedDisscussionIds; + export const targetNoteHash = state => state.targetNoteHash; export const getNotesData = state => state.notesData; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 887e6d22b06..6168aeae35d 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -5,6 +5,7 @@ import mutations from '../mutations'; export default () => ({ state: { discussions: [], + convertedDisscussionIds: [], targetNoteHash: null, lastFetchedAt: null, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index df943c155f4..796370920bb 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -17,6 +17,8 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; export const APPLY_SUGGESTION = 'APPLY_SUGGESTION'; +export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION'; +export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 33d39ad2ec9..ae6f8b7790a 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -264,4 +264,16 @@ export default { ).length; state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1; }, + + [types.CONVERT_TO_DISCUSSION](state, discussionId) { + const convertedDisscussionIds = [...state.convertedDisscussionIds, discussionId]; + Object.assign(state, { convertedDisscussionIds }); + }, + + [types.REMOVE_CONVERTED_DISCUSSION](state, discussionId) { + const convertedDisscussionIds = [...state.convertedDisscussionIds]; + + convertedDisscussionIds.splice(convertedDisscussionIds.indexOf(discussionId), 1); + Object.assign(state, { convertedDisscussionIds }); + }, }; diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js index 8f98be79640..01001d4f3ff 100644 --- a/app/assets/javascripts/pages/dashboard/projects/index.js +++ b/app/assets/javascripts/pages/dashboard/projects/index.js @@ -1,7 +1,5 @@ import ProjectsList from '~/projects_list'; -import Star from '../../../star'; document.addEventListener('DOMContentLoaded', () => { new ProjectsList(); // eslint-disable-line no-new - new Star('.project-row'); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js index 0c585e162cb..01001d4f3ff 100644 --- a/app/assets/javascripts/pages/explore/projects/index.js +++ b/app/assets/javascripts/pages/explore/projects/index.js @@ -1,3 +1,5 @@ import ProjectsList from '~/projects_list'; -document.addEventListener('DOMContentLoaded', () => new ProjectsList()); +document.addEventListener('DOMContentLoaded', () => { + new ProjectsList(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 736c6a62610..35d4b034654 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,9 +1,11 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; -import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; document.addEventListener('DOMContentLoaded', () => { + IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); + initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, isGroupDecendent: true, diff --git a/app/assets/javascripts/pages/import/gitea/status/index.js b/app/assets/javascripts/pages/import/gitea/status/index.js new file mode 100644 index 00000000000..dcd84f0faf9 --- /dev/null +++ b/app/assets/javascripts/pages/import/gitea/status/index.js @@ -0,0 +1,7 @@ +import mountImportProjectsTable from '~/import_projects'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('import-projects-mount-element'); + + mountImportProjectsTable(mountElement); +}); diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js new file mode 100644 index 00000000000..dcd84f0faf9 --- /dev/null +++ b/app/assets/javascripts/pages/import/github/status/index.js @@ -0,0 +1,7 @@ +import mountImportProjectsTable from '~/import_projects'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('import-projects-mount-element'); + + mountImportProjectsTable(mountElement); +}); diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index c7ce4675573..0dd0d5336fc 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import createFlash from '~/flash'; import GfmAutoComplete from '~/gfm_auto_complete'; +import emojiRegex from 'emoji-regex'; import EmojiMenu from './emoji_menu'; const defaultStatusEmoji = 'speech_balloon'; @@ -42,6 +43,17 @@ document.addEventListener('DOMContentLoaded', () => { const emojiAutocomplete = new GfmAutoComplete(); emojiAutocomplete.setup($(statusMessageField), { emojis: true }); + const userNameInput = document.getElementById('user_name'); + userNameInput.addEventListener('input', () => { + const EMOJI_REGEX = emojiRegex(); + if (EMOJI_REGEX.test(userNameInput.value)) { + // set field to invalid so it gets detected by GlFieldErrors + userNameInput.setCustomValidity('Invalid field'); + } else { + userNameInput.setCustomValidity(''); + } + }); + import(/* webpackChunkName: 'emoji' */ '~/emoji') .then(Emoji => { const emojiMenu = new EmojiMenu( diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index 0b644780ad4..0d69a689316 100644 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js @@ -1,3 +1,3 @@ -import monitoringBundle from '~/monitoring/monitoring_bundle'; +import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle'; document.addEventListener('DOMContentLoaded', monitoringBundle); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 3ccad513c05..314519ee442 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -2,65 +2,86 @@ import $ from 'jquery'; import Chart from 'chart.js'; import _ from 'underscore'; +import { barChartOptions, pieChartOptions } from '~/lib/utils/chart_utils'; + document.addEventListener('DOMContentLoaded', () => { const projectChartData = JSON.parse(document.getElementById('projectChartData').innerHTML); - const responsiveChart = (selector, data) => { - const options = { - scaleOverlay: true, - responsive: true, - pointHitDetectionRadius: 2, - maintainAspectRatio: false, - }; + const barChart = (selector, data) => { // get selector by context const ctx = selector.get(0).getContext('2d'); // pointing parent container to make chart.js inherit its width const container = $(selector).parent(); - const generateChart = () => { - selector.attr('width', $(container).width()); - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - return new Chart(ctx).Bar(data, options); - }; - // enabling auto-resizing - $(window).resize(generateChart); - return generateChart(); + selector.attr('width', $(container).width()); + + // Scale fonts if window width lower than 768px (iPad portrait) + const shouldAdjustFontSize = window.innerWidth < 768; + return new Chart(ctx, { + type: 'bar', + data, + options: barChartOptions(shouldAdjustFontSize), + }); + }; + + const pieChart = (context, data) => { + const options = pieChartOptions(); + + return new Chart(context, { + type: 'pie', + data, + options, + }); }; const chartData = data => ({ labels: Object.keys(data), datasets: [ { - fillColor: 'rgba(220,220,220,0.5)', - strokeColor: 'rgba(220,220,220,1)', - barStrokeWidth: 1, - barValueSpacing: 1, - barDatasetSpacing: 1, + backgroundColor: 'rgba(220,220,220,0.5)', + borderColor: 'rgba(220,220,220,1)', + borderWidth: 1, data: _.values(data), }, ], }); + const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { + if (firstDayOfWeek === 0) { + return weekDays; + } + + return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => { + const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length]; + + return { + ...acc, + [reorderedDayName]: weekDays[reorderedDayName], + }; + }, {}); + }; + const hourData = chartData(projectChartData.hour); - responsiveChart($('#hour-chart'), hourData); + barChart($('#hour-chart'), hourData); - const dayData = chartData(projectChartData.weekDays); - responsiveChart($('#weekday-chart'), dayData); + const weekDays = reorderWeekDays(projectChartData.weekDays, gon.first_day_of_week); + const dayData = chartData(weekDays); + barChart($('#weekday-chart'), dayData); const monthData = chartData(projectChartData.month); - responsiveChart($('#month-chart'), monthData); + barChart($('#month-chart'), monthData); - const data = projectChartData.languages; + const data = { + datasets: [ + { + data: projectChartData.languages.map(x => x.value), + backgroundColor: projectChartData.languages.map(x => x.color), + hoverBackgroundColor: projectChartData.languages.map(x => x.highlight), + }, + ], + labels: projectChartData.languages.map(x => x.label), + }; const ctx = $('#languages-chart') .get(0) .getContext('2d'); - const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, - }; - - new Chart(ctx).Pie(data, options); + pieChart(ctx, data); }); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index a56c0bb6be8..8bf0c2edc71 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -4,11 +4,13 @@ import IssuableIndex from '~/issuable_index'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import UsersSelect from '~/users_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; document.addEventListener('DOMContentLoaded', () => { + IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); + initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js index 48353f3b4ef..9fa580d2ba9 100644 --- a/app/assets/javascripts/pages/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js @@ -1,29 +1,31 @@ import $ from 'jquery'; import Chart from 'chart.js'; -const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, -}; +import { barChartOptions, lineChartOptions } from '~/lib/utils/chart_utils'; + +const SUCCESS_LINE_COLOR = '#1aaa55'; + +const TOTAL_LINE_COLOR = '#707070'; -const buildChart = chartScope => { +const buildChart = (chartScope, shouldAdjustFontSize) => { const data = { labels: chartScope.labels, datasets: [ { - fillColor: '#707070', - strokeColor: '#707070', - pointColor: '#707070', - pointStrokeColor: '#EEE', - data: chartScope.totalValues, + backgroundColor: SUCCESS_LINE_COLOR, + borderColor: SUCCESS_LINE_COLOR, + pointBackgroundColor: SUCCESS_LINE_COLOR, + pointBorderColor: '#fff', + data: chartScope.successValues, + fill: 'origin', }, { - fillColor: '#1aaa55', - strokeColor: '#1aaa55', - pointColor: '#1aaa55', - pointStrokeColor: '#fff', - data: chartScope.successValues, + backgroundColor: TOTAL_LINE_COLOR, + borderColor: TOTAL_LINE_COLOR, + pointBackgroundColor: TOTAL_LINE_COLOR, + pointBorderColor: '#EEE', + data: chartScope.totalValues, + fill: '-1', }, ], }; @@ -31,36 +33,51 @@ const buildChart = chartScope => { .get(0) .getContext('2d'); - new Chart(ctx).Line(data, options); + return new Chart(ctx, { + type: 'line', + data, + options: lineChartOptions({ + width: ctx.canvas.width, + numberOfPoints: chartScope.totalValues.length, + shouldAdjustFontSize, + }), + }); }; -document.addEventListener('DOMContentLoaded', () => { - const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); - const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); +const buildBarChart = (chartTimesData, shouldAdjustFontSize) => { const data = { labels: chartTimesData.labels, datasets: [ { - fillColor: 'rgba(220,220,220,0.5)', - strokeColor: 'rgba(220,220,220,1)', - barStrokeWidth: 1, + backgroundColor: 'rgba(220,220,220,0.5)', + borderColor: 'rgba(220,220,220,1)', + borderWidth: 1, barValueSpacing: 1, barDatasetSpacing: 1, data: chartTimesData.values, }, ], }; - - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - - new Chart( + return new Chart( $('#build_timesChart') .get(0) .getContext('2d'), - ).Bar(data, options); + { + type: 'bar', + data, + options: barChartOptions(shouldAdjustFontSize), + }, + ); +}; + +document.addEventListener('DOMContentLoaded', () => { + const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); + const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); + + // Scale fonts if window width lower than 768px (iPad portrait) + const shouldAdjustFontSize = window.innerWidth < 768; + + buildBarChart(chartTimesData, shouldAdjustFontSize); - chartsData.forEach(scope => buildChart(scope)); + chartsData.forEach(scope => buildChart(scope, shouldAdjustFontSize)); }); diff --git a/app/assets/javascripts/pages/projects/tags/releases/index.js b/app/assets/javascripts/pages/projects/tags/releases/index.js new file mode 100644 index 00000000000..d6afc71fb03 --- /dev/null +++ b/app/assets/javascripts/pages/projects/tags/releases/index.js @@ -0,0 +1,8 @@ +import $ from 'jquery'; +import ZenMode from '~/zen_mode'; +import GLForm from '~/gl_form'; + +document.addEventListener('DOMContentLoaded', () => { + new ZenMode(); // eslint-disable-line no-new + new GLForm($('.release-form')); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index d54bff88f70..e1a3f42a71f 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import UsernameValidator from './username_validator'; +import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import SigninTabsMemoizer from './signin_tabs_memoizer'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; @@ -7,6 +8,7 @@ import preserveUrlFragment from './preserve_url_fragment'; document.addEventListener('DOMContentLoaded', () => { new UsernameValidator(); // eslint-disable-line no-new new SigninTabsMemoizer(); // eslint-disable-line no-new + new NoEmojiValidator(); // eslint-disable-line no-new new OAuthRememberMe({ container: $('.omniauth-container'), diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 8a84ac37dab..afa099d0e0b 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -159,7 +159,7 @@ export default class ActivityCalendar { .append('g') .attr('transform', (group, i) => { _.each(group, (stamp, a) => { - if (a === 0 && stamp.day === 0) { + if (a === 0 && stamp.day === this.firstDayOfWeek) { const month = stamp.date.getMonth(); const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace; const lastMonth = _.last(this.months); @@ -205,6 +205,14 @@ export default class ActivityCalendar { y: 29 + this.dayYPos(5), }, ]; + + if (this.firstDayOfWeek === 1) { + days.push({ + text: 'S', + y: 29 + this.dayYPos(7), + }); + } + this.svg .append('g') .selectAll('text') diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 1c3fd58ca74..636308c5401 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -221,7 +221,7 @@ export default class UserTabs { const monthsAgo = UserTabs.getVisibleCalendarPeriod($calendarWrap); const calendarActivitiesPath = $calendarWrap.data('calendarActivitiesPath'); const utcOffset = $calendarWrap.data('utcOffset'); - const calendarHint = __('Issues, merge requests, pushes and comments.'); + const calendarHint = __('Issues, merge requests, pushes, and comments.'); $calendarWrap.html(CALENDAR_TEMPLATE); @@ -234,7 +234,7 @@ export default class UserTabs { data, calendarActivitiesPath, utcOffset, - 0, + gon.first_day_of_week, monthsAgo, ); } diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 74faa35358d..1ec2784cc5a 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -134,6 +134,13 @@ export default { >ms / <span title="Invoke Count">{{ currentRequest.details.gc.invokes }}</span> gc </span> </div> + <div + v-if="currentRequest.details && currentRequest.details.tracing" + id="peek-view-trace" + class="view" + > + <a :href="currentRequest.details.tracing.tracing_url"> trace </a> + </div> <request-selector v-if="currentRequest" :current-request="currentRequest" diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index cf9db89e32b..2b32a6e4a98 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -108,7 +108,7 @@ export default { :href="status.details_path" :title="tooltipText" :class="cssClassJobName" - class="js-pipeline-graph-job-link" + class="js-pipeline-graph-job-link qa-job-link" > <job-name-component :name="job.name" :status="job.status" /> </gl-link> diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue index 0152e2fbe04..244d332f38f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue @@ -59,28 +59,30 @@ export default { </script> <template> <div class="btn-group"> - <gl-button + <button v-gl-tooltip + type="button" :disabled="isLoading" class="dropdown-new btn btn-default js-pipeline-dropdown-manual-actions" - title="Manual job" + :title="__('Manual job')" data-toggle="dropdown" - aria-label="Manual job" + :aria-label="__('Manual job')" > - <icon name="play" class="icon-play" /> <i class="fa fa-caret-down" aria-hidden="true"> </i> + <icon name="play" class="icon-play" /> + <i class="fa fa-caret-down" aria-hidden="true"></i> <gl-loading-icon v-if="isLoading" /> - </gl-button> + </button> <ul class="dropdown-menu dropdown-menu-right"> <li v-for="action in actions" :key="action.path"> <gl-button :class="{ disabled: isActionDisabled(action) }" :disabled="isActionDisabled(action)" - class="js-pipeline-action-link no-btn btn" + class="js-pipeline-action-link no-btn btn d-flex align-items-center justify-content-between flex-wrap" @click="onClickAction(action)" > {{ action.name }} - <span v-if="action.scheduled_at" class="pull-right"> + <span v-if="action.scheduled_at"> <icon name="clock" /> <gl-countdown :end-date-string="action.scheduled_at" /> </span> diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue index 908b10afee6..2ab0ad4d013 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue @@ -1,5 +1,5 @@ <script> -import { GlLink, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; export default { @@ -9,7 +9,6 @@ export default { components: { Icon, GlLink, - GlButton, }, props: { artifacts: { @@ -21,20 +20,22 @@ export default { </script> <template> <div class="btn-group" role="group"> - <gl-button + <button v-gl-tooltip - class="dropdown-toggle build-artifacts js-pipeline-dropdown-download" - title="Artifacts" + type="button" + class="dropdown-toggle build-artifacts btn btn-default js-pipeline-dropdown-download" + :title="__('Artifacts')" data-toggle="dropdown" - aria-label="Artifacts" + :aria-label="__('Artifacts')" > - <icon name="download" /> <i class="fa fa-caret-down" aria-hidden="true"> </i> - </gl-button> + <icon name="download" /> + <i class="fa fa-caret-down" aria-hidden="true"></i> + </button> <ul class="dropdown-menu dropdown-menu-right"> <li v-for="(artifact, i) in artifacts" :key="i"> - <gl-link :href="artifact.path" rel="nofollow" download> - Download {{ artifact.name }} artifacts - </gl-link> + <gl-link :href="artifact.path" rel="nofollow" download + >Download {{ artifact.name }} artifacts</gl-link + > </li> </ul> </div> diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 32bfa47e5f2..74ca3071364 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -94,8 +94,7 @@ export default { this.isLoading = false; this.successCallback(response); - // restart polling - this.poll.restart({ data: this.requestData }); + this.poll.enable({ data: this.requestData, response }); }) .catch(() => { this.isLoading = false; diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index a33835472bb..5ee510eb11d 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -5,97 +5,101 @@ import Api from './api'; import ProjectSelectComboButton from './project_select_combo_button'; export default function projectSelect() { - $('.ajax-project-select').each(function(i, select) { - var placeholder; - const simpleFilter = $(select).data('simpleFilter') || false; - this.groupId = $(select).data('groupId'); - this.includeGroups = $(select).data('includeGroups'); - this.allProjects = $(select).data('allProjects') || false; - this.orderBy = $(select).data('orderBy') || 'id'; - this.withIssuesEnabled = $(select).data('withIssuesEnabled'); - this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); - this.withShared = - $(select).data('withShared') === undefined ? true : $(select).data('withShared'); - this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; - this.allowClear = $(select).data('allowClear') || false; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('.ajax-project-select').each(function(i, select) { + var placeholder; + const simpleFilter = $(select).data('simpleFilter') || false; + this.groupId = $(select).data('groupId'); + this.includeGroups = $(select).data('includeGroups'); + this.allProjects = $(select).data('allProjects') || false; + this.orderBy = $(select).data('orderBy') || 'id'; + this.withIssuesEnabled = $(select).data('withIssuesEnabled'); + this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); + this.withShared = + $(select).data('withShared') === undefined ? true : $(select).data('withShared'); + this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; + this.allowClear = $(select).data('allowClear') || false; - placeholder = 'Search for project'; - if (this.includeGroups) { - placeholder += ' or group'; - } + placeholder = 'Search for project'; + if (this.includeGroups) { + placeholder += ' or group'; + } - $(select).select2({ - placeholder: placeholder, - minimumInputLength: 0, - query: (function(_this) { - return function(query) { - var finalCallback, projectsCallback; - finalCallback = function(projects) { - var data; - data = { - results: projects, - }; - return query.callback(data); - }; - if (_this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { + $(select).select2({ + placeholder: placeholder, + minimumInputLength: 0, + query: (function(_this) { + return function(query) { + var finalCallback, projectsCallback; + finalCallback = function(projects) { var data; - data = groups.concat(projects); - return finalCallback(data); + data = { + results: projects, + }; + return query.callback(data); }; - return Api.groups(query.term, {}, groupsCallback); + if (_this.includeGroups) { + projectsCallback = function(projects) { + var groupsCallback; + groupsCallback = function(groups) { + var data; + data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, {}, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (_this.groupId) { + return Api.groupProjects( + _this.groupId, + query.term, + { + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + with_shared: _this.withShared, + include_subgroups: _this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else { + return Api.projects( + query.term, + { + order_by: _this.orderBy, + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + membership: !_this.allProjects, + }, + projectsCallback, + ); + } }; - } else { - projectsCallback = finalCallback; - } - if (_this.groupId) { - return Api.groupProjects( - _this.groupId, - query.term, - { - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - with_shared: _this.withShared, - include_subgroups: _this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } else { - return Api.projects( - query.term, - { - order_by: _this.orderBy, - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - membership: !_this.allProjects, - }, - projectsCallback, - ); - } - }; - })(this), - id: function(project) { - if (simpleFilter) return project.id; - return JSON.stringify({ - name: project.name, - url: project.web_url, - }); - }, - text: function(project) { - return project.name_with_namespace || project.name; - }, + })(this), + id: function(project) { + if (simpleFilter) return project.id; + return JSON.stringify({ + name: project.name, + url: project.web_url, + }); + }, + text: function(project) { + return project.name_with_namespace || project.name; + }, - initSelection: function(el, callback) { - return Api.project(el.val()).then(({ data }) => callback(data)); - }, + initSelection: function(el, callback) { + return Api.project(el.val()).then(({ data }) => callback(data)); + }, - allowClear: this.allowClear, + allowClear: this.allowClear, - dropdownCssClass: 'ajax-project-dropdown', - }); - if (simpleFilter) return select; - return new ProjectSelectComboButton(select); - }); + dropdownCssClass: 'ajax-project-dropdown', + }); + if (simpleFilter) return select; + return new ProjectSelectComboButton(select); + }); + }) + .catch(() => {}); } diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js index 3dbac3ff942..d3b5f532dc1 100644 --- a/app/assets/javascripts/project_select_combo_button.js +++ b/app/assets/javascripts/project_select_combo_button.js @@ -44,9 +44,13 @@ export default class ProjectSelectComboButton { // eslint-disable-next-line class-methods-use-this openDropdown(event) { - $(event.currentTarget) - .siblings('.project-item-select') - .select2('open'); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $(event.currentTarget) + .siblings('.project-item-select') + .select2('open'); + }) + .catch(() => {}); } selectProject() { diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 998554d1be5..6fb25622a05 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -115,15 +115,59 @@ const bindEvents = () => { const templates = { rails: { text: 'Ruby on Rails', - icon: '.template-option svg.icon-rails', + icon: '.template-option .icon-rails', }, express: { text: 'NodeJS Express', - icon: '.template-option svg.icon-node-express', + icon: '.template-option .icon-express', }, spring: { text: 'Spring', - icon: '.template-option svg.icon-java-spring', + icon: '.template-option .icon-spring', + }, + dotnetcore: { + text: '.NET Core', + icon: '.template-option .icon-dotnet', + }, + hugo: { + text: 'Pages/Hugo', + icon: '.template-option .icon-hugo', + }, + jekyll: { + text: 'Pages/Jekyll', + icon: '.template-option .icon-jekyll', + }, + plainhtml: { + text: 'Pages/Plain HTML', + icon: '.template-option .icon-plainhtml', + }, + gitbook: { + text: 'Pages/GitBook', + icon: '.template-option .icon-gitbook', + }, + hexo: { + text: 'Pages/Hexo', + icon: '.template-option .icon-hexo', + }, + nfhugo: { + text: 'Netlify/Hugo', + icon: '.template-option .icon-netlify', + }, + nfjekyll: { + text: 'Netlify/Jekyll', + icon: '.template-option .icon-netlify', + }, + nfplainhtml: { + text: 'Netlify/Plain HTML', + icon: '.template-option .icon-netlify', + }, + nfgitbook: { + text: 'Netlify/GitBook', + icon: '.template-option .icon-netlify', + }, + nfhexo: { + text: 'Netlify/Hexo', + icon: '.template-option .icon-netlify', }, }; diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/store/actions.js index baa2251403e..b5c4d54ac33 100644 --- a/app/assets/javascripts/releases/store/actions.js +++ b/app/assets/javascripts/releases/store/actions.js @@ -11,7 +11,7 @@ export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES); /** * Fetches the main endpoint. * Will dispatch requestNamespace action before starting the request. - * Will dispatch receiveNamespaceSuccess if the request is successfull + * Will dispatch receiveNamespaceSuccess if the request is successful * Will dispatch receiveNamesapceError if the request returns an error * * @param {String} projectId diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue new file mode 100644 index 00000000000..4d18c5c4bdd --- /dev/null +++ b/app/assets/javascripts/serverless/components/environment_row.vue @@ -0,0 +1,65 @@ +<script> +import FunctionRow from './function_row.vue'; +import ItemCaret from '~/groups/components/item_caret.vue'; + +export default { + components: { + ItemCaret, + FunctionRow, + }, + props: { + env: { + type: Array, + required: true, + }, + envName: { + type: String, + required: true, + }, + }, + data() { + return { + isOpen: true, + }; + }, + computed: { + envId() { + if (this.envName === '*') { + return 'env-global'; + } + + return `env-${this.envName}`; + }, + isOpenClass() { + return { + 'is-open': this.isOpen, + }; + }, + }, + methods: { + toggleOpen() { + this.isOpen = !this.isOpen; + }, + }, +}; +</script> + +<template> + <li :id="envId" :class="isOpenClass" class="group-row has-children"> + <div + class="group-row-contents d-flex justify-content-end align-items-center" + role="button" + @click.stop="toggleOpen" + > + <div class="folder-toggle-wrap d-flex align-items-center"> + <item-caret :is-group-open="isOpen" /> + </div> + <div class="group-text flex-grow title namespace-title prepend-left-default"> + {{ envName }} + </div> + </div> + <ul v-if="isOpen" class="content-list group-list-tree"> + <function-row v-for="(f, index) in env" :key="f.name" :index="index" :func="f" /> + </ul> + </li> +</template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index 2b1c21f041b..4f89ad69129 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -1,13 +1,11 @@ <script> import PodBox from './pod_box.vue'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import Icon from '~/vue_shared/components/icon.vue'; +import Url from './url.vue'; export default { components: { - Icon, PodBox, - ClipboardButton, + Url, }, props: { func: { @@ -36,24 +34,9 @@ export default { <section id="serverless-function-details"> <h3>{{ name }}</h3> <div class="append-bottom-default"> - <div v-for="line in description.split('\n')" :key="line">{{ line }}<br /></div> - </div> - <div class="clipboard-group append-bottom-default"> - <div class="label label-monospace">{{ funcUrl }}</div> - <clipboard-button - :text="String(funcUrl)" - :title="s__('ServerlessDetails|Copy URL to clipboard')" - class="input-group-text js-clipboard-btn" - /> - <a - :href="funcUrl" - target="_blank" - rel="noopener noreferrer nofollow" - class="input-group-text btn btn-default" - > - <icon name="external-link" /> - </a> + <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> </div> + <url :uri="funcUrl" /> <h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4> <div v-if="podCount > 0"> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index 44bfae388cb..773d18781fd 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -1,9 +1,12 @@ <script> import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; +import Url from './url.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; export default { components: { Timeago, + Url, }, props: { func: { @@ -16,13 +19,18 @@ export default { return this.func.name; }, description() { - return this.func.description; + const desc = this.func.description.split('\n'); + if (desc.length > 1) { + return desc[1]; + } + + return desc[0]; }, detailUrl() { return this.func.detail_url; }, - environment() { - return this.func.environment_scope; + targetUrl() { + return this.func.url; }, image() { return this.func.image; @@ -31,25 +39,34 @@ export default { return this.func.created_at; }, }, + methods: { + checkClass(element) { + if (element.closest('.no-expand') === null) { + return true; + } + + return false; + }, + openDetails(e) { + if (this.checkClass(e.target)) { + visitUrl(this.detailUrl); + } + }, + }, }; </script> <template> - <div class="gl-responsive-table-row"> - <div class="table-section section-20 section-wrap"> - <a :href="detailUrl">{{ name }}</a> - </div> - <div class="table-section section-10">{{ environment }}</div> - <div class="table-section section-40 section-wrap"> - <span class="line-break">{{ description }}</span> + <li :id="name" class="group-row"> + <div class="group-row-contents" role="button" @click="openDetails"> + <p class="float-right text-right"> + <span>{{ image }}</span + ><br /> + <timeago :time="timestamp" /> + </p> + <b>{{ name }}</b> + <div v-for="line in description.split('\n')" :key="line">{{ line }}</div> + <url :uri="targetUrl" class="prepend-top-8 no-expand" /> </div> - <div class="table-section section-20">{{ image }}</div> - <div class="table-section section-10"><timeago :time="timestamp" /></div> - </div> + </li> </template> - -<style> -.line-break { - white-space: pre; -} -</style> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 9606a78410e..4bde409f906 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,19 +1,21 @@ <script> import { GlSkeletonLoading } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; +import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; export default { components: { + EnvironmentRow, FunctionRow, EmptyState, GlSkeletonLoading, }, props: { functions: { - type: Array, + type: Object, required: true, - default: () => [], + default: () => ({}), }, installed: { type: Boolean, @@ -45,33 +47,21 @@ export default { <section id="serverless-functions"> <div v-if="installed"> <div v-if="hasFunctionData"> - <div class="ci-table js-services-list function-element"> - <div class="gl-responsive-table-row table-row-header" role="row"> - <div class="table-section section-20" role="rowheader"> - {{ s__('Serverless|Function') }} - </div> - <div class="table-section section-10" role="rowheader"> - {{ s__('Serverless|Cluster Env') }} - </div> - <div class="table-section section-40" role="rowheader"> - {{ s__('Serverless|Description') }} - </div> - <div class="table-section section-20" role="rowheader"> - {{ s__('Serverless|Runtime') }} - </div> - <div class="table-section section-10" role="rowheader"> - {{ s__('Serverless|Last Update') }} - </div> + <template v-if="loadingData"> + <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div> + </template> + <template v-else> + <div class="groups-list-tree-container"> + <ul class="content-list group-list-tree"> + <environment-row + v-for="(env, index) in functions" + :key="index" + :env="env" + :env-name="index" + /> + </ul> </div> - <template v-if="loadingData"> - <div v-for="j in 3" :key="j" class="gl-responsive-table-row"> - <gl-skeleton-loading /> - </div> - </template> - <template v-else> - <function-row v-for="f in functions" :key="f.name" :func="f" /> - </template> - </div> + </template> </div> <div v-else class="empty-state js-empty-state"> <div class="text-content"> @@ -111,16 +101,3 @@ export default { <empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" /> </section> </template> - -<style> -.top-area { - border-bottom: 0; -} - -.function-element { - border-bottom: 1px solid #e5e5e5; - border-bottom-color: rgb(229, 229, 229); - border-bottom-style: solid; - border-bottom-width: 1px; -} -</style> diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue new file mode 100644 index 00000000000..ca53bf6c52a --- /dev/null +++ b/app/assets/javascripts/serverless/components/url.vue @@ -0,0 +1,38 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlButton, + ClipboardButton, + }, + props: { + uri: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="clipboard-group"> + <div class="url-text-field label label-monospace">{{ uri }}</div> + <clipboard-button + :text="uri" + :title="s__('ServerlessURL|Copy URL to clipboard')" + class="input-group-text js-clipboard-btn" + /> + <gl-button + :href="uri" + target="_blank" + rel="noopener noreferrer nofollow" + class="input-group-text btn btn-default" + > + <icon name="external-link" /> + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js index 774c15b5b12..816d55a03f9 100644 --- a/app/assets/javascripts/serverless/stores/serverless_store.js +++ b/app/assets/javascripts/serverless/stores/serverless_store.js @@ -1,7 +1,7 @@ export default class ServerlessStore { constructor(knativeInstalled = false, clustersPath, helpPath) { this.state = { - functions: [], + functions: {}, hasFunctionData: true, loadingData: true, installed: knativeInstalled, @@ -10,8 +10,13 @@ export default class ServerlessStore { }; } - updateFunctionsFromServer(functions = []) { - this.state.functions = functions; + updateFunctionsFromServer(upstreamFunctions = []) { + this.state.functions = upstreamFunctions.reduce((rv, func) => { + const envs = rv; + envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]); + + return envs; + }, {}); } updateLoadingState(loadingData) { diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index edefb3735d7..5172a1ef3d6 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import 'deckar01-task_list'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; @@ -8,46 +9,79 @@ export default class TaskList { this.selector = options.selector; this.dataType = options.dataType; this.fieldName = options.fieldName; + this.lockVersion = options.lockVersion; + this.taskListContainerSelector = `${this.selector} .js-task-list-container`; + this.updateHandler = this.update.bind(this); this.onSuccess = options.onSuccess || (() => {}); - this.onError = function showFlash(e) { - let errorMessages = ''; + this.onError = + options.onError || + function showFlash(e) { + let errorMessages = ''; - if (e.response.data && typeof e.response.data === 'object') { - errorMessages = e.response.data.errors.join(' '); - } + if (e.response.data && typeof e.response.data === 'object') { + errorMessages = e.response.data.errors.join(' '); + } - return new Flash(errorMessages || 'Update failed', 'alert'); - }; + return new Flash(errorMessages || __('Update failed'), 'alert'); + }; this.init(); } init() { - // Prevent duplicate event bindings - this.disable(); - $(`${this.selector} .js-task-list-container`).taskList('enable'); - $(document).on( - 'tasklist:changed', - `${this.selector} .js-task-list-container`, - this.update.bind(this), - ); + this.disable(); // Prevent duplicate event bindings + + $(this.taskListContainerSelector).taskList('enable'); + $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler); + } + + getTaskListTarget(e) { + return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector); + } + + disableTaskListItems(e) { + this.getTaskListTarget(e).taskList('disable'); + } + + enableTaskListItems(e) { + this.getTaskListTarget(e).taskList('enable'); } disable() { - $(`${this.selector} .js-task-list-container`).taskList('disable'); - $(document).off('tasklist:changed', `${this.selector} .js-task-list-container`); + this.disableTaskListItems(); + $(document).off('tasklist:changed', this.taskListContainerSelector); } update(e) { const $target = $(e.target); + const { index, checked, lineNumber, lineSource } = e.detail; const patchData = {}; + patchData[this.dataType] = { [this.fieldName]: $target.val(), + lock_version: this.lockVersion, + update_task: { + index, + checked, + line_number: lineNumber, + line_source: lineSource, + }, }; + this.disableTaskListItems(e); + return axios .patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData) - .then(({ data }) => this.onSuccess(data)) - .catch(err => this.onError(err)); + .then(({ data }) => { + this.lockVersion = data.lock_version; + this.enableTaskListItems(e); + + return this.onSuccess(data); + }) + .catch(({ response }) => { + this.enableTaskListItems(e); + + return this.onError(response.data); + }); } } diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index ce051582299..4017630d6ef 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -579,101 +579,109 @@ function UsersSelect(currentUser, els, options = {}) { }; })(this), ); - $('.ajax-users-select').each( - (function(_this) { - return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('projectId'); - options.groupId = $(select).data('groupId'); - options.showCurrentUser = $(select).data('currentUser'); - options.authorId = $(select).data('authorId'); - options.skipUsers = $(select).data('skipUsers'); - showNullUser = $(select).data('nullUser'); - showAnyUser = $(select).data('anyUser'); - showEmailUser = $(select).data('emailUser'); - firstUser = $(select).data('firstUser'); - return $(select).select2({ - placeholder: 'Search for a user', - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - return _this.users(query.term, options, function(users) { - var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; - data = { - results: users, - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - ref = data.results; - - for (index = 0, len = ref.length; index < len; index += 1) { - obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('.ajax-users-select').each( + (function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('projectId'); + options.groupId = $(select).data('groupId'); + options.showCurrentUser = $(select).data('currentUser'); + options.authorId = $(select).data('authorId'); + options.skipUsers = $(select).data('skipUsers'); + showNullUser = $(select).data('nullUser'); + showAnyUser = $(select).data('anyUser'); + showEmailUser = $(select).data('emailUser'); + firstUser = $(select).data('firstUser'); + return $(select).select2({ + placeholder: 'Search for a user', + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return _this.users(query.term, options, function(users) { + var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; + data = { + results: users, + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + ref = data.results; + + for (index = 0, len = ref.length; index < len; index += 1) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } + } + } + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0, + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + name: name, + id: null, + }; + data.results.unshift(anyUser); } } - } - if (showNullUser) { - nullUser = { - name: 'Unassigned', - id: 0, - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = 'Any User'; + if ( + showEmailUser && + data.results.length === 0 && + query.term.match(/^[^@]+@[^@]+$/) + ) { + var trimmed = query.term.trim(); + emailUser = { + name: 'Invite "' + trimmed + '" by email', + username: trimmed, + id: trimmed, + invite: true, + }; + data.results.unshift(emailUser); } - anyUser = { - name: name, - id: null, - }; - data.results.unshift(anyUser); - } - } - if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { - var trimmed = query.term.trim(); - emailUser = { - name: 'Invite "' + trimmed + '" by email', - username: trimmed, - id: trimmed, - invite: true, - }; - data.results.unshift(emailUser); - } - return query.callback(data); + return query.callback(data); + }); + }, + initSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: 'ajax-users-dropdown', + // we do not want to escape markup since we are displaying html in results + escapeMarkup: function(m) { + return m; + }, }); - }, - initSelection: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: 'ajax-users-dropdown', - // we do not want to escape markup since we are displaying html in results - escapeMarkup: function(m) { - return m; - }, - }); - }; - })(this), - ); + }; + })(this), + ); + }) + .catch(() => {}); } UsersSelect.prototype.initSelection = function(element, callback) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 2f2a37347af..da0a9483f8e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -54,6 +54,12 @@ export default { deployTimeago() { return this.timeFormated(this.deployment.deployed_at); }, + deploymentExternalUrl() { + if (this.deployment.changes && this.deployment.changes.length === 1) { + return this.deployment.changes[0].external_url; + } + return this.deployment.external_url; + }, hasExternalUrls() { return !!(this.deployment.external_url && this.deployment.external_url_formatted); }, @@ -78,7 +84,7 @@ export default { : ''; }, shouldRenderDropdown() { - return this.deployment.changes && this.deployment.changes.length > 0; + return this.deployment.changes && this.deployment.changes.length > 1; }, showMemoryUsage() { return this.hasMetrics && this.showMetrics; @@ -154,12 +160,12 @@ export default { v-if="shouldRenderDropdown" class="js-mr-wigdet-deployment-dropdown inline" :items="deployment.changes" - :main-action-link="deployment.external_url" + :main-action-link="deploymentExternalUrl" filter-key="path" > <template slot="mainAction" slot-scope="slotProps"> <review-app-link - :link="deployment.external_url" + :link="deploymentExternalUrl" :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" /> </template> @@ -183,7 +189,7 @@ export default { </filtered-search-dropdown> <review-app-link v-else - :link="deployment.external_url" + :link="deploymentExternalUrl" css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inlin" /> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue new file mode 100644 index 00000000000..a38f25cce35 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -0,0 +1,40 @@ +<script> +export default { + props: { + value: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <li> + <div class="commit-message-editor"> + <div class="d-flex flex-wrap align-items-center justify-content-between"> + <label class="col-form-label" :for="inputId"> + <strong>{{ label }}</strong> + </label> + <slot name="header"></slot> + </div> + <textarea + :id="inputId" + :value="value" + class="form-control js-gfm-input append-bottom-default commit-message-edit" + required="required" + rows="7" + @input="$emit('input', $event.target.value)" + ></textarea> + <slot name="checkbox"></slot> + </div> + </li> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue new file mode 100644 index 00000000000..b3c1c0e329d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -0,0 +1,38 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + commits: { + type: Array, + required: true, + default: () => [], + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown + right + no-caret + text="Use an existing commit message" + variant="link" + class="mr-commit-dropdown" + > + <gl-dropdown-item + v-for="commit in commits" + :key="commit.short_id" + class="text-nowrap text-truncate" + @click="$emit('input', commit.message)" + > + <span class="monospace mr-2">{{ commit.short_id }}</span> {{ commit.title }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue new file mode 100644 index 00000000000..33963d5e1e6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import _ from 'underscore'; +import { __, n__, sprintf, s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + Icon, + GlButton, + }, + props: { + isSquashEnabled: { + type: Boolean, + required: true, + }, + commitsCount: { + type: Number, + required: false, + default: 0, + }, + targetBranch: { + type: String, + required: true, + }, + }, + data() { + return { + expanded: false, + }; + }, + computed: { + collapseIcon() { + return this.expanded ? 'chevron-down' : 'chevron-right'; + }, + commitsCountMessage() { + return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount); + }, + modifyLinkMessage() { + return this.isSquashEnabled ? __('Modify commit messages') : __('Modify merge commit'); + }, + ariaLabel() { + return this.expanded ? __('Collapse') : __('Expand'); + }, + message() { + return sprintf( + s__( + 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.', + ), + { + commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`, + mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`, + targetBranch: `<span class="label-branch">${_.escape(this.targetBranch)}</span>`, + }, + false, + ); + }, + }, + methods: { + toggle() { + this.expanded = !this.expanded; + }, + }, +}; +</script> + +<template> + <div> + <div + class="js-mr-widget-commits-count mr-widget-extension clickable d-flex align-items-center px-3 py-2" + @click="toggle()" + > + <gl-button + :aria-label="ariaLabel" + variant="blank" + class="commit-edit-toggle square s24 mr-2" + @click.stop="toggle()" + > + <icon :name="collapseIcon" :size="16" /> + </gl-button> + <span v-if="expanded">{{ __('Collapse') }}</span> + <span v-else> + <span class="vertical-align-middle" v-html="message"></span> + <gl-button variant="link" class="modify-message-button"> + {{ modifyLinkMessage }} + </gl-button> + </span> + </div> + <div v-show="expanded"><slot></slot></div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index b8f29649eb5..ce4207864ea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -2,17 +2,24 @@ import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; import simplePoll from '~/lib/utils/simple_poll'; +import { __ } from '~/locale'; import MergeRequest from '../../../merge_request'; import Flash from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; import SquashBeforeMerge from './squash_before_merge.vue'; +import CommitsHeader from './commits_header.vue'; +import CommitEdit from './commit_edit.vue'; +import CommitMessageDropdown from './commit_message_dropdown.vue'; export default { name: 'ReadyToMerge', components: { statusIcon, SquashBeforeMerge, + CommitsHeader, + CommitEdit, + CommitMessageDropdown, }, props: { mr: { type: Object, required: true }, @@ -22,27 +29,20 @@ export default { return { removeSourceBranch: this.mr.shouldRemoveSourceBranch, mergeWhenBuildSucceeds: false, - useCommitMessageWithDescription: false, setToMergeWhenPipelineSucceeds: false, - showCommitMessageEditor: false, isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, squashBeforeMerge: this.mr.squash, successSvg, warningSvg, + squashCommitMessage: this.mr.squashCommitMessage, }; }, computed: { shouldShowMergeWhenPipelineSucceedsText() { return this.mr.isPipelineActive; }, - commitMessageLinkTitle() { - const withDesc = 'Include description in commit message'; - const withoutDesc = "Don't include description in commit message"; - - return this.useCommitMessageWithDescription ? withoutDesc : withDesc; - }, status() { const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; @@ -84,9 +84,9 @@ export default { }, mergeButtonText() { if (this.isMergingImmediately) { - return 'Merge in progress'; + return __('Merge in progress'); } else if (this.shouldShowMergeWhenPipelineSucceedsText) { - return 'Merge when pipeline succeeds'; + return __('Merge when pipeline succeeds'); } return 'Merge'; @@ -98,7 +98,7 @@ export default { const { commitMessage } = this; return Boolean( !commitMessage.length || - !this.shouldShowMergeControls() || + !this.shouldShowMergeControls || this.isMakingRequest || this.mr.preventMerge, ); @@ -110,18 +110,14 @@ export default { const { commitsCount, enableSquashBeforeMerge } = this.mr; return enableSquashBeforeMerge && commitsCount > 1; }, - }, - methods: { shouldShowMergeControls() { return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText; }, - updateCommitMessage() { - const cmwd = this.mr.commitMessageWithDescription; - this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription; - this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage; - }, - toggleCommitMessageEditor() { - this.showCommitMessageEditor = !this.showCommitMessageEditor; + }, + methods: { + updateMergeCommitMessage(includeDescription) { + const { commitMessageWithDescription, commitMessage } = this.mr; + this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage; }, handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) { // TODO: Remove no-param-reassign @@ -139,6 +135,7 @@ export default { merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, should_remove_source_branch: this.removeSourceBranch === true, squash: this.squashBeforeMerge, + squash_commit_message: this.squashCommitMessage, }; this.isMakingRequest = true; @@ -158,7 +155,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; - new Flash('Something went wrong. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line }); }, initiateMergePolling() { @@ -194,7 +191,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line }); }, initiateRemoveSourceBranchPolling() { @@ -223,7 +220,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while deleting the source branch. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line }); }, }, @@ -231,127 +228,136 @@ export default { </script> <template> - <div class="mr-widget-body media"> - <status-icon :status="iconClass" /> - <div class="media-body"> - <div class="mr-widget-body-controls media space-children"> - <span class="btn-group"> - <button - :disabled="isMergeButtonDisabled" - :class="mergeButtonClass" - type="button" - class="qa-merge-button" - @click="handleMergeButtonClick()" - > - <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i> - {{ mergeButtonText }} - </button> - <button - v-if="shouldShowMergeOptionsDropdown" - :disabled="isMergeButtonDisabled" - type="button" - class="btn btn-sm btn-info dropdown-toggle js-merge-moment" - data-toggle="dropdown" - aria-label="Select merge moment" - > - <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> - </button> - <ul - v-if="shouldShowMergeOptionsDropdown" - class="dropdown-menu dropdown-menu-right" - role="menu" - > - <li> - <a - class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option" - href="#" - @click.prevent="handleMergeButtonClick(true)" - > - <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> - <span class="media-body merge-opt-title">Merge when pipeline succeeds</span> - </span> - </a> - </li> - <li> - <a - class="accept-merge-request qa-merge-immediately-option" - href="#" - @click.prevent="handleMergeButtonClick(false, true)" - > - <span class="media"> - <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> - <span class="media-body merge-opt-title">Merge immediately</span> - </span> - </a> - </li> - </ul> - </span> - <div class="media-body-wrap space-children"> - <template v-if="shouldShowMergeControls()"> - <label v-if="mr.canRemoveSourceBranch"> - <input - id="remove-source-branch-input" - v-model="removeSourceBranch" - :disabled="isRemoveSourceBranchButtonDisabled" - class="js-remove-source-branch-checkbox" - type="checkbox" - /> - Delete source branch - </label> - - <!-- Placeholder for EE extension of this component --> - <squash-before-merge - v-if="shouldShowSquashBeforeMerge" - v-model="squashBeforeMerge" - :help-path="mr.squashBeforeMergeHelpPath" - :is-disabled="isMergeButtonDisabled" - /> - - <span v-if="mr.ffOnlyEnabled" class="js-fast-forward-message"> - Fast-forward merge without a merge commit - </span> + <div> + <div class="mr-widget-body media"> + <status-icon :status="iconClass" /> + <div class="media-body"> + <div class="mr-widget-body-controls media space-children"> + <span class="btn-group"> <button - v-else :disabled="isMergeButtonDisabled" - class="js-modify-commit-message-button btn btn-default btn-sm" + :class="mergeButtonClass" type="button" - @click="toggleCommitMessageEditor" + class="qa-merge-button" + @click="handleMergeButtonClick()" > - Modify commit message + <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i> + {{ mergeButtonText }} </button> - </template> - <template v-else> - <span class="bold js-resolve-mr-widget-items-message"> - You can only merge once the items above are resolved - </span> - </template> - </div> - </div> - <div v-if="showCommitMessageEditor" class="prepend-top-default commit-message-editor"> - <div class="form-group clearfix"> - <label class="col-form-label" for="commit-message"> Commit message </label> - <div class="col-sm-10"> - <div class="commit-message-container"> - <div class="max-width-marker"></div> - <textarea - id="commit-message" - v-model="commitMessage" - class="form-control js-commit-message" - required="required" - rows="14" - name="Commit message" - ></textarea> - </div> - <p class="hint"> - Try to keep the first line under 52 characters and the others under 72 - </p> - <div class="hint"> - <a href="#" @click.prevent="updateCommitMessage"> {{ commitMessageLinkTitle }} </a> - </div> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-sm btn-info dropdown-toggle js-merge-moment" + data-toggle="dropdown" + aria-label="Select merge moment" + > + <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu" + > + <li> + <a + class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option" + href="#" + @click.prevent="handleMergeButtonClick(true)" + > + <span class="media"> + <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span> + <span class="media-body merge-opt-title">{{ + __('Merge when pipeline succeeds') + }}</span> + </span> + </a> + </li> + <li> + <a + class="accept-merge-request qa-merge-immediately-option" + href="#" + @click.prevent="handleMergeButtonClick(false, true)" + > + <span class="media"> + <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> + <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span> + </span> + </a> + </li> + </ul> + </span> + <div class="media-body-wrap space-children"> + <template v-if="shouldShowMergeControls"> + <label v-if="mr.canRemoveSourceBranch"> + <input + id="remove-source-branch-input" + v-model="removeSourceBranch" + :disabled="isRemoveSourceBranchButtonDisabled" + class="js-remove-source-branch-checkbox" + type="checkbox" + /> + {{ __('Delete source branch') }} + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + v-model="squashBeforeMerge" + :help-path="mr.squashBeforeMergeHelpPath" + :is-disabled="isMergeButtonDisabled" + /> + </template> + <template v-else> + <span class="bold js-resolve-mr-widget-items-message"> + {{ __('You can only merge once the items above are resolved') }} + </span> + </template> </div> </div> </div> </div> + <template v-if="shouldShowMergeControls"> + <div v-if="mr.ffOnlyEnabled" class="mr-fast-forward-message"> + {{ __('Fast-forward merge without a merge commit') }} + </div> + <template v-else> + <commits-header + :is-squash-enabled="squashBeforeMerge" + :commits-count="mr.commitsCount" + :target-branch="mr.targetBranch" + > + <ul class="border-top content-list commits-list flex-list"> + <commit-edit + v-if="squashBeforeMerge" + v-model="squashCommitMessage" + :label="__('Squash commit message')" + input-id="squash-message-edit" + squash + > + <commit-message-dropdown + slot="header" + v-model="squashCommitMessage" + :commits="mr.commits" + /> + </commit-edit> + <commit-edit + v-model="commitMessage" + :label="__('Merge commit message')" + input-id="merge-message-edit" + > + <label slot="checkbox"> + <input + id="include-description" + type="checkbox" + @change="updateMergeCommitMessage($event.target.checked)" + /> + {{ __('Include merge request description') }} + </label> + </commit-edit> + </ul> + </commits-header> + </template> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js deleted file mode 100644 index 8780aa4bd1c..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js +++ /dev/null @@ -1,3 +0,0 @@ -import MRWidgetOptions from './mr_widget_options.vue'; - -export default MRWidgetOptions; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 60cebbfc2b2..0cedbdbdfef 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import MrWidgetOptions from './ee_switch_mr_widget_options'; +import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import Translate from '../vue_shared/translate'; Vue.use(Translate); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 5a9d86594b1..57c4dfbe3b7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -3,6 +3,9 @@ import _ from 'underscore'; import { __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; +import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; +import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; +import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import createFlash from '../flash'; import WidgetHeader from './components/mr_widget_header.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; @@ -28,10 +31,7 @@ import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue'; import MergeWhenPipelineSucceedsState from './components/states/mr_widget_merge_when_pipeline_succeeds.vue'; import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue'; import CheckingState from './components/states/mr_widget_checking.vue'; -import MRWidgetStore from './stores/ee_switch_mr_widget_store'; -import MRWidgetService from './services/ee_switch_mr_widget_service'; import eventHub from './event_hub'; -import stateMaps from './stores/ee_switch_state_maps'; import notify from '~/lib/utils/notify'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; @@ -142,8 +142,8 @@ export default { } }, methods: { - createService(store) { - const endpoints = { + getServiceEndpoints(store) { + return { mergePath: store.mergePath, mergeCheckPath: store.mergeCheckPath, cancelAutoMergePath: store.cancelAutoMergePath, @@ -154,7 +154,9 @@ export default { mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, }; - return new MRWidgetService(endpoints); + }, + createService(store) { + return new MRWidgetService(this.getServiceEndpoints(store)); }, checkStatus(cb, isRebased) { return this.service diff --git a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js deleted file mode 100644 index ea2aabb78fe..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js +++ /dev/null @@ -1,3 +0,0 @@ -import MRWidgetService from './mr_widget_service'; - -export default MRWidgetService; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js deleted file mode 100644 index ebef30e3eab..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js +++ /dev/null @@ -1,3 +0,0 @@ -import getStateKey from './get_state_key'; - -export default getStateKey; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js deleted file mode 100644 index 92a07c53f2d..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js +++ /dev/null @@ -1,3 +0,0 @@ -import MergeRequestStore from './mr_widget_store'; - -export default MergeRequestStore; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js deleted file mode 100644 index 50cf9503ea7..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js +++ /dev/null @@ -1,3 +0,0 @@ -import stateMaps from './state_maps'; - -export default stateMaps; 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 e5a52c6a7f6..58363f632a9 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 @@ -1,5 +1,5 @@ import Timeago from 'timeago.js'; -import getStateKey from './ee_switch_get_state_key'; +import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { stateKey } from './state_maps'; import { formatDate } from '../../lib/utils/datetime_utility'; @@ -32,16 +32,18 @@ export default class MergeRequestStore { this.sourceBranchProtected = data.source_branch_protected; this.conflictsDocsPath = data.conflicts_docs_path; this.mergeStatus = data.merge_status; - this.commitMessage = data.merge_commit_message; + this.commitMessage = data.default_merge_commit_message; this.shortMergeCommitSha = data.short_merge_commit_sha; this.mergeCommitSha = data.merge_commit_sha; - this.commitMessageWithDescription = data.merge_commit_message_with_description; + this.commitMessageWithDescription = data.default_merge_commit_message_with_description; this.commitsCount = data.commits_count; this.divergedCommitsCount = data.diverged_commits_count; this.pipeline = data.pipeline || {}; this.mergePipeline = data.merge_pipeline || {}; this.deployments = this.deployments || data.deployments || []; this.postMergeDeployments = this.postMergeDeployments || []; + this.commits = data.commits_without_merge_commits || []; + this.squashCommitMessage = data.default_squash_commit_message; this.initRebase(data); if (data.issues_links) { diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index bb7710f708e..e9ab6f5ba7a 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -37,6 +37,11 @@ export default { required: false, default: 12, }, + isCentered: { + type: Boolean, + required: false, + default: true, + }, }, computed: { changedIcon() { @@ -78,7 +83,12 @@ export default { </script> <template> - <span v-gl-tooltip.right :title="tooltipTitle" class="file-changed-icon ml-auto"> + <span + v-gl-tooltip.right + :title="tooltipTitle" + :class="{ 'ml-auto': isCentered }" + class="file-changed-icon" + > <icon v-if="showIcon" :name="changedIcon" :size="size" :css-classes="changedIconClass" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index b8eb555106f..2f498c4fa2a 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -46,6 +46,11 @@ export default { required: false, default: false, }, + cssClasses: { + type: String, + required: false, + default: '', + }, }, computed: { cssClass() { @@ -59,5 +64,5 @@ export default { }; </script> <template> - <span :class="cssClass"> <icon :name="icon" :size="size" /> </span> + <span :class="cssClass"> <icon :name="icon" :size="size" :css-classes="cssClasses" /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index 75c66ed850b..ebb253ff422 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -1,6 +1,5 @@ <script> -import { diffModes } from '~/ide/constants'; -import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils'; +import { diffViewerModes, diffModes } from '~/ide/constants'; import ImageDiffViewer from './viewers/image_diff_viewer.vue'; import DownloadDiffViewer from './viewers/download_diff_viewer.vue'; import RenamedFile from './viewers/renamed.vue'; @@ -12,6 +11,10 @@ export default { type: String, required: true, }, + diffViewerMode: { + type: String, + required: true, + }, newPath: { type: String, required: true, @@ -46,7 +49,7 @@ export default { }, computed: { viewer() { - if (this.diffMode === diffModes.renamed) { + if (this.diffViewerMode === diffViewerModes.renamed) { return RenamedFile; } else if (this.diffMode === diffModes.mode_changed) { return ModeChanged; @@ -54,11 +57,8 @@ export default { if (!this.newPath) return null; - const previewInfo = viewerInformationForPath(this.newPath); - if (!previewInfo) return DownloadDiffViewer; - - switch (previewInfo.id) { - case 'image': + switch (this.diffViewerMode) { + case diffViewerModes.image: return ImageDiffViewer; default: return DownloadDiffViewer; diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue new file mode 100644 index 00000000000..c5cdddf2f64 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue @@ -0,0 +1,5 @@ +<template> + <div class="nothing-here-block"> + {{ __('No preview for this file type') }} + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue new file mode 100644 index 00000000000..d4d3038f066 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue @@ -0,0 +1,5 @@ +<template> + <div class="nothing-here-block"> + {{ __('This diff was suppressed by a .gitattributes entry.') }} + </div> +</template> diff --git a/app/assets/javascripts/ide/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index 0b0cd7b75eb..b57455adaad 100644 --- a/app/assets/javascripts/ide/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -1,45 +1,62 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import Mousetrap from 'mousetrap'; import VirtualList from 'vue-virtual-scroll-list'; import Item from './item.vue'; -import router from '../../ide_router'; -import { - MAX_FILE_FINDER_RESULTS, - FILE_FINDER_ROW_HEIGHT, - FILE_FINDER_EMPTY_ROW_HEIGHT, -} from '../../constants'; -import { - UP_KEY_CODE, - DOWN_KEY_CODE, - ENTER_KEY_CODE, - ESC_KEY_CODE, -} from '../../../lib/utils/keycodes'; +import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; + +export const MAX_FILE_FINDER_RESULTS = 40; +export const FILE_FINDER_ROW_HEIGHT = 55; +export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; + +const originalStopCallback = Mousetrap.stopCallback; export default { components: { Item, VirtualList, }, + props: { + files: { + type: Array, + required: true, + }, + visible: { + type: Boolean, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, + clearSearchOnClose: { + type: Boolean, + required: false, + default: true, + }, + }, data() { return { - focusedIndex: 0, + focusedIndex: -1, searchText: '', mouseOver: false, cancelMouseOver: false, }; }, computed: { - ...mapGetters(['allBlobs']), - ...mapState(['fileFindVisible', 'loading']), filteredBlobs() { const searchText = this.searchText.trim(); if (searchText === '') { - return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS); + return this.files.slice(0, MAX_FILE_FINDER_RESULTS); } - return fuzzaldrinPlus.filter(this.allBlobs, searchText, { + return fuzzaldrinPlus.filter(this.files, searchText, { key: 'path', maxResults: MAX_FILE_FINDER_RESULTS, }); @@ -58,10 +75,12 @@ export default { }, }, watch: { - fileFindVisible() { + visible() { this.$nextTick(() => { - if (!this.fileFindVisible) { - this.searchText = ''; + if (!this.visible) { + if (this.clearSearchOnClose) { + this.searchText = ''; + } } else { this.focusedIndex = 0; @@ -72,7 +91,11 @@ export default { }); }, searchText() { - this.focusedIndex = 0; + this.focusedIndex = -1; + + this.$nextTick(() => { + this.focusedIndex = 0; + }); }, focusedIndex() { if (!this.mouseOver) { @@ -98,8 +121,25 @@ export default { } }, }, + mounted() { + if (this.files.length) { + this.focusedIndex = 0; + } + + Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { + if (e.preventDefault) { + e.preventDefault(); + } + + this.toggle(!this.visible); + }); + + Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); + }, methods: { - ...mapActions(['toggleFileFinder']), + toggle(visible) { + this.$emit('toggle', visible); + }, clearSearchInput() { this.searchText = ''; @@ -139,15 +179,15 @@ export default { this.openFile(this.filteredBlobs[this.focusedIndex]); break; case ESC_KEY_CODE: - this.toggleFileFinder(false); + this.toggle(false); break; default: break; } }, openFile(file) { - this.toggleFileFinder(false); - router.push(`/project${file.url}`); + this.toggle(false); + this.$emit('click', file); }, onMouseOver(index) { if (!this.cancelMouseOver) { @@ -159,14 +199,26 @@ export default { this.cancelMouseOver = false; this.onMouseOver(index); }, + mousetrapStopCallback(e, el, combo) { + if ( + (combo === 't' && el.classList.contains('dropdown-input-field')) || + el.classList.contains('inputarea') + ) { + return true; + } else if (combo === 'command+p' || combo === 'ctrl+p') { + return false; + } + + return originalStopCallback(e, el, combo); + }, }, }; </script> <template> - <div class="ide-file-finder-overlay" @mousedown.self="toggleFileFinder(false)"> - <div class="dropdown-menu diff-file-changes ide-file-finder show"> - <div class="dropdown-input"> + <div class="file-finder-overlay" @mousedown.self="toggle(false)"> + <div class="dropdown-menu diff-file-changes file-finder show"> + <div :class="{ 'has-value': showClearInputButton }" class="dropdown-input"> <input ref="searchInput" v-model="searchText" @@ -186,9 +238,6 @@ export default { ></i> <i :aria-label="__('Clear search input')" - :class="{ - show: showClearInputButton, - }" role="button" class="fa fa-times dropdown-input-clear" @click="clearSearchInput" @@ -203,6 +252,7 @@ export default { :search-text="searchText" :focused="index === focusedIndex" :index="index" + :show-diff-stats="showDiffStats" class="disable-hover" @click="openFile" @mouseover="onMouseOver" @@ -225,3 +275,25 @@ export default { </div> </div> </template> + +<style scoped> +.file-finder-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 200; +} + +.file-finder { + top: 10px; + left: 50%; + transform: translateX(-50%); +} + +.diff-file-changes { + top: 50px; + max-height: 327px; +} +</style> diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue index 83e80d50aff..73511879ff2 100644 --- a/app/assets/javascripts/ide/components/file_finder/item.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -1,5 +1,6 @@ <script> import fuzzaldrinPlus from 'fuzzaldrin-plus'; +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'; @@ -7,6 +8,7 @@ const MAX_PATH_LENGTH = 60; export default { components: { + Icon, ChangedFileIcon, FileIcon, }, @@ -27,6 +29,11 @@ export default { type: Number, required: true, }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, }, computed: { pathWithEllipsis() { @@ -97,8 +104,23 @@ export default { </span> </span> </span> - <span v-if="file.changed || file.tempFile" class="diff-changed-stats"> - <changed-file-icon :file="file" /> + <span v-if="file.changed || file.tempFile" v-once class="diff-changed-stats"> + <span v-if="showDiffStats"> + <span class="cgreen bold"> + <icon name="file-addition" class="align-text-top" /> {{ file.addedLines }} + </span> + <span class="cred bold ml-1"> + <icon name="file-deletion" class="align-text-top" /> {{ file.removedLines }} + </span> + </span> + <changed-file-icon v-else :file="file" /> </span> </button> </template> + +<style scoped> +.highlighted { + color: #1f78d1; + font-weight: 600; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index f54033efc54..0cbcdbf2eb4 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -136,6 +136,7 @@ export default { <div v-else :class="fileClass" + :title="file.name" class="file-row" role="button" @click="clickFile" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue index 834c39a5ee0..4e5dfbf3bf8 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue @@ -1,15 +1,21 @@ <script> import $ from 'jquery'; +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; /** * Renders a split dropdown with * an input that allows to search through the given * array of options. + * + * When there are no results and `showCreateMode` is true + * it renders a create button with the value typed. */ export default { name: 'FilteredSearchDropdown', components: { Icon, + GlButton, }, props: { title: { @@ -43,6 +49,16 @@ export default { type: String, required: true, }, + showCreateMode: { + type: Boolean, + required: false, + default: false, + }, + createButtonText: { + type: String, + required: false, + default: __('Create'), + }, }, data() { return { @@ -64,6 +80,12 @@ export default { return this.items.slice(0, this.visibleItems); }, + computedCreateButtonText() { + return `${this.createButtonText} ${this.filter}`; + }, + shouldRenderCreateButton() { + return this.showCreateMode && this.filteredResults.length === 0 && this.filter !== ''; + }, }, mounted() { /** @@ -112,10 +134,20 @@ export default { <div class="dropdown-content"> <ul> <li v-for="(result, i) in filteredResults" :key="i" class="js-filtered-dropdown-result"> - <slot name="result" :result="result"> {{ result[filterKey] }} </slot> + <slot name="result" :result="result">{{ result[filterKey] }}</slot> </li> </ul> </div> + + <div v-if="shouldRenderCreateButton" class="dropdown-footer"> + <slot name="footer" :filter="filter"> + <gl-button + class="js-dropdown-create-button btn-transparent" + @click="$emit('createItem', filter)" + >{{ computedCreateButtonText }}</gl-button + > + </slot> + </div> </div> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index cc07ef46064..3f607aa2a0a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -27,11 +27,6 @@ export default { type: String, required: true, }, - markdownVersion: { - type: Number, - required: false, - default: 0, - }, addSpacingClasses: { type: Boolean, required: false, @@ -158,7 +153,7 @@ export default { this.markdownPreviewLoading = true; this.markdownPreview = __('Loading…'); this.$http - .post(this.versionedPreviewPath(), { text }) + .post(this.markdownPreviewPath, { text }) .then(resp => resp.json()) .then(data => this.renderMarkdown(data)) .catch(() => new Flash(__('Error loading markdown preview'))); @@ -186,13 +181,6 @@ export default { .then(() => $(this.$refs['markdown-preview']).renderGFM()) .catch(() => new Flash(__('Error rendering markdown preview'))); }, - - versionedPreviewPath() { - const { markdownPreviewPath, markdownVersion } = this; - return `${markdownPreviewPath}${ - markdownPreviewPath.indexOf('?') === -1 ? '?' : '&' - }markdown_version=${markdownVersion}`; - }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index b9f884074d0..a351ca62c94 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -42,7 +42,7 @@ export default { </script> <template> - <div> + <div class="md-suggestion"> <suggestion-diff-header class="qa-suggestion-diff-header" :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" diff --git a/app/assets/javascripts/vue_shared/components/panel_resizer.vue b/app/assets/javascripts/vue_shared/components/panel_resizer.vue index bf736a378dd..8d81940eb91 100644 --- a/app/assets/javascripts/vue_shared/components/panel_resizer.vue +++ b/app/assets/javascripts/vue_shared/components/panel_resizer.vue @@ -28,11 +28,12 @@ export default { data() { return { size: this.startSize, + isDragging: false, }; }, computed: { className() { - return `drag-${this.side}`; + return [`position-${this.side}-0`, { 'is-dragging': this.isDragging }]; }, cursorStyle() { if (this.enabled) { @@ -57,6 +58,7 @@ export default { startDrag(e) { if (this.enabled) { e.preventDefault(); + this.isDragging = true; this.startPos = e.clientX; this.currentStartSize = this.size; document.addEventListener('mousemove', this.drag); @@ -80,6 +82,7 @@ export default { }, endDrag(e) { e.preventDefault(); + this.isDragging = false; document.removeEventListener('mousemove', this.drag); this.$emit('resize-end', this.size); }, @@ -91,7 +94,7 @@ export default { <div :class="className" :style="cursorStyle" - class="drag-handle" + class="position-absolute position-top-0 position-bottom-0 drag-handle" @mousedown="startDrag" @dblclick="resetSize" ></div> diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index 8bdb5bf22c2..fa502b9beb9 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -1,6 +1,7 @@ <script> import Pikaday from 'pikaday'; import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; export default { name: 'DatePicker', @@ -8,7 +9,7 @@ export default { label: { type: String, required: false, - default: 'Date picker', + default: __('Date picker'), }, selectedDate: { type: Date, @@ -40,6 +41,7 @@ export default { toString: date => pikadayToString(date), onSelect: this.selected.bind(this), onClose: this.toggled.bind(this), + firstDay: gon.first_day_of_week, }); this.$el.append(this.calendar.el); diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue index b399c232937..881b5059d2a 100644 --- a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue +++ b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue @@ -26,7 +26,7 @@ export default { </script> <template> - <span :class="sizeClass" class="avatar-container project-avatar"> + <span :class="sizeClass" class="avatar-container rect-avatar project-avatar"> <project-avatar-image v-if="project.avatar_url" :link-href="project.path" @@ -34,6 +34,12 @@ export default { :img-alt="project.name" :img-size="size" /> - <identicon v-else :entity-id="project.id" :entity-name="project.name" :size-class="sizeClass" /> + <identicon + v-else + :entity-id="project.id" + :entity-name="project.name" + :size-class="sizeClass" + class="rect-avatar" + /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue new file mode 100644 index 00000000000..3074ea859cc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -0,0 +1,35 @@ +<script> +import $ from 'jquery'; +import 'select2/select2'; + +export default { + name: 'Select2Select', + props: { + options: { + type: Object, + required: false, + default: () => ({}), + }, + value: { + type: String, + required: false, + default: '', + }, + }, + + mounted() { + $(this.$refs.dropdownInput) + .val(this.value) + .select2(this.options) + .on('change', event => this.$emit('input', event.target.value)); + }, + + beforeDestroy() { + $(this.$refs.dropdownInput).select2('destroy'); + }, +}; +</script> + +<template> + <input ref="dropdownInput" type="hidden" /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue index 6c0c7f15943..45f01a6fced 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue @@ -4,6 +4,7 @@ import datePicker from '../pikaday.vue'; import toggleSidebar from './toggle_sidebar.vue'; import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; import { dateInWords } from '../../../lib/utils/datetime_utility'; +import { __ } from '~/locale'; export default { name: 'SidebarDatePicker', @@ -42,7 +43,7 @@ export default { label: { type: String, required: false, - default: 'Date picker', + default: __('Date picker'), }, selectedDate: { type: Date, diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index 95f4395ac13..a6c1737dcab 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -68,7 +68,8 @@ export default { sanitizedSource() { let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; // Only adds the width to the URL if its not a base64 data image - if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`; + if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) + baseSrc += `?width=${this.size}`; return baseSrc; }, resultantSrcAttribute() { diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index bdf20866197..83ad8766cb5 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -40,16 +40,6 @@ @import "components/**/*"; /* - * Code highlight - */ -@import "highlight/dark"; -@import "highlight/monokai"; -@import "highlight/solarized_dark"; -@import "highlight/solarized_light"; -@import "highlight/white"; -@import "highlight/none"; - -/* * Styles for JS behaviors. */ @import "behaviors"; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 62d471bc30c..555ea276c6c 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -65,3 +65,4 @@ @import 'framework/terms'; @import 'framework/read_more'; @import 'framework/flex_grid'; +@import 'framework/system_messages'; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 4fb787887a1..70d50c74ca9 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -63,15 +63,15 @@ // // Pass in any number of transitions @mixin transition($transitions...) { - $unfoldedTransitions: (); + $unfolded-transitions: (); @each $transition in $transitions { - $unfoldedTransitions: append($unfoldedTransitions, unfoldTransition($transition), comma); + $unfolded-transitions: append($unfolded-transitions, unfold-transition($transition), comma); } - transition: $unfoldedTransitions; + transition: $unfolded-transitions; } -@mixin disableAllAnimation { +@mixin disable-all-animation { /*CSS transitions*/ -o-transition-property: none !important; -moz-transition-property: none !important; @@ -92,27 +92,27 @@ animation: none !important; } -@function unfoldTransition ($transition) { +@function unfold-transition ($transition) { // Default values $property: all; $duration: $general-hover-transition-duration; $easing: $general-hover-transition-curve; // Browser default is ease, which is what we want $delay: null; // Browser default is 0, which is what we want - $defaultProperties: ($property, $duration, $easing, $delay); + $default-properties: ($property, $duration, $easing, $delay); // Grab transition properties if they exist - $unfoldedTransition: (); - @for $i from 1 through length($defaultProperties) { + $unfolded-transition: (); + @for $i from 1 through length($default-properties) { $p: null; @if $i <= length($transition) { $p: nth($transition, $i); } @else { - $p: nth($defaultProperties, $i); + $p: nth($default-properties, $i); } - $unfoldedTransition: append($unfoldedTransition, $p); + $unfolded-transition: append($unfolded-transition, $p); } - @return $unfoldedTransition; + @return $unfolded-transition; } .btn { diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index e132aa4c216..bfd3d776bd4 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -124,6 +124,30 @@ &.s64 { min-width: 64px; min-height: 64px; } } +.rect-avatar { + border-radius: $border-radius-small; + &.s16 { border-radius: $border-radius-small; } + &.s18 { border-radius: $border-radius-small; } + &.s19 { border-radius: $border-radius-small; } + &.s20 { border-radius: $border-radius-small; } + &.s24 { border-radius: $border-radius-default; } + &.s26 { border-radius: $border-radius-default; } + &.s32 { border-radius: $border-radius-default; } + &.s36 { border-radius: $border-radius-default; } + &.s40 { border-radius: $border-radius-default; } + &.s46 { border-radius: $border-radius-default; } + &.s48 { border-radius: $border-radius-large; } + &.s60 { border-radius: $border-radius-large; } + &.s64 { border-radius: $border-radius-large; } + &.s70 { border-radius: $border-radius-large; } + &.s90 { border-radius: $border-radius-large; } + &.s96 { border-radius: $border-radius-large; } + &.s100 { border-radius: $border-radius-large; } + &.s110 { border-radius: $border-radius-large; } + &.s140 { border-radius: $border-radius-large; } + &.s160 { border-radius: $border-radius-large; } +} + .avatar-counter { background-color: $gray-darkest; color: $white-light; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index ad650d45314..5cfd5bbd4f5 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -15,7 +15,7 @@ margin-top: 3px; padding: $gl-padding; z-index: 300; - width: 300px; + width: $award-emoji-width; font-size: 14px; background-color: $white-light; border: 1px solid $border-white-light; @@ -55,6 +55,10 @@ transform: none; } } + + @include media-breakpoint-down(xs) { + width: $award-emoji-width-xs; + } } .emoji-search { @@ -229,10 +233,10 @@ height: $default-icon-size; width: $default-icon-size; border-radius: 50%; + } - path { - fill: $border-gray-normal; - } + path { + fill: $border-gray-normal; } } @@ -243,6 +247,10 @@ left: 10px; bottom: 6px; opacity: 0; + + path { + fill: $award-emoji-positive-add-lines; + } } .award-control-text { diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 5d2cbdde8dc..cb2c8879c5f 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -42,6 +42,10 @@ color: $text; border-color: $border; + &.btn-border-color { + border-color: $border-color; + } + > .icon { color: $text; } @@ -162,7 +166,8 @@ @include btn-outline($white-light, $green-600, $green-500, $green-500, $white-light, $green-600, $green-600, $green-700); } - &.btn-remove { + &.btn-remove, + &.btn-danger { @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700); } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index a499a3a9f95..b09e44e052a 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -48,6 +48,15 @@ color: $brand-info; } +.text-break-word { + word-break: break-all; +} + +.text-underline, +.text-underline:hover { + text-decoration: underline; +} + .hint { font-style: italic; color: $gl-gray-400; } .light { color: $gl-text-color; } @@ -89,6 +98,10 @@ hr { .str-truncated { @include str-truncated; + &-30 { + @include str-truncated(30%); + } + &-60 { @include str-truncated(60%); } @@ -381,19 +394,32 @@ img.emoji { .inline { display: inline-block; } .center { text-align: center; } .vertical-align-middle { vertical-align: middle; } +.vertical-align-sub { vertical-align: sub; } .flex-align-self-center { align-self: center; } .flex-grow { flex-grow: 1; } .flex-no-shrink { flex-shrink: 0; } .ws-initial { white-space: initial; } .overflow-auto { overflow: auto; } +.d-flex-center { + display: flex; + align-items: center; + justify-content: center; +} /** COMMON SIZING CLASSES **/ .w-0 { width: 0; } -.h-13em { height: 13em; } + +.h-12em { height: 12em; } + .mw-460 { max-width: 460px; } .mw-6em { max-width: 6em; } + .min-height-0 { min-height: 0; } +.w-3 { width: #{3 * $grid-size}; } + +.h-3 { width: #{3 * $grid-size}; } + /** COMMON SPACING CLASSES **/ .gl-pl-0 { padding-left: 0; } .gl-pl-1 { padding-left: #{0.5 * $grid-size}; } @@ -408,3 +434,36 @@ img.emoji { .gl-pr-3 { padding-right: #{2 * $grid-size}; } .gl-pr-4 { padding-right: #{3 * $grid-size}; } .gl-pr-5 { padding-right: #{4 * $grid-size}; } + +/** + * Removes browser specific clear icon from input fields in + * Internet Explorer 10, Internet Explorer 11, and Microsoft Edge. + * This is intended for elements which add a customized clear icon. + * + * see also https://developer.mozilla.org/en-US/docs/Web/CSS/::-ms-clear + */ +.ms-no-clear ::-ms-clear { + display: none; +} + +/** COMMON POSITIONING CLASSES */ +.position-bottom-0 { bottom: 0; } +.position-left-0 { left: 0; } +.position-right-0 { right: 0; } +.position-top-0 { top: 0; } + +.drag-handle { + width: 4px; + + &:hover { + background-color: $white-normal; + } + + &.is-dragging { + background-color: $gray-600; + } +} + +.cursor-pointer { + cursor: pointer; +} diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 037a5adfb7e..6108eaa1ad0 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -4,6 +4,7 @@ */ .file-holder { border: 1px solid $border-color; + border-top: 0; border-radius: $border-radius-default; &.file-holder-no-border { @@ -51,6 +52,7 @@ position: absolute; top: 5px; right: 15px; + margin-left: auto; .btn { padding: 0 10px; @@ -324,10 +326,12 @@ span.idiff { &, .file-holder & { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; background-color: $gray-light; border-bottom: 1px solid $border-color; + border-top: 1px solid $border-color; padding: 5px $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; @@ -365,16 +369,12 @@ span.idiff { margin: 0 10px 0 0; } - .file-actions { - white-space: nowrap; - - .btn { - padding: 0 10px; - font-size: 13px; - line-height: 28px; - display: inline-block; - float: none; - } + .file-actions .btn { + padding: 0 10px; + font-size: 13px; + line-height: 28px; + display: inline-block; + float: none; } @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 36dd1cee4de..23dcc1817b1 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -565,15 +565,14 @@ } .navbar-empty { + justify-content: center; height: $header-height; background: $white-light; border-bottom: 1px solid $white-normal; - .mx-auto { - .tanuki-logo, - img { - height: 36px; - } + .tanuki-logo, + .brand-header-logo { + max-height: 100%; } } diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index a20920e2503..d78c707192f 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -38,7 +38,10 @@ svg { fill: currentColor; +} +.square, +svg { $svg-sizes: 8 10 12 14 16 18 24 32 48 72; @each $svg-size in $svg-sizes { &.s#{$svg-size} { diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 679148ddf7b..d6c4e68f68f 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -149,14 +149,6 @@ margin: 10px 0; } - // Border around images in issue and MR comments. - img:not(.emoji) { - border: 1px solid $white-normal; - padding: 5px; - margin: 5px 0; - // Ensure that image does not exceed viewport - max-height: calc(100vh - 100px); - } table:not(.js-syntax-highlight) { @include markdown-table; @@ -228,7 +220,7 @@ .cur { .avatar { - @include disableAllAnimation; + @include disable-all-animation; border: 1px solid $white-light; } } @@ -280,6 +272,8 @@ .md-suggestion-diff { display: table !important; border: 1px solid $border-color !important; + width: 100% !important; + font-family: $monospace-font !important; } .md-suggestion-header { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 9837b1a6bd0..3b0869e31a9 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -36,10 +36,6 @@ width: fit-content; } - tbody { - background-color: $white-light; - } - tr { th { border-bottom: solid 2px $gl-gray-100; @@ -117,11 +113,6 @@ } } -@mixin dark-diff-match-line { - color: $dark-diff-match-bg; - background: $dark-diff-match-color; -} - @mixin webkit-prefix($property, $value) { #{'-webkit-' + $property}: $value; #{$property}: $value; diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index ace46e32b18..3703b7568c8 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -111,10 +111,11 @@ body.modal-open { flex-grow: 1; height: 56px; padding: $gl-btn-padding $gl-btn-padding 0; + text-align: right; - > svg { - float: right; - height: 100%; + .illustration { + height: inherit; + width: initial; } } } diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss new file mode 100644 index 00000000000..3d66136938f --- /dev/null +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -0,0 +1,110 @@ +.header-message, +.footer-message { + padding: 0 15px; + border: 1px solid transparent; + border-radius: 0; + position: fixed; + left: 0; + width: 100%; + text-align: center; + margin: 0; + z-index: 1000; + + p { + @include str-truncated(100%); + margin-top: 0; + margin-bottom: 0; + } +} + +.header-message { + top: 0; + height: $system-header-height; + line-height: $system-header-height; +} + +.footer-message { + bottom: 0; + height: $system-footer-height; + line-height: $system-footer-height; +} + +.with-performance-bar { + .header-message { + top: $performance-bar-height; + } +} + +// System Header +.with-system-header { + // main navigation + // login page + .navbar-gitlab, + .fixed-top { + top: $system-header-height; + } + + // left sidebar eg: project page + // right sidebar eg: MR page + .nav-sidebar, + .right-sidebar { + top: $system-header-height + $header-height; + } + + .content-wrapper { + margin-top: $system-header-height + $header-height; + } + + // Performance Bar + // System Header + &.with-performance-bar { + // main navigation + header.navbar-gitlab { + top: $performance-bar-height + $system-header-height; + } + + .layout-page { + margin-top: $header-height + $performance-bar-height + $system-header-height; + } + + // left sidebar eg: project page + // right sidebar eg: MR page + .nav-sidebar, + .right-sidebar { + top: $header-height + $performance-bar-height + $system-header-height; + } + } +} + +// System Footer +.with-system-footer { + // left sidebar eg: project page + // right sidebar eg: mr page + .nav-sidebar, + .right-sidebar, + // navless pages' footer eg: login page + // navless pages' footer border eg: login page + &.devise-layout-html body .footer-container, + &.devise-layout-html body hr.footer-fixed { + bottom: $system-footer-height; + } +} + +.fullscreen-layout { + .header-message, + .footer-message { + position: static; + top: auto; + bottom: auto; + } + + .content-wrapper { + .with-system-header & { + margin-top: 0; + } + + .with-system-footer & { + margin-top: 0; + } + } +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index a08639936c0..1b36c1f4862 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -49,13 +49,6 @@ word-wrap: normal; } - // Multi-line code blocks should scroll horizontally - pre { - code { - white-space: pre; - } - } - kbd { display: inline-block; padding: 3px 5px; @@ -166,6 +159,10 @@ overflow-x: auto; border-radius: 2px; + // Multi-line code blocks should scroll horizontally + code { + white-space: pre; + } &.plain-readme { background: none; @@ -303,11 +300,10 @@ body { } .page-title-empty { - margin-top: 0; + margin: 12px 0; line-height: 1.3; font-size: 1.25em; font-weight: $gl-font-weight-bold; - margin: 12px 0; } h1, @@ -375,6 +371,16 @@ code { .md:not(.use-csslab) { @include md-typography; + + &:not(.wiki) { + img:not(.emoji) { + border: 1px solid $white-normal; + padding: 5px; + margin: 5px 0; + // Ensure that image does not exceed viewport + max-height: calc(100vh - 100px); + } + } } /** diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index e886a54dc99..25b272ab3a9 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -243,6 +243,7 @@ $gl-padding-8: 8px; $gl-padding: 16px; $gl-padding-24: 24px; $gl-padding-32: 32px; +$gl-padding-50: 50px; $gl-col-padding: 15px; $gl-input-padding: 10px; $gl-vert-padding: 6px; @@ -250,7 +251,7 @@ $gl-padding-top: 10px; $gl-sidebar-padding: 22px; $gl-bar-padding: 3px; $input-horizontal-padding: 12px; -$browserScrollbarSize: 10px; +$browser-scrollbar-size: 10px; /* * Misc @@ -264,6 +265,7 @@ $container-text-max-width: 540px; $gl-avatar-size: 40px; $border-radius-default: 4px; $border-radius-small: 2px; +$border-radius-large: 8px; $default-icon-size: 18px; $layout-link-gray: #7e7c7c; $btn-side-margin: 10px; @@ -275,11 +277,13 @@ $general-hover-transition-duration: 100ms; $general-hover-transition-curve: linear; $highlight-changes-color: rgb(235, 255, 232); $performance-bar-height: 35px; +$system-header-height: 35px; +$system-footer-height: $system-header-height; $flash-height: 52px; $context-header-height: 60px; $breadcrumb-min-height: 48px; -$project-title-row-height: 64px; -$project-avatar-mobile-size: 24px; +$home-panel-title-row-height: 64px; +$home-panel-avatar-mobile-size: 24px; $gl-line-height: 16px; $gl-line-height-24: 24px; $gl-line-height-14: 14px; @@ -404,6 +408,8 @@ $status-icon-size: 22px; $award-emoji-menu-shadow: rgba(0, 0, 0, 0.175); $award-emoji-positive-add-bg: #fed159; $award-emoji-positive-add-lines: #bb9c13; +$award-emoji-width: 376px; +$award-emoji-width-xs: 300px; /* * Search Box @@ -490,6 +496,7 @@ $builds-trace-bg: #111; */ $commit-max-width-marker-color: rgba(0, 0, 0, 0); $commit-message-text-area-bg: rgba(0, 0, 0, 0); +$commit-stat-summary-height: 36px; /* * Common @@ -664,8 +671,14 @@ $priority-label-empty-state-width: 114px; Issues Analytics */ $issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15); + /* Merge Requests */ $mr-tabs-height: 51px; $mr-version-controls-height: 56px; + +/* +Compare Branches +*/ +$compare-branches-sticky-header-height: 68px; diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 1dfe2a69a2f..814e802f7c1 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -41,5 +41,6 @@ $spacers: ( 2: ($spacer), 3: ($spacer * 2), 4: ($spacer * 3), - 5: ($spacer * 4) + 5: ($spacer * 4), + 6: ($spacer * 8) ); diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss new file mode 100644 index 00000000000..2b0794759d5 --- /dev/null +++ b/app/assets/stylesheets/highlight/common.scss @@ -0,0 +1,18 @@ +@import "../framework/variables"; + +@mixin diff-background($background, $idiff, $border) { + background: $background; + + &.line_content span.idiff { + background: $idiff; + } + + &.diff-line-num { + border-color: $border; + } +} + +@mixin dark-diff-match-line { + color: $dark-diff-match-bg; + background: $dark-diff-match-color; +} diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 604f806dc58..16893dd047e 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -1,5 +1,7 @@ /* https://github.com/MozMorris/tomorrow-pygments */ +@import "../common"; + /* * Dark syntax colors */ @@ -125,7 +127,7 @@ $dark-il: #de935f; .diff-line-num.new, .line_content.new { - @include diff_background($dark-new-bg, $dark-new-idiff, $dark-border); + @include diff-background($dark-new-bg, $dark-new-idiff, $dark-border); &::before, a { @@ -135,7 +137,7 @@ $dark-il: #de935f; .diff-line-num.old, .line_content.old { - @include diff_background($dark-old-bg, $dark-old-idiff, $dark-border); + @include diff-background($dark-old-bg, $dark-old-idiff, $dark-border); &::before, a { diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 8e2720511da..37fe61b925c 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -1,5 +1,7 @@ /* https://github.com/richleland/pygments-css/blob/master/monokai.css */ +@import "../common"; + /* * Monokai Colors */ @@ -125,7 +127,7 @@ $monokai-gi: #a6e22e; .diff-line-num.new, .line_content.new { - @include diff_background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border); + @include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border); &::before, a { @@ -135,7 +137,7 @@ $monokai-gi: #a6e22e; .diff-line-num.old, .line_content.old { - @include diff_background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border); + @include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border); &::before, a { diff --git a/app/assets/stylesheets/highlight/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index 7d692a87e33..b4217aac37a 100644 --- a/app/assets/stylesheets/highlight/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -2,9 +2,9 @@ * None Syntax Colors */ +@import "../common"; - -@mixin matchLine { +@mixin match-line { color: $black-transparent; background-color: $white-normal; } @@ -38,30 +38,24 @@ $none-over-bg: #ded7fc; $none-expanded-border: #e0e0e0; - $none-expanded-bg: #f7f7f7; + $none-expanded-bg: #e0e0e0; .line_holder { &.match .line_content, .new-nonewline.line_content, .old-nonewline.line_content { - @include matchLine; + @include match-line; } .diff-line-num { &.old { - background-color: $line-number-old; - border-color: $line-removed-dark; - a { color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); } } &.new { - background-color: $line-number-new; - border-color: $line-added-dark; - a { color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); } @@ -78,8 +72,8 @@ } &.hll:not(.empty-cell) { - background-color: $line-number-select; - border-color: $line-select-yellow-dark; + background-color: $white-light; + border-color: $white-normal; } } @@ -101,35 +95,37 @@ .line_content { &.old { - background-color: $line-removed; + background-color: $white-normal; &::before { - color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); + color: $gl-text-color; } span.idiff { - background-color: $line-removed-dark; + background-color: $white-normal; + text-decoration: underline; } } &.new { - background-color: $line-added; + background-color: $white-normal; &::before { - color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); + color: $gl-text-color; } span.idiff { - background-color: $line-added-dark; + background-color: $white-normal; + text-decoration: underline; } } &.match { - @include matchLine; + @include match-line; } &.hll:not(.empty-cell) { - background-color: $line-select-yellow; + background-color: $white-normal; } } } diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index cd1f0f6650f..a4e9eda22c9 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -1,5 +1,7 @@ /* https://gist.github.com/qguv/7936275 */ +@import "../common"; + /* * Solarized dark colors */ @@ -129,7 +131,7 @@ $solarized-dark-il: #2aa198; .diff-line-num.new, .line_content.new { - @include diff_background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border); + @include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border); &::before, a { @@ -139,7 +141,7 @@ $solarized-dark-il: #2aa198; .diff-line-num.old, .line_content.old { - @include diff_background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border); + @include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border); &::before, a { diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 09c3ea36414..b604d1ccb6c 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -1,5 +1,7 @@ /* https://gist.github.com/qguv/7936275 */ +@import "../common"; + /* * Solarized light syntax colors */ @@ -90,7 +92,7 @@ $solarized-light-vg: #268bd2; $solarized-light-vi: #268bd2; $solarized-light-il: #2aa198; -@mixin matchLine { +@mixin match-line { color: $black-transparent; background: $solarized-light-matchline-bg; } @@ -125,7 +127,7 @@ $solarized-light-il: #2aa198; &.match .line_content, &.old-nonewline .line_content, &.new-nonewline .line_content { - @include matchLine; + @include match-line; } td.diff-line-num.hll:not(.empty-cell), @@ -136,7 +138,7 @@ $solarized-light-il: #2aa198; .diff-line-num.new, .line_content.new { - @include diff_background($solarized-light-new-bg, + @include diff-background($solarized-light-new-bg, $solarized-light-new-idiff, $solarized-light-border); &::before, @@ -147,7 +149,7 @@ $solarized-light-il: #2aa198; .diff-line-num.old, .line_content.old { - @include diff_background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border); + @include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border); &::before, a { @@ -168,7 +170,7 @@ $solarized-light-il: #2aa198; } .line_content.match { - @include matchLine; + @include match-line; } &:not(.diff-expanded) + .diff-expanded, diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss new file mode 100644 index 00000000000..7239086f649 --- /dev/null +++ b/app/assets/stylesheets/highlight/themes/white.scss @@ -0,0 +1,3 @@ +.code.white { + @import "../white_base"; +} diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss deleted file mode 100644 index 355c8d223f7..00000000000 --- a/app/assets/stylesheets/highlight/white.scss +++ /dev/null @@ -1,3 +0,0 @@ -.code.white { - @import "white_base"; -} diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 90a5250c247..23ec3380ce9 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -1,5 +1,7 @@ /* https://github.com/aahan/pygments-github-style */ +@import "./common"; + /* * White Syntax Colors */ @@ -70,7 +72,7 @@ $white-gc-color: #999; $white-gc-bg: #eaf2f5; -@mixin matchLine { +@mixin match-line { color: $black-transparent; background-color: $gray-light; } @@ -105,7 +107,7 @@ pre.code, &.match .line_content, .new-nonewline.line_content, .old-nonewline.line_content { - @include matchLine; + @include match-line; } .diff-line-num { @@ -185,7 +187,7 @@ pre.code, } &.match { - @include matchLine; + @include match-line; } &.hll:not(.empty-cell) { diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 553cc44fe83..a80158943c6 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -395,6 +395,11 @@ $ide-commit-header-height: 48px; svg { vertical-align: sub; } + + .ide-status-avatar { + float: none; + margin: 0 0 1px; + } } .ide-status-file { @@ -677,25 +682,6 @@ $ide-commit-header-height: 48px; flex: 1; } -.drag-handle { - position: absolute; - top: 0; - bottom: 0; - width: 4px; - - &:hover { - background-color: $white-normal; - } - - &.drag-right { - right: 0; - } - - &.drag-left { - left: 0; - } -} - .ide-commit-list-container { display: flex; flex: 1; @@ -811,26 +797,6 @@ $ide-commit-header-height: 48px; z-index: 1; } -.ide-file-finder-overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 100; -} - -.ide-file-finder { - top: 10px; - left: 50%; - transform: translateX(-50%); - - .highlighted { - color: $blue-500; - font-weight: $gl-font-weight-bold; - } -} - .ide-commit-message-field { height: 200px; background-color: $white-light; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index bc28ffb3a92..a9324ba2ed0 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -164,6 +164,13 @@ display: none; } } + + &:not(.is-collapsed) { + .board-list-component { + display: flex; + flex-direction: column; + } + } } .board-inner { diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss index 38fec3f0aa8..ce0622b3d48 100644 --- a/app/assets/stylesheets/pages/branches.scss +++ b/app/assets/stylesheets/pages/branches.scss @@ -11,15 +11,24 @@ } .divergence-graph { + $graph-side-width: 80px; + $graph-separator-width: 1px; + padding: 0 6px; .graph-side { position: relative; - width: 80px; + width: $graph-side-width; height: 22px; padding: 5px 0 13px; float: left; + &.full { + width: $graph-side-width * 2 + $graph-separator-width; + display: flex; + justify-content: center; + } + .bar { position: absolute; height: 4px; @@ -57,7 +66,7 @@ .graph-separator { position: relative; - width: 1px; + width: $graph-separator-width; height: 18px; margin: 5px 0 0; float: left; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 65f46e3852a..fa5a182243c 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -75,7 +75,11 @@ @include build-trace-top-bar(35px); &.has-archived-block { - top: $header-height + $performance-bar-height + 28px; + top: $header-height + 28px; + + .with-performance-bar & { + top: $header-height + $performance-bar-height + 28px; + } } &.affix { diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index ad12cd101b6..809ba6d4953 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -58,6 +58,20 @@ } } +.cluster-application-banner { + height: 45px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.cluster-application-banner-close { + align-self: flex-start; + font-weight: 500; + font-size: 20px; + margin: $gl-padding-8 14px 0 0; +} + .cluster-application-description { flex: 1; } diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index 37ed5ae674a..cb5f1a84005 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -34,7 +34,6 @@ .detail-page-header-actions { align-self: center; - flex-shrink: 0; flex: 0 0 auto; @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 4f804866886..d001dff7986 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -7,22 +7,13 @@ cursor: pointer; @media (min-width: map-get($grid-breakpoints, md)) { + $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height; + position: -webkit-sticky; position: sticky; - top: $mr-version-controls-height + $header-height + $mr-tabs-height; - margin-left: -1px; - border-left: 1px solid $border-color; + top: $mr-file-header-top; z-index: 102; - &.is-commit { - top: $header-height + 36px; - - .with-performance-bar & { - top: $header-height + 36px + $performance-bar-height; - - } - } - &::before { content: ''; position: absolute; @@ -35,7 +26,23 @@ } .with-performance-bar & { - top: $header-height + $performance-bar-height + $mr-version-controls-height + $mr-tabs-height; + top: $mr-file-header-top + $performance-bar-height; + } + + &.is-commit { + top: $header-height + $commit-stat-summary-height; + + .with-performance-bar & { + top: $header-height + $commit-stat-summary-height + $performance-bar-height; + } + } + + &.is-compare { + top: $header-height + $compare-branches-sticky-header-height; + + .with-performance-bar & { + top: $performance-bar-height + $header-height + $compare-branches-sticky-header-height; + } } } @@ -501,6 +508,25 @@ } } +.diff-stats { + align-items: center; + padding: 0 .25rem; + + .diff-stats-group { + padding: 0 .25rem; + } + + svg.diff-stats-icon { + vertical-align: text-bottom; + } + + &.is-compare-versions-header { + .diff-stats-group { + padding: 0 .5rem; + } + } +} + .file-content .diff-file { margin: 0; border: 0; @@ -576,18 +602,6 @@ } } -@mixin diff_background($background, $idiff, $border) { - background: $background; - - &.line_content span.idiff { - background: $idiff; - } - - &.diff-line-num { - border-color: $border; - } -} - .files { .diff-file:last-child { margin-bottom: 0; @@ -1012,12 +1026,30 @@ } .diff-tree-list { - width: 320px; + position: -webkit-sticky; + position: sticky; + $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; + top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; + max-height: calc(100vh - #{$top-pos}); + padding-right: $gl-padding; + z-index: 202; + + .with-performance-bar & { + $performance-bar-top-pos: $performance-bar-height + $top-pos; + top: $performance-bar-top-pos; + max-height: calc(100vh - #{$performance-bar-top-pos}); + } + + .drag-handle { + bottom: 16px; + transform: translateX(-6px); + } } .diff-files-holder { flex: 1; min-width: 0; + z-index: 201; } .compare-versions-container { @@ -1025,23 +1057,12 @@ } .tree-list-holder { - position: -webkit-sticky; - position: sticky; - $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; - top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; - max-height: calc(100vh - $top-pos); - padding-right: $gl-padding; + height: 100%; .file-row { margin-left: 0; margin-right: 0; } - - .with-performance-bar & { - $performance-bar-top-pos: $performance-bar-height + $top-pos; - top: $performance-bar-top-pos; - max-height: calc(100vh - $performance-bar-top-pos); - } } .tree-list-scroll { @@ -1095,12 +1116,6 @@ } } -.tree-list-view-toggle { - svg { - top: 0; - } -} - .image-diff-overlay, .image-diff-overlay-add-comment { top: 0; diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 5a988b184b6..655b297295a 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -182,9 +182,8 @@ .template-selector-dropdowns-wrap { display: inline-block; - margin-left: 8px; - vertical-align: top; margin: 5px 0 0 8px; + vertical-align: top; @media(max-width: map-get($grid-breakpoints, md)-1) { display: block; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index b6abb792709..61ecf133b02 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -240,18 +240,7 @@ } .prometheus-graph { - flex: 1 0 auto; - min-width: 450px; - max-width: 100%; padding: $gl-padding / 2; - - h5 { - font-size: 16px; - } - - @include media-breakpoint-down(sm) { - min-width: 100%; - } } .prometheus-graph-header { @@ -261,6 +250,7 @@ margin-bottom: $gl-padding-8; h5 { + font-size: $gl-font-size-large; margin: 0; } } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index ebbb5beed81..8ade995525a 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -29,9 +29,7 @@ } } -.group-nav-container .group-search, .group-nav-container .nav-controls { - display: flex; align-items: flex-start; padding: $gl-padding-top 0 0; @@ -44,6 +42,52 @@ margin-top: 0; } + @include media-breakpoint-down(sm) { + .dropdown, + .dropdown .dropdown-toggle, + .btn-success { + display: block; + } + + .group-filter-form, + .dropdown { + margin-bottom: 10px; + margin-right: 0; + } + + &, + .group-filter-form, + .group-filter-form-field, + .dropdown, + .dropdown .dropdown-toggle, + .btn-success { + width: 100%; + } + + .dropdown .dropdown-toggle .fa-chevron-down { + position: absolute; + top: 11px; + right: 8px; + } + } +} + +.home-panel-buttons { + .home-panel-action-button { + vertical-align: top; + } + + + .notification-dropdown { + .dropdown-menu { + @extend .dropdown-menu-right; + } + + .icon { + fill: $gl-text-color-secondary; + } + } + .new-project-subgroup { .dropdown-primary { min-width: 115px; @@ -99,61 +143,29 @@ font-weight: $gl-font-weight-bold; } } - } - } - - @include media-breakpoint-down(sm) { - &, - .dropdown, - .dropdown .dropdown-toggle, - .btn-success { - display: block; - } - .group-filter-form, - .dropdown { - margin-bottom: 10px; - margin-right: 0; - } - - .group-filter-form, - .dropdown .dropdown-toggle, - .btn-success { - width: 100%; - } - - .dropdown .dropdown-toggle .fa-chevron-down { - position: absolute; - top: 11px; - right: 8px; - } - - .new-project-subgroup { - display: flex; - align-items: flex-start; + @include media-breakpoint-down(sm) { + display: flex; + align-items: flex-start; - .dropdown-primary { - flex: 1; - } + .dropdown-primary { + flex: 1; + } - .dropdown-toggle { - width: auto; - } + .dropdown-toggle { + width: auto; + } - .dropdown-menu { - width: 100%; - max-width: inherit; - min-width: inherit; + .dropdown-menu { + width: 100%; + max-width: inherit; + min-width: inherit; + } } } } } -.group-nav-container .group-search { - padding: $gl-padding 0; - border-bottom: 1px solid $border-color; -} - .groups-listing { .group-list-tree .group-row:first-child { border-top: 0; diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss index a4f76a9495a..7f800367cad 100644 --- a/app/assets/stylesheets/pages/import.scss +++ b/app/assets/stylesheets/pages/import.scss @@ -1,20 +1,51 @@ -.import-jobs-from-col, .import-jobs-to-col { - width: 40%; + width: 39%; } .import-jobs-status-col { - width: 20%; + width: 15%; } -.btn-import { - .loading-icon { - display: none; +.import-jobs-cta-col { + width: 1%; +} + +.import-project-name-input { + border-radius: 0 $border-radius-default $border-radius-default 0; + position: relative; + left: -1px; + max-width: 300px; +} + +.import-namespace-select { + width: auto !important; + + > .select2-choice { + border-radius: $border-radius-default 0 0 $border-radius-default; + position: relative; + left: 1px; } +} - &.is-loading { - .loading-icon { - display: inline-block; - } +.import-slash-divider { + background-color: $gray-lightest; + border: 1px solid $border-color; +} + +.import-row { + height: 55px; +} + +.import-table { + .import-jobs-from-col, + .import-jobs-to-col, + .import-jobs-status-col, + .import-jobs-cta-col { + border-bottom-width: 1px; + padding-left: $gl-padding; } } + +.import-projects-loading-icon { + margin-top: $gl-padding-32; +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 53afb182b54..cfd3faab122 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -38,9 +38,7 @@ } .mr-widget-section { - .media { - align-items: center; - } + border-radius: $border-radius-default $border-radius-default 0 0; .code-text { flex: 1; @@ -56,6 +54,11 @@ .mr-widget-extension { border-top: 1px solid $border-color; background-color: $gray-light; + + &.clickable:hover { + background-color: $gl-gray-200; + cursor: pointer; + } } .mr-widget-workflow { @@ -78,7 +81,7 @@ border-top: 0; } -.mr-widget-section, +.mr-widget-body, .mr-widget-content, .mr-widget-footer { padding: $gl-padding; @@ -87,11 +90,38 @@ .mr-state-widget { color: $gl-text-color; + .commit-message-edit { + border-radius: $border-radius-default; + } + .mr-widget-section, .mr-widget-footer { border-top: solid 1px $border-color; } + .mr-fast-forward-message { + padding-left: $gl-padding-50; + padding-bottom: $gl-padding; + } + + .commits-list { + > li { + padding: $gl-padding; + + @include media-breakpoint-up(md) { + padding-left: $gl-padding-50; + } + } + } + + .mr-commit-dropdown { + .dropdown-menu { + @include media-breakpoint-up(md) { + width: 150%; + } + } + } + .mr-widget-footer { padding: 0; } @@ -405,7 +435,7 @@ } .mr-widget-help { - padding: 10px 16px 10px 48px; + padding: 10px 16px 10px $gl-padding-50; font-style: italic; } @@ -423,10 +453,6 @@ } } -.mr-widget-body-controls { - flex-wrap: wrap; -} - .mr_source_commit, .mr_target_commit { margin-bottom: 0; @@ -708,9 +734,11 @@ .mr-version-controls { position: relative; - z-index: 103; + z-index: 203; background: $gray-light; color: $gl-text-color; + margin-top: -1px; + border-top: 1px solid $border-color; .mr-version-menus-container { display: flex; @@ -762,7 +790,6 @@ position: sticky; top: $header-height + $mr-tabs-height; width: 100%; - border-top: 1px solid $border-color; &.is-fileTreeOpen { margin-left: -16px; @@ -781,12 +808,9 @@ .merge-request-tabs-holder { top: $header-height; - z-index: 200; + z-index: 300; background-color: $white-light; - - @include media-breakpoint-down(md) { - border-bottom: 1px solid $border-color; - } + border-bottom: 1px solid $border-color; @include media-breakpoint-up(sm) { position: sticky; @@ -986,3 +1010,14 @@ width: $ci-action-icon-size-lg; } } + +.merge-request-details .file-finder-overlay.diff-file-finder { + position: fixed; + z-index: 99999; + background: $black-transparent; +} + +.source-branch-removal-status { + padding-left: 50px; + padding-bottom: $gl-padding; +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 23b9e4f9416..1198b9ea143 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -4,7 +4,7 @@ $note-form-margin-left: 72px; @mixin vertical-line($left) { &::before { - content: ''; + content: ""; border-left: 2px solid $gray-100; position: absolute; top: 0; @@ -53,12 +53,12 @@ $note-form-margin-left: 72px; &.note-form { margin-left: 0; - @include notes-media('min', map-get($grid-breakpoints, md)) { + @include notes-media("min", map-get($grid-breakpoints, md)) { margin-left: $note-form-margin-left; } .timeline-icon { - @include notes-media('min', map-get($grid-breakpoints, sm)) { + @include notes-media("min", map-get($grid-breakpoints, sm)) { margin-left: -$note-icon-gutter-width; } } @@ -242,7 +242,7 @@ $note-form-margin-left: 72px; } .note-header { - @include notes-media('max', map-get($grid-breakpoints, xs)) { + @include notes-media("max", map-get($grid-breakpoints, xs)) { .inline { display: block; } @@ -303,28 +303,8 @@ $note-form-margin-left: 72px; } } - .timeline-icon { - float: left; - display: flex; - align-items: center; - background-color: $white-light; - width: $system-note-icon-size; - height: $system-note-icon-size; - border: 1px solid $border-color; - border-radius: $system-note-icon-size; - margin: -6px $gl-padding 0 0; - - svg { - width: $system-note-svg-size; - height: $system-note-svg-size; - fill: $gray-darkest; - display: block; - margin: 0 auto; - } - } - .timeline-content { - @include notes-media('min', map-get($grid-breakpoints, sm)) { + @include notes-media("min", map-get($grid-breakpoints, sm)) { margin-left: 30px; } } @@ -368,7 +348,7 @@ $note-form-margin-left: 72px; } &::after { - content: ''; + content: ""; height: 70px; position: absolute; left: $gl-padding-24; @@ -380,6 +360,37 @@ $note-form-margin-left: 72px; } } } + + .system-note, + .discussion-filter-note { + .timeline-icon { + float: left; + display: flex; + align-items: center; + background-color: $white-light; + width: $system-note-icon-size; + height: $system-note-icon-size; + border: 1px solid $border-color; + border-radius: $system-note-icon-size; + margin: -6px $gl-padding 0 0; + + svg { + width: $system-note-svg-size; + height: $system-note-svg-size; + fill: $gray-darkest; + display: block; + margin: 0 auto; + } + } + } + + .discussion-filter-note { + .timeline-icon { + width: $system-note-icon-size + 6; + height: $system-note-icon-size + 6; + margin-top: -8px; + } + } } // Diff code in discussion view @@ -494,11 +505,6 @@ $note-form-margin-left: 72px; .discussion-notes { margin-left: 0; border-left: 0; - - .notes { - position: relative; - @include vertical-line(52px); - } } .note-wrapper { @@ -550,6 +556,11 @@ $note-form-margin-left: 72px; .note-header-info { padding-bottom: 0; } + + .timeline-content { + overflow-x: auto; + overflow-y: hidden; + } } .unresolved { @@ -579,7 +590,7 @@ $note-form-margin-left: 72px; .note-headline-light { display: inline; - @include notes-media('max', map-get($grid-breakpoints, xs)) { + @include notes-media("max", map-get($grid-breakpoints, xs)) { display: block; } } @@ -597,7 +608,6 @@ $note-form-margin-left: 72px; .note-headline-meta { display: inline-block; - white-space: nowrap; .system-note-message { white-space: normal; @@ -607,6 +617,10 @@ $note-form-margin-left: 72px; color: $gl-text-color-disabled; } + .note-timestamp { + white-space: nowrap; + } + a:hover { text-decoration: underline; } @@ -642,7 +656,7 @@ $note-form-margin-left: 72px; margin-left: 10px; color: $gray-darkest; - @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { + @include notes-media("max", map-get($grid-breakpoints, sm) - 1) { float: none; margin-left: 0; } @@ -761,7 +775,7 @@ $note-form-margin-left: 72px; } .line-resolve-all-container { - @include notes-media('min', map-get($grid-breakpoints, sm)) { + @include notes-media("min", map-get($grid-breakpoints, sm)) { margin-right: 0; } @@ -902,7 +916,6 @@ $note-form-margin-left: 72px; } .discussion-filter-container { - .btn > svg { width: $gl-col-padding; height: $gl-col-padding; @@ -924,7 +937,6 @@ $note-form-margin-left: 72px; //This needs to be deleted when Snippet/Commit comments are convered to Vue // See https://gitlab.com/gitlab-org/gitlab-ce/issues/53918#note_117038785 .unstyled-comments { - .discussion-header { padding: $gl-padding; border-bottom: 1px solid $border-color; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index a28921592ec..e676d48c1f4 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -861,7 +861,7 @@ button.mini-pipeline-graph-dropdown-toggle { height: $ci-action-dropdown-svg-size; fill: $gl-text-color-secondary; position: relative; - top: 0; + top: 1px; vertical-align: initial; } } @@ -869,7 +869,7 @@ button.mini-pipeline-graph-dropdown-toggle { // SVGs in the commit widget and mr widget a.ci-action-icon-container.ci-action-icon-wrapper svg { - top: 2px; + top: 4px; } .scrollable-menu { diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 505f6e036e3..277030ad3af 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -140,73 +140,19 @@ } } -.project-home-panel, -.group-home-panel { - padding-top: 24px; - padding-bottom: 24px; - - .group-avatar { - float: none; - margin: 0 auto; - - &.identicon { - border-radius: 50%; - } - } - - .group-title { - margin-top: 10px; - margin-bottom: 10px; - font-size: 24px; - font-weight: $gl-font-weight-normal; - line-height: 1; - word-wrap: break-word; - - .fa { - margin-left: 2px; - font-size: 12px; - vertical-align: middle; - } - } - - .group-home-desc { - margin-left: auto; - margin-right: auto; - margin-bottom: 0; - max-width: 700px; - - > p { - margin-bottom: 0; - } - } - - .notifications-btn { - .fa-bell, - .fa-spinner { - margin-right: 6px; - } - - .fa-angle-down { - margin-left: 6px; - } - } -} - +.group-home-panel, .project-home-panel { padding-top: $gl-padding; padding-bottom: $gl-padding; - .project-avatar { - width: $project-title-row-height; - height: $project-title-row-height; + .home-panel-avatar { + width: $home-panel-title-row-height; + height: $home-panel-title-row-height; flex-shrink: 0; - flex-basis: $project-title-row-height; - margin: 0 $gl-padding 0 0; + flex-basis: $home-panel-title-row-height; } - .project-title { - margin-top: 8px; - margin-bottom: 5px; + .home-panel-title { font-size: 20px; line-height: $gl-line-height-24; font-weight: bold; @@ -215,11 +161,7 @@ font-size: $gl-font-size-large; } - .project-visibility { - color: $gl-text-color-secondary; - } - - .project-topic-list { + .home-panel-topic-list { font-size: $gl-font-size; font-weight: $gl-font-weight-normal; @@ -231,12 +173,12 @@ } } - .project-title-row { + .home-panel-title-row { @include media-breakpoint-down(sm) { - .project-avatar { - width: $project-avatar-mobile-size; - height: $project-avatar-mobile-size; - flex-basis: $project-avatar-mobile-size; + .home-panel-avatar { + width: $home-panel-avatar-mobile-size; + height: $home-panel-avatar-mobile-size; + flex-basis: $home-panel-avatar-mobile-size; .avatar { font-size: 20px; @@ -244,28 +186,26 @@ } } - .project-title { + .home-panel-title { margin-top: 4px; margin-bottom: 2px; font-size: $gl-font-size; line-height: $gl-font-size-large; } - .project-topic-list, - .project-metadata { + .home-panel-topic-list, + .home-panel-metadata { font-size: $gl-font-size-small; } } } - .project-metadata { + .home-panel-metadata { font-weight: normal; font-size: 14px; line-height: $gl-btn-line-height; - color: $gl-text-color-secondary; - - .project-license { + .home-panel-license { .btn { line-height: 0; border-width: 0; @@ -273,13 +213,13 @@ } .access-request-link, - .project-topic-list { + .home-panel-topic-list { padding-left: $gl-padding-8; border-left: 1px solid $gl-text-color-secondary; } } - .project-description { + .home-panel-description { @include media-breakpoint-up(md) { font-size: $gl-font-size-large; } @@ -292,12 +232,11 @@ } } -.nav > .project-repo-buttons { +.nav > .project-buttons { margin-top: 0; } -.project-repo-buttons, -.group-buttons { +.project-repo-buttons { .btn { &:last-child { margin-left: 0; @@ -318,8 +257,30 @@ margin-left: 0; } } + + .notifications-icon { + top: 1px; + margin-right: 0; + } + } + + .icon { + top: 0; + } + + .count-badge, + .btn-xs { + height: 24px; } + .dropdown-toggle, + .clone-dropdown-btn { + .fa { + color: unset; + } + } + + .home-panel-action-button, .project-action-button { margin: $gl-padding $gl-padding-8 0 0; vertical-align: top; @@ -385,31 +346,6 @@ } } -.project-repo-buttons { - .icon { - top: 0; - } - - .count-badge, - .btn-xs { - height: 24px; - } - - .dropdown-toggle, - .clone-dropdown-btn { - .fa { - color: unset; - } - } - - .btn { - .notifications-icon { - top: 1px; - margin-right: 0; - } - } -} - .split-one { display: inline-table; margin-right: 12px; @@ -571,12 +507,6 @@ } .template-option { - .logo { - .btn-template-icon { - width: 40px !important; - } - } - padding: 16px 0; &:not(:first-child) { @@ -615,9 +545,8 @@ } .selected-icon { - svg { + img { display: none; - top: 7px; height: 20px; width: 20px; } @@ -772,14 +701,11 @@ .project-stats, .project-buttons { - font-size: 0; - text-align: center; - .scrolling-tabs-container { .scrolling-tabs { margin-top: $gl-padding-8; - margin-bottom: $gl-padding-8 - $browserScrollbarSize; - padding-bottom: $browserScrollbarSize; + margin-bottom: $gl-padding-8 - $browser-scrollbar-size; + padding-bottom: $browser-scrollbar-size; flex-wrap: wrap; border-bottom: 0; } @@ -787,7 +713,7 @@ .fade-left, .fade-right { top: 0; - height: calc(100% - #{$browserScrollbarSize}); + height: calc(100% - #{$browser-scrollbar-size}); .fa { top: 50%; @@ -1013,6 +939,11 @@ pre.light-well { .flex-wrapper { min-width: 0; margin-top: -$gl-padding-8; // negative margin required for flex-wrap + flex: 1 1 100%; + + .project-title { + line-height: 20px; + } } p, @@ -1051,14 +982,16 @@ pre.light-well { } .controls { - margin-top: $gl-padding-8; + @include media-breakpoint-down(xs) { + margin-top: $gl-padding-8; + } - @include media-breakpoint-down(md) { + @include media-breakpoint-up(sm) { margin-top: 0; } - @include media-breakpoint-down(xs) { - margin-top: $gl-padding-8; + @include media-breakpoint-up(lg) { + flex: 1 1 40%; } .icon-wrapper { @@ -1108,7 +1041,7 @@ pre.light-well { min-height: 40px; min-width: 40px; - .identicon.s64 { + .identicon.s48 { font-size: 16px; } } diff --git a/app/assets/stylesheets/pages/serverless.scss b/app/assets/stylesheets/pages/serverless.scss new file mode 100644 index 00000000000..a5b73492380 --- /dev/null +++ b/app/assets/stylesheets/pages/serverless.scss @@ -0,0 +1,3 @@ +.url-text-field { + cursor: text; +} diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index c5b9d1f6885..811cc310a8f 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -167,12 +167,14 @@ font-weight: $gl-font-weight-normal; display: inline-block; color: $gl-text-color; + vertical-align: top; } .option-description, .option-disabled-reason { margin-left: 30px; color: $project-option-descr-color; + margin-top: -5px; } .option-disabled-reason { diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb index e3226c86b0b..2b9cae21da2 100644 --- a/app/controllers/admin/appearances_controller.rb +++ b/app/controllers/admin/appearances_controller.rb @@ -74,6 +74,10 @@ class Admin::AppearancesController < Admin::ApplicationController favicon_cache new_project_guidelines updated_by + header_message + footer_message + message_background_color + message_font_color ] end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index e93be1c1ba2..bfa7c7d0109 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true class Admin::UsersController < Admin::ApplicationController + include RoutableActions + before_action :user, except: [:index, :new, :create] before_action :check_impersonation_availability, only: :impersonate def index - @users = User.order_name_asc.filter(params[:filter]) + @users = User.filter_items(params[:filter]).order_name_asc @users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present? @users = @users.sort_by_attribute(@sort = params[:sort]) @users = @users.page(params[:page]) @@ -177,11 +179,13 @@ class Admin::UsersController < Admin::ApplicationController user == current_user end - # rubocop: disable CodeReuse/ActiveRecord def user - @user ||= User.find_by!(username: params[:id]) + @user ||= find_routable!(User, params[:id]) + end + + def build_canonical_path(user) + url_for(safe_params.merge(id: user.to_param)) end - # rubocop: enable CodeReuse/ActiveRecord def redirect_back_or_admin_user(options = {}) redirect_back_or_default(default: default_route, options: options) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 26cd5dc801f..b7eb6af6d67 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -43,7 +43,10 @@ class ApplicationController < ActionController::Base :git_import_enabled?, :gitlab_project_import_enabled?, :manifest_import_enabled? + # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security + # concerns due to caching private data. DEFAULT_GITLAB_CACHE_CONTROL = "#{ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL}, no-store".freeze + DEFAULT_GITLAB_CONTROL_NO_CACHE = "#{DEFAULT_GITLAB_CACHE_CONTROL}, no-cache".freeze rescue_from Encoding::CompatibilityError do |exception| log_exception(exception) @@ -137,6 +140,8 @@ class ApplicationController < ActionController::Base if response.status == 422 && response.body.present? && response.content_type == 'application/json'.freeze payload[:response] = response.body end + + payload[:queue_duration] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] end ## @@ -233,9 +238,9 @@ class ApplicationController < ActionController::Base end def no_cache_headers - response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT" + headers['Cache-Control'] = DEFAULT_GITLAB_CONTROL_NO_CACHE + headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility + headers['Expires'] = 'Fri, 01 Jan 1990 00:00:00 GMT' end def default_headers @@ -245,10 +250,16 @@ class ApplicationController < ActionController::Base headers['X-Content-Type-Options'] = 'nosniff' if current_user - # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security - # concerns due to caching private data. - headers['Cache-Control'] = DEFAULT_GITLAB_CACHE_CONTROL - headers["Pragma"] = "no-cache" # HTTP 1.0 compatibility + headers['Cache-Control'] = default_cache_control + headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility + end + end + + def default_cache_control + if request.xhr? + ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL + else + DEFAULT_GITLAB_CACHE_CONTROL end end diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index b9717b97640..68a2a83f0de 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -24,7 +24,7 @@ class Clusters::ClustersController < Clusters::BaseController # Note: We are paginating through an array here but this should OK as: # # In CE, we can have a maximum group nesting depth of 21, so including - # project cluster, we can have max 22 clusters for a group hierachy. + # project cluster, we can have max 22 clusters for a group hierarchy. # In EE (Premium) we can have any number, as multiple clusters are # supported, but the number of clusters are fairly low currently. # @@ -127,6 +127,7 @@ class Clusters::ClustersController < Clusters::BaseController params.require(:cluster).permit( :enabled, :environment_scope, + :base_domain, platform_kubernetes_attributes: [ :namespace ] @@ -136,6 +137,7 @@ class Clusters::ClustersController < Clusters::BaseController :enabled, :name, :environment_scope, + :base_domain, platform_kubernetes_attributes: [ :api_url, :token, diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 3d64ae8b775..cd3fa641e89 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -7,6 +7,9 @@ module IssuableActions included do before_action :authorize_destroy_issuable!, only: :destroy before_action :authorize_admin_issuable!, only: :bulk_update + before_action only: :show do + push_frontend_feature_flag(:reply_to_individual_notes) + end end def permitted_keys @@ -58,7 +61,8 @@ module IssuableActions title_text: issuable.title, description: view_context.markdown_field(issuable, :description), description_text: issuable.description, - task_status: issuable.task_status + task_status: issuable.task_status, + lock_version: issuable.lock_version } if issuable.edited? diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 789e0dc736e..c529aabf797 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -91,6 +91,7 @@ module IssuableCollections options = { scope: params[:scope], state: params[:state], + confidential: Gitlab::Utils.to_boolean(params[:confidential]), sort: set_sort_order } @@ -129,13 +130,13 @@ module IssuableCollections return sort_param if Gitlab::Database.read_only? if user_preference[issuable_sorting_field] != sort_param - user_preference.update_attribute(issuable_sorting_field, sort_param) + user_preference.update(issuable_sorting_field => sort_param) end sort_param end - # Implement default_sorting_field method on controllers + # Implement issuable_sorting_field method on controllers # to choose which column to store the sorting parameter. def issuable_sorting_field nil diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issuable_collections_action.rb index a75590457d6..18ed4027eac 100644 --- a/app/controllers/concerns/issues_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module IssuesAction +module IssuableCollectionsAction extend ActiveSupport::Concern include IssuableCollections include IssuesCalendar @@ -18,6 +18,12 @@ module IssuesAction format.atom { render layout: 'xml.atom' } end end + + def merge_requests + @merge_requests = issuables_collection.page(params[:page]) + + @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type) + end # rubocop:enable Gitlab/ModuleWithInstanceVariables def issues_calendar @@ -26,8 +32,29 @@ module IssuesAction private + def issuable_sorting_field + case action_name + when 'issues' + Issue::SORTING_PREFERENCE_FIELD + when 'merge_requests' + MergeRequest::SORTING_PREFERENCE_FIELD + else + nil + end + end + def finder_type - (super if defined?(super)) || - (IssuesFinder if %w(issues issues_calendar).include?(action_name)) + case action_name + when 'issues', 'issues_calendar' + IssuesFinder + when 'merge_requests' + MergeRequestsFinder + else + nil + end + end + + def finder_options + super.merge(non_archived: true) end end diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 5572c3cee2d..57e444319e0 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -123,7 +123,7 @@ module LfsRequest (authentication_abilities || []).include?(capability) end - # Overriden in EE + # Overridden in EE def limit_exceeded? false end diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb deleted file mode 100644 index ed10f32512e..00000000000 --- a/app/controllers/concerns/merge_requests_action.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module MergeRequestsAction - extend ActiveSupport::Concern - include IssuableCollections - - # rubocop:disable Gitlab/ModuleWithInstanceVariables - def merge_requests - @merge_requests = issuables_collection.page(params[:page]) - - @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type) - end - # rubocop:enable Gitlab/ModuleWithInstanceVariables - - private - - def finder_type - (super if defined?(super)) || - (MergeRequestsFinder if action_name == 'merge_requests') - end - - def finder_options - super.merge(non_archived: true) - end -end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index eccbe35577b..c0c0160a827 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -8,7 +8,7 @@ module MilestoneActions format.html { redirect_to milestone_redirect_path } format.json do render json: tabs_json("shared/milestones/_merge_requests_tab", { - merge_requests: @milestone.sorted_merge_requests, # rubocop:disable Gitlab/ModuleWithInstanceVariables + merge_requests: @milestone.sorted_merge_requests(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables show_project_name: true }) end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 0319948a12f..b4fee93713b 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -6,7 +6,6 @@ module NotesActions extend ActiveSupport::Concern included do - prepend_before_action :normalize_create_params, only: [:create] before_action :set_polling_interval_header, only: [:index] before_action :require_noteable!, only: [:index, :create] before_action :authorize_admin_note!, only: [:update, :destroy] @@ -44,12 +43,7 @@ module NotesActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def create - create_params = note_params.merge( - merge_request_diff_head_sha: params[:merge_request_diff_head_sha], - in_reply_to_discussion_id: params[:in_reply_to_discussion_id] - ) - - @note = Notes::CreateService.new(note_project, current_user, create_params).execute + @note = Notes::CreateService.new(note_project, current_user, create_note_params).execute respond_to do |format| format.json do @@ -78,7 +72,7 @@ module NotesActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def update - @note = Notes::UpdateService.new(project, current_user, note_params).execute(note) + @note = Notes::UpdateService.new(project, current_user, update_note_params).execute(note) prepare_notes_for_rendering([@note]) respond_to do |format| @@ -196,24 +190,36 @@ module NotesActions return access_denied! unless can?(current_user, :admin_note, note) end - def note_params + def create_note_params params.require(:note).permit( - :project_id, - :noteable_type, - :noteable_id, - :commit_id, - :noteable, :type, - :note, - :attachment, + :line_code, # LegacyDiffNote + :position # DiffNote + ).tap do |create_params| + create_params.merge!( + params.permit(:merge_request_diff_head_sha, :in_reply_to_discussion_id) + ) - # LegacyDiffNote - :line_code, + # These params are also sent by the client but we need to set these based on + # target_type and target_id because we're checking permissions based on that + create_params[:noteable_type] = params[:target_type].classify + + case params[:target_type] + when 'commit' + create_params[:commit_id] = params[:target_id] + when 'merge_request' + create_params[:noteable_id] = params[:target_id] + # Notes on MergeRequest can have an extra `commit_id` context + create_params[:commit_id] = params.dig(:note, :commit_id) + else + create_params[:noteable_id] = params[:target_id] + end + end + end - # DiffNote - :position - ) + def update_note_params + params.require(:note).permit(:note) end def set_polling_interval_header @@ -248,15 +254,6 @@ module NotesActions DiscussionSerializer.new(project: project, noteable: noteable, current_user: current_user, note_entity: ProjectNoteEntity) end - # Avoids checking permissions in the wrong object - this ensures that the object we checked permissions for - # is the object we're actually creating a note in. - def normalize_create_params - params[:note].try do |note| - note[:noteable_id] = params[:target_id] - note[:noteable_type] = params[:target_type].classify - end - end - def note_project strong_memoize(:note_project) do next nil unless project diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 4b0f0b8255c..f72d25fc54c 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -16,8 +16,6 @@ module PreviewMarkdown else {} end - markdown_params[:markdown_engine] = result[:markdown_engine] - render json: { body: view_context.markdown(result[:text], markdown_params), references: { diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb new file mode 100644 index 00000000000..372c803278d --- /dev/null +++ b/app/controllers/concerns/record_user_last_activity.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# == RecordUserLastActivity +# +# Controller concern that updates the `last_activity_on` field of `users` +# for any authenticated GET request. The DB update will only happen once per day. +# +# In order to determine if you should include this concern or not, please check the +# description and discussion on this issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/54947 +module RecordUserLastActivity + include CookiesHelper + extend ActiveSupport::Concern + + included do + before_action :set_user_last_activity + end + + def set_user_last_activity + return unless request.get? + return unless Feature.enabled?(:set_user_last_activity, default_enabled: true) + return if Gitlab::Database.read_only? + + if current_user && current_user.last_activity_on != Date.today + Users::ActivityService.new(current_user, "visited #{request.path}").execute + end + end +end diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb index 515a9eede8e..28e4cece548 100644 --- a/app/controllers/concerns/send_file_upload.rb +++ b/app/controllers/concerns/send_file_upload.rb @@ -3,16 +3,19 @@ module SendFileUpload def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, proxy: false, disposition: 'attachment') if attachment + response_disposition = ::Gitlab::ContentDisposition.format(disposition: disposition, filename: attachment) + # Response-Content-Type will not override an existing Content-Type in # Google Cloud Storage, so the metadata needs to be cleared on GCS for # this to work. However, this override works with AWS. - redirect_params[:query] = { "response-content-disposition" => "#{disposition};filename=#{attachment.inspect}", + redirect_params[:query] = { "response-content-disposition" => response_disposition, "response-content-type" => guess_content_type(attachment) } # By default, Rails will send uploads with an extension of .js with a # content-type of text/javascript, which will trigger Rails' # cross-origin JavaScript protection. send_params[:content_type] = 'text/plain' if File.extname(attachment) == '.js' - send_params.merge!(filename: attachment, disposition: disposition) + + send_params.merge!(filename: attachment, disposition: utf8_encoded_disposition(disposition, attachment)) end if file_upload.file_storage? @@ -25,6 +28,18 @@ module SendFileUpload end end + # Since Rails 5 doesn't properly support support non-ASCII filenames, + # we have to add our own to ensure RFC 5987 compliance. However, Rails + # 5 automatically appends `filename#{filename}` here: + # https://github.com/rails/rails/blob/v5.0.7/actionpack/lib/action_controller/metal/data_streaming.rb#L137 + # Rails 6 will have https://github.com/rails/rails/pull/33829, so we + # can get rid of this special case handling when we upgrade. + def utf8_encoded_disposition(disposition, filename) + content = ::Gitlab::ContentDisposition.new(disposition: disposition, filename: filename) + + "#{disposition}; #{content.utf8_filename}" + end + def guess_content_type(filename) types = MIME::Types.type_for(filename) diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index c6ae4fe15bf..48451bedcc2 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -72,7 +72,7 @@ module ServiceParams dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables service_params = params.permit(:id, service: allowed_service_params + dynamic_params) - if service_params[:service].is_a?(Hash) + if service_params[:service].is_a?(ActionController::Parameters) FILTER_BLANK_PARAMS.each do |param| service_params[:service].delete(param) if service_params[:service][param].blank? end diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb index cee0753a021..0e9fdc60363 100644 --- a/app/controllers/dashboard/application_controller.rb +++ b/app/controllers/dashboard/application_controller.rb @@ -2,6 +2,7 @@ class Dashboard::ApplicationController < ApplicationController include ControllerWithCrossProjectAccessCheck + include RecordUserLastActivity layout 'dashboard' diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb index 3802aa5f40f..912036da0ea 100644 --- a/app/controllers/dashboard/milestones_controller.rb +++ b/app/controllers/dashboard/milestones_controller.rb @@ -25,9 +25,7 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController private def group_milestones - groups = GroupsFinder.new(current_user, all_available: false).execute - - DashboardGroupMilestone.build_collection(groups) + DashboardGroupMilestone.build_collection(groups, params) end # See [#39545](https://gitlab.com/gitlab-org/gitlab-ce/issues/39545) for info about the deprecation of dynamic milestones @@ -45,6 +43,6 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController end def groups - @groups ||= GroupsFinder.new(current_user, state_all: true).execute + @groups ||= GroupsFinder.new(current_user, all_available: false).execute end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index be2d9512c01..75329b05a6f 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true class DashboardController < Dashboard::ApplicationController - include IssuesAction - include MergeRequestsAction + include IssuableCollectionsAction prepend_before_action(only: [:issues]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) } diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 9f074690cbc..f3d76c5a478 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -15,7 +15,7 @@ class Explore::ProjectsController < Explore::ApplicationController format.html format.json do render json: { - html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects }) } end end @@ -30,7 +30,7 @@ class Explore::ProjectsController < Explore::ApplicationController format.html format.json do render json: { - html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects }) } end end @@ -44,7 +44,7 @@ class Explore::ProjectsController < Explore::ApplicationController format.html format.json do render json: { - html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) + html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects }) } end end diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb index dd9f5af61b3..ed0995e7ffd 100644 --- a/app/controllers/google_api/authorizations_controller.rb +++ b/app/controllers/google_api/authorizations_controller.rb @@ -2,6 +2,10 @@ module GoogleApi class AuthorizationsController < ApplicationController + include Gitlab::Utils::StrongMemoize + + before_action :validate_session_key! + def callback token, expires_at = GoogleApi::CloudPlatform::Client .new(nil, callback_google_api_auth_url) @@ -11,21 +15,27 @@ module GoogleApi session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = expires_at.to_s - state_redirect_uri = redirect_uri_from_session_key(params[:state]) - - if state_redirect_uri - redirect_to state_redirect_uri - else - redirect_to root_path - end + redirect_to redirect_uri_from_session end private - def redirect_uri_from_session_key(state) - key = GoogleApi::CloudPlatform::Client - .session_key_for_redirect_uri(params[:state]) - session[key] if key + def validate_session_key! + access_denied! unless redirect_uri_from_session.present? + end + + def redirect_uri_from_session + strong_memoize(:redirect_uri_from_session) do + if params[:state].present? + session[session_key_for_redirect_uri(params[:state])] + else + nil + end + end + end + + def session_key_for_redirect_uri(state) + GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(state) end end end diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index cdc6f53df8e..51fdb6c05fb 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -2,6 +2,7 @@ class Groups::BoardsController < Groups::ApplicationController include BoardsResponses + include RecordUserLastActivity before_action :assign_endpoint_vars before_action :boards, only: :index diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 868deea3f01..7ed4384089b 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -115,6 +115,6 @@ class Groups::MilestonesController < Groups::ApplicationController end def search_params - params.permit(:state).merge(group_ids: group.id) + params.permit(:state, :search_title).merge(group_ids: group.id) end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index c5d8ac2ed77..4e50106398a 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -2,10 +2,10 @@ class GroupsController < Groups::ApplicationController include API::Helpers::RelatedResourcesHelpers - include IssuesAction - include MergeRequestsAction + include IssuableCollectionsAction include ParamsBackwardCompatibility include PreviewMarkdown + include RecordUserLastActivity respond_to :html diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index e5a1fc9d6ff..a9d6addd4a4 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -13,9 +13,10 @@ class HelpController < ApplicationController # Remove YAML frontmatter so that it doesn't look weird @help_index = File.read(Rails.root.join('doc', 'README.md')).sub(YAML_FRONT_MATTER_REGEXP, '') - # Prefix Markdown links with `help/` unless they are external links - # See http://rubular.com/r/X3baHTbPO2 - @help_index.gsub!(%r{(?<delim>\]\()(?!.+://)(?!/)(?<link>[^\)\(]+\))}) do + # Prefix Markdown links with `help/` unless they are external links. + # '//' not necessarily part of URL, e.g., mailto:mail@example.com + # See https://rubular.com/r/DFHZl5w8d3bpzV + @help_index.gsub!(%r{(?<delim>\]\()(?!\w+:)(?!/)(?<link>[^\)\(]+\))}) do "#{$~[:delim]}#{Gitlab.config.gitlab.relative_url_root}/help/#{$~[:link]}" end end diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb index 87338488eba..f333e43b892 100644 --- a/app/controllers/import/bitbucket_server_controller.rb +++ b/app/controllers/import/bitbucket_server_controller.rb @@ -13,7 +13,10 @@ class Import::BitbucketServerController < Import::BaseController # Repository names are limited to 128 characters. They must start with a # letter or number and may contain spaces, hyphens, underscores, and periods. # (https://community.atlassian.com/t5/Answers-Developer-Questions/stash-repository-names/qaq-p/499054) - VALID_BITBUCKET_CHARS = /\A[\w\-_\.\s]+\z/ + # + # Bitbucket Server starts personal project names with a tilde. + VALID_BITBUCKET_PROJECT_CHARS = /\A~?[\w\-\.\s]+\z/ + VALID_BITBUCKET_CHARS = /\A[\w\-\.\s]+\z/ def new end @@ -91,7 +94,7 @@ class Import::BitbucketServerController < Import::BaseController return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present? return render_validation_error('Missing repository slug') unless @repo_slug.present? - return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_CHARS + return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_PROJECT_CHARS return render_validation_error('Invalid repository slug') unless @repo_slug =~ VALID_BITBUCKET_CHARS end diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index f067ef625aa..68ad8650dba 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class Import::GiteaController < Import::GithubController + extend ::Gitlab::Utils::Override + def new - if session[access_token_key].present? && session[host_key].present? + if session[access_token_key].present? && provider_url.present? redirect_to status_import_url end end @@ -12,8 +14,8 @@ class Import::GiteaController < Import::GithubController super end + # Must be defined or it will 404 def status - @gitea_host_url = session[host_key] super end @@ -23,25 +25,33 @@ class Import::GiteaController < Import::GithubController :"#{provider}_host_url" end - # Overridden methods + override :provider def provider :gitea end + override :provider_url + def provider_url + session[host_key] + end + # Gitea is not yet an OAuth provider # See https://github.com/go-gitea/gitea/issues/27 + override :logged_in_with_provider? def logged_in_with_provider? false end + override :provider_auth def provider_auth - if session[access_token_key].blank? || session[host_key].blank? + if session[access_token_key].blank? || provider_url.blank? redirect_to new_import_gitea_url, alert: 'You need to specify both an Access Token and a Host URL.' end end + override :client_options def client_options - { host: session[host_key], api_version: 'v1' } + { host: provider_url, api_version: 'v1' } end end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 3fbc0817e95..aa4aa0fbdac 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true class Import::GithubController < Import::BaseController + include ImportHelper + before_action :verify_import_enabled - before_action :provider_auth, only: [:status, :jobs, :create] + before_action :provider_auth, only: [:status, :realtime_changes, :create] + before_action :expire_etag_cache, only: [:status, :create] rescue_from Octokit::Unauthorized, with: :provider_unauthorized @@ -24,30 +27,37 @@ class Import::GithubController < Import::BaseController redirect_to status_import_url end - # rubocop: disable CodeReuse/ActiveRecord def status - @repos = client.repos - @already_added_projects = find_already_added_projects(provider) - already_added_projects_names = @already_added_projects.pluck(:import_source) - - @repos.reject! { |repo| already_added_projects_names.include? repo.full_name } - end - # rubocop: enable CodeReuse/ActiveRecord - - def jobs - render json: find_jobs(provider) + # Request repos to display error page if provider token is invalid + # Improving in https://gitlab.com/gitlab-org/gitlab-ce/issues/55585 + client_repos + + respond_to do |format| + format.json do + render json: { imported_projects: serialized_imported_projects, + provider_repos: serialized_provider_repos, + namespaces: serialized_namespaces } + end + format.html + end end def create result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider) if result[:status] == :success - render json: ProjectSerializer.new.represent(result[:project]) + render json: serialized_imported_projects(result[:project]) else render json: { errors: result[:message] }, status: result[:http_status] end end + def realtime_changes + Gitlab::PollingInterval.set_header(response, interval: 3_000) + + render json: find_jobs(provider) + end + private def import_params @@ -58,10 +68,45 @@ class Import::GithubController < Import::BaseController [:repo_id, :new_name, :target_namespace] end + def serialized_imported_projects(projects = already_added_projects) + ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url) + end + + def serialized_provider_repos + repos = client_repos.reject { |repo| already_added_project_names.include? repo.full_name } + ProviderRepoSerializer.new(current_user: current_user).represent(repos, provider: provider, provider_url: provider_url) + end + + def serialized_namespaces + NamespaceSerializer.new.represent(namespaces) + end + + def already_added_projects + @already_added_projects ||= find_already_added_projects(provider) + end + + def already_added_project_names + @already_added_projects_names ||= already_added_projects.pluck(:import_source) # rubocop:disable CodeReuse/ActiveRecord + end + + def namespaces + current_user.manageable_groups_with_routes + end + + def expire_etag_cache + Gitlab::EtagCaching::Store.new.tap do |store| + store.touch(realtime_changes_path) + end + end + def client @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options) end + def client_repos + @client_repos ||= client.repos + end + def verify_import_enabled render_404 unless import_enabled? end @@ -74,6 +119,10 @@ class Import::GithubController < Import::BaseController __send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend end + def realtime_changes_path + public_send("realtime_changes_import_#{provider}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend + end + def new_import_url public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end @@ -105,6 +154,14 @@ class Import::GithubController < Import::BaseController :github end + def provider_url + strong_memoize(:provider_url) do + provider = Gitlab::Auth::OAuth::Provider.config_for('github') + + provider&.dig('url').presence || 'https://github.com' + end + end + # rubocop: disable CodeReuse/ActiveRecord def logged_in_with_provider? current_user.identities.exists?(provider: provider) diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb index 384f308269a..43c4f4d220e 100644 --- a/app/controllers/notification_settings_controller.rb +++ b/app/controllers/notification_settings_controller.rb @@ -17,7 +17,8 @@ class NotificationSettingsController < ApplicationController @saved = @notification_setting.update(notification_setting_params_for(@notification_setting.source)) if params[:hide_label].present? - render_response("projects/buttons/_notifications") + btn_class = params[:project_id].present? ? 'btn-xs' : '' + render_response("shared/notifications/_new_button", btn_class) else render_response end @@ -41,9 +42,9 @@ class NotificationSettingsController < ApplicationController can?(current_user, ability_name, resource) end - def render_response(response_template = "shared/notifications/_button") + def render_response(response_template = "shared/notifications/_button", btn_class = nil) render json: { - html: view_to_html_string(response_template, notification_setting: @notification_setting), + html: view_to_html_string(response_template, notification_setting: @notification_setting, btn_class: btn_class), saved: @saved } end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index f8e482937d5..cc2bb99f55b 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -4,7 +4,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include AuthenticatesWithTwoFactor include Devise::Controllers::Rememberable - protect_from_forgery except: [:kerberos, :saml, :cas3], prepend: true + protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true def handle_omniauth omniauth_flow(Gitlab::Auth::OAuth) @@ -116,8 +116,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController session[:service_tickets][provider] = ticket end + def build_auth_user(auth_user_class) + auth_user_class.new(oauth) + end + def sign_in_user_flow(auth_user_class) - auth_user = auth_user_class.new(oauth) + auth_user = build_auth_user(auth_user_class) user = auth_user.find_and_update! if auth_user.valid_sign_in? diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 2912a22411e..28f113b5cbe 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -5,7 +5,7 @@ class PasswordsController < Devise::PasswordsController before_action :resource_from_email, only: [:create] before_action :check_password_authentication_available, only: [:create] - before_action :throttle_reset, only: [:create] + before_action :throttle_reset, only: [:create] # rubocop: disable CodeReuse/ActiveRecord def edit diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb index efe7ede5efa..c473023cacb 100644 --- a/app/controllers/profiles/active_sessions_controller.rb +++ b/app/controllers/profiles/active_sessions_controller.rb @@ -2,15 +2,6 @@ class Profiles::ActiveSessionsController < Profiles::ApplicationController def index - @sessions = ActiveSession.list(current_user) - end - - def destroy - ActiveSession.destroy(current_user, params[:id]) - - respond_to do |format| - format.html { redirect_to profile_active_sessions_url, status: :found } - format.js { head :ok } - end + @sessions = ActiveSession.list(current_user).reject(&:is_impersonated) end end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 37ac11dc6a1..0227af2c266 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -33,12 +33,18 @@ class Profiles::PreferencesController < Profiles::ApplicationController end def preferences_params - params.require(:user).permit( + params.require(:user).permit(preferences_param_names) + end + + def preferences_param_names + [ :color_scheme_id, :layout, :dashboard, :project_view, - :theme_id - ) + :theme_id, + :first_day_of_week, + :preferred_language + ] end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 15248d2d08f..b9c52618d4b 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -104,7 +104,6 @@ class ProfilesController < Profiles::ApplicationController :username, :website_url, :organization, - :preferred_language, :private_profile, :include_private_contributions, status: [:emoji, :message] diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 9c130af8394..0e3f13045ce 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Projects::AutocompleteSourcesController < Projects::ApplicationController + before_action :authorize_read_milestone!, only: :milestones + def members render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target) end diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index a6bfb913900..32b7f3207ef 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -29,7 +29,8 @@ class Projects::BranchesController < Projects::ApplicationController Gitlab::GitalyClient.allow_n_plus_1_calls do @max_commits = @branches.reduce(0) do |memo, branch| diverging_commit_counts = repository.diverging_commit_counts(branch) - [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max + [memo, diverging_commit_counts.values_at(:behind, :ahead, :distance)] + .flatten.compact.max end end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index fbad56cf6b3..939a09d4fd2 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -24,10 +24,10 @@ class Projects::CommitController < Projects::ApplicationController apply_diff_view_cookie! respond_to do |format| - format.html do + format.html do render end - format.diff do + format.diff do send_git_diff(@project.repository, @commit.diff_refs) end format.patch do diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index b62606067c0..028390c7e2a 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -40,7 +40,7 @@ class Projects::DiscussionsController < Projects::ApplicationController def render_json_with_discussions_serializer render json: - DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity) + DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity) .represent(discussion, context: self, render_truncated_diff_lines: true) end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index a63eea0ca0e..e9cd475a199 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -11,10 +11,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index] - before_action do - push_frontend_feature_flag(:area_chart, project) - end - def index @environments = project.environments .with_state(params[:scope] || :available) @@ -25,11 +21,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: 3_000) render json: { - environments: EnvironmentSerializer - .new(project: @project, current_user: @current_user) - .with_pagination(request, response) - .within_folders - .represent(@environments), + environments: serialize_environments(request, response, params[:nested]), available_count: project.environments.available.count, stopped_count: project.environments.stopped.count } @@ -37,6 +29,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + # Returns all environments for a given folder # rubocop: disable CodeReuse/ActiveRecord def folder folder_environments = project.environments.where(environment_type: params[:id]) @@ -48,10 +41,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.html format.json do render json: { - environments: EnvironmentSerializer - .new(project: @project, current_user: @current_user) - .with_pagination(request, response) - .represent(@environments), + environments: serialize_environments(request, response), available_count: folder_environments.available.count, stopped_count: folder_environments.stopped.count } @@ -163,6 +153,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + def search + respond_to do |format| + format.json do + environment_names = search_environment_names + + render json: environment_names, status: environment_names.any? ? :ok : :no_content + end + end + end + private def verify_api_request! @@ -186,6 +186,20 @@ class Projects::EnvironmentsController < Projects::ApplicationController @environment ||= project.environments.find(params[:id]) end + def search_environment_names + return [] unless params[:query] + + project.environments.for_name_like(params[:query]).pluck_names + end + + def serialize_environments(request, response, nested = false) + EnvironmentSerializer + .new(project: @project, current_user: @current_user) + .tap { |serializer| serializer.within_folders if nested } + .with_pagination(request, response) + .represent(@environments) + end + def authorize_stop_environment! access_denied! unless can?(current_user, :stop_environment, environment) end diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb index 9e403e1d25b..88d0755f41f 100644 --- a/app/controllers/projects/error_tracking_controller.rb +++ b/app/controllers/projects/error_tracking_controller.rb @@ -15,6 +15,14 @@ class Projects::ErrorTrackingController < Projects::ApplicationController end end + def list_projects + respond_to do |format| + format.json do + render_project_list_json + end + end + end + private def render_index_json @@ -32,6 +40,32 @@ class Projects::ErrorTrackingController < Projects::ApplicationController } end + def render_project_list_json + service = ErrorTracking::ListProjectsService.new( + project, + current_user, + list_projects_params + ) + result = service.execute + + if result[:status] == :success + render json: { + projects: serialize_projects(result[:projects]) + } + else + return render( + status: result[:http_status] || :bad_request, + json: { + message: result[:message] + } + ) + end + end + + def list_projects_params + params.require(:error_tracking_setting).permit([:api_host, :token]) + end + def set_polling_interval Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) end @@ -41,4 +75,10 @@ class Projects::ErrorTrackingController < Projects::ApplicationController .new(project: project, user: current_user) .represent(errors) end + + def serialize_projects(projects) + ErrorTracking::ProjectSerializer + .new(project: project, user: current_user) + .represent(projects) + end end diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb index 925b6ed9bfd..c80fce513f6 100644 --- a/app/controllers/projects/graphs_controller.rb +++ b/app/controllers/projects/graphs_controller.rb @@ -45,7 +45,14 @@ class Projects::GraphsController < Projects::ApplicationController end def get_languages - @languages = @project.repository.languages + @languages = + if @project.repository_languages.present? + @project.repository_languages.map do |lang| + { value: lang.share, label: lang.name, color: lang.color, highlight: lang.color } + end + else + @project.repository.languages + end end def fetch_graph diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 7c713c19762..bc942ba9288 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -13,9 +13,10 @@ class Projects::GroupLinksController < Projects::ApplicationController group = Group.find(params[:link_group_id]) if params[:link_group_id].present? if group - return render_404 unless can?(current_user, :read_group, group) + result = Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group) + return render_404 if result[:http_status] == 404 - Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group) + flash[:alert] = result[:message] if result[:http_status] == 409 else flash[:alert] = 'Please select a group.' end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index f45cac86f5a..b9d02a62fc3 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -8,6 +8,7 @@ class Projects::IssuesController < Projects::ApplicationController include IssuableCollections include IssuesCalendar include SpammableActions + include RecordUserLastActivity def self.issue_except_actions %i[index calendar new create bulk_update import_csv] @@ -191,6 +192,10 @@ class Projects::IssuesController < Projects::ApplicationController protected + def issuable_sorting_field + Issue::SORTING_PREFERENCE_FIELD + end + # rubocop: disable CodeReuse/ActiveRecord def issue return @issue if defined?(@issue) @@ -242,7 +247,7 @@ class Projects::IssuesController < Projects::ApplicationController task_num lock_version discussion_locked - ] + [{ label_ids: [], assignee_ids: [] }] + ] + [{ label_ids: [], assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] }] end def store_uri diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 368ee89ff5c..6045ee4e171 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -34,13 +34,17 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont :task_num, :title, :discussion_locked, - label_ids: [] + label_ids: [], + update_task: [:index, :checked, :line_number, :line_source] ] end def set_pipeline_variables - @pipelines = @merge_request.all_pipelines - @pipeline = @merge_request.head_pipeline - @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0 + @pipelines = + if can?(current_user, :read_pipeline, @project) + @merge_request.all_pipelines + else + Ci::Pipeline.none + end end end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 5639402a1e9..32cefe54613 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -89,7 +89,11 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap def build_merge_request params[:merge_request] ||= ActionController::Parameters.new(source_project: @project) - @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute + + # Gitaly N+1 issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/58096 + Gitlab::GitalyClient.allow_n_plus_1_calls do + @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute + end end def define_new_vars diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index ddffbb17ace..518d41bd3fb 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController - include DiffForPath include DiffHelper include RendersNotes diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 162c2636641..46a44841c31 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -7,6 +7,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include RendersCommits include ToggleAwardEmoji include IssuableCollections + include RecordUserLastActivity skip_before_action :merge_request, only: [:index, :bulk_update] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] @@ -15,6 +16,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] + before_action only: [:show] do + push_frontend_feature_flag(:diff_tree_filtering, default_enabled: true) + end + def index @merge_requests = @issuables @@ -55,7 +60,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo render json: serializer.represent(@merge_request, serializer: params[:serializer]) end - format.patch do + format.patch do break render_404 unless @merge_request.diff_refs send_git_patch @project.repository, @merge_request.diff_refs @@ -230,12 +235,16 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo alias_method :issuable, :merge_request alias_method :awardable, :merge_request + def issuable_sorting_field + MergeRequest::SORTING_PREFERENCE_FIELD + end + def merge_params params.permit(merge_params_attributes) end def merge_params_attributes - [:should_remove_source_branch, :commit_message, :squash] + [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash] end def merge_when_pipeline_succeeds_active? diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 8bc59d8a305..f6f61b6e5fb 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -147,6 +147,6 @@ class Projects::MilestonesController < Projects::ApplicationController groups = project_group.self_and_ancestors.select(:id) end - params.permit(:state).merge(project_ids: @project.id, group_ids: groups) + params.permit(:state, :search_title).merge(project_ids: @project.id, group_ids: groups) end end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index c1ad6707c97..73e629ab7c3 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -5,7 +5,8 @@ class Projects::PagesController < Projects::ApplicationController before_action :require_pages_enabled! before_action :authorize_read_pages!, only: [:show] - before_action :authorize_update_pages!, except: [:show] + before_action :authorize_update_pages!, except: [:show, :destroy] + before_action :authorize_remove_pages!, only: [:destroy] # rubocop: disable CodeReuse/ActiveRecord def show @@ -18,7 +19,7 @@ class Projects::PagesController < Projects::ApplicationController project.pages_domains.destroy_all # rubocop: disable DestroyAll respond_to do |format| - format.html do + format.html do redirect_to project_pages_path(@project), status: 302, notice: 'Pages were removed' diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index 439ec9b1731..58b1bc54181 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -4,7 +4,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController layout 'project_settings' before_action :require_pages_enabled! - before_action :authorize_update_pages!, except: [:show] + before_action :authorize_update_pages! before_action :domain, except: [:new, :create] def show diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 67827b1d3bb..6a86f8ca729 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -4,6 +4,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :whitelist_query_limiting, only: [:create, :retry] before_action :pipeline, except: [:index, :new, :create, :charts] before_action :authorize_read_pipeline! + before_action :authorize_read_build!, only: [:index] before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] @@ -69,7 +70,7 @@ class Projects::PipelinesController < Projects::ApplicationController render json: PipelineSerializer .new(project: @project, current_user: @current_user) - .represent(@pipeline, grouped: true) + .represent(@pipeline, show_represent_params) end end end @@ -157,6 +158,10 @@ class Projects::PipelinesController < Projects::ApplicationController end end + def show_represent_params + { grouped: true } + end + def create_params params.require(:pipeline).permit(:ref, variables_attributes: %i[key secret_value]) end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 3fe300dcfc0..edebfc55c17 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -31,20 +31,6 @@ class Projects::TreeController < Projects::ApplicationController lfs_blob_ids @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit end - - format.js do - # Disable cache so browser history works - no_cache_headers - end - - format.json do - page_title @path.presence || _("Files"), @ref, @project.full_name - - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261 - Gitlab::GitalyClient.allow_n_plus_1_calls do - render json: TreeSerializer.new(project: @project, repository: @repository, ref: @ref).represent(@tree) - end - end end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index d3af35723ac..33c6608d321 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -6,6 +6,7 @@ class ProjectsController < Projects::ApplicationController include ExtractsPath include PreviewMarkdown include SendFileUpload + include RecordUserLastActivity prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb index 091bcb1253d..eee14b0faf4 100644 --- a/app/controllers/snippets/notes_controller.rb +++ b/app/controllers/snippets/notes_controller.rb @@ -26,10 +26,6 @@ class Snippets::NotesController < ApplicationController # rubocop: enable CodeReuse/ActiveRecord alias_method :noteable, :snippet - def note_params - super.merge(noteable_id: params[:snippet_id]) - end - def finder_params params.merge(last_fetched_at: last_fetched_at, target_id: snippet.id, target_type: 'personal_snippet') end diff --git a/app/finders/admin/runners_finder.rb b/app/finders/admin/runners_finder.rb index fbb1cfc5c66..8d936b8121c 100644 --- a/app/finders/admin/runners_finder.rb +++ b/app/finders/admin/runners_finder.rb @@ -14,7 +14,7 @@ class Admin::RunnersFinder < UnionFinder sort! paginate! - @runners + @runners.with_tags end def sort_key diff --git a/app/finders/concerns/finder_methods.rb b/app/finders/concerns/finder_methods.rb index 5290313585f..8de3276184d 100644 --- a/app/finders/concerns/finder_methods.rb +++ b/app/finders/concerns/finder_methods.rb @@ -3,13 +3,13 @@ module FinderMethods # rubocop: disable CodeReuse/ActiveRecord def find_by!(*args) - raise_not_found_unless_authorized execute.find_by!(*args) + raise_not_found_unless_authorized execute.reorder(nil).find_by!(*args) end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def find_by(*args) - if_authorized execute.find_by(*args) + if_authorized execute.reorder(nil).find_by(*args) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 96a36db7ec8..ec340f38450 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -134,7 +134,7 @@ class GroupDescendantsFinder def subgroups return Group.none unless Group.supports_nested_objects? - # When filtering subgroups, we want to find all matches withing the tree of + # When filtering subgroups, we want to find all matches within the tree of # descendants to show to the user groups = if params[:filter] subgroups_matching_filter diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 1a69ec85d18..5870f158690 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -18,6 +18,7 @@ # assignee_id: integer or 'None' or 'Any' # assignee_username: string # search: string +# in: 'title', 'description', or a string joining them with comma # label_name: string # sort: string # non_archived: boolean @@ -56,6 +57,7 @@ class IssuableFinder milestone_title my_reaction_emoji search + in ] end @@ -92,6 +94,7 @@ class IssuableFinder items = by_scope(items) items = by_created_at(items) items = by_updated_at(items) + items = by_closed_at(items) items = by_state(items) items = by_group(items) items = by_assignee(items) @@ -303,7 +306,7 @@ class IssuableFinder def use_subquery_for_search? strong_memoize(:use_subquery_for_search) do attempt_group_search_optimizations? && - Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: false) + Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: true) end end @@ -351,6 +354,13 @@ class IssuableFinder items end + def by_closed_at(items) + items = items.closed_after(params[:closed_after]) if params[:closed_after].present? + items = items.closed_before(params[:closed_before]) if params[:closed_before].present? + + items + end + # rubocop: disable CodeReuse/ActiveRecord def by_state(items) case params[:state].to_s @@ -408,7 +418,7 @@ class IssuableFinder items = klass.with(cte.to_arel).from(klass.table_name) end - items.full_search(search) + items.full_search(search, matched_columns: params[:in]) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 45e494725d7..cb44575d6f1 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -14,6 +14,7 @@ # milestone_title: string # assignee_id: integer # search: string +# in: 'title', 'description', or a string joining them with comma # label_name: string # sort: string # my_reaction_emoji: string @@ -68,7 +69,16 @@ class IssuesFinder < IssuableFinder end def filter_items(items) - by_due_date(super) + issues = super + issues = by_due_date(issues) + issues = by_confidential(issues) + issues + end + + def by_confidential(items) + return items if params[:confidential].nil? + + params[:confidential] ? items.confidential_only : items.public_only end def by_due_date(items) diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index bf01f05a9e9..93bee3f1488 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -15,6 +15,7 @@ # author_id: integer # assignee_id: integer # search: string +# in: 'title', 'description', or a string joining them with comma # label_name: string # sort: string # non_archived: boolean diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb index fcd54b6106e..77b55cbb838 100644 --- a/app/finders/milestones_finder.rb +++ b/app/finders/milestones_finder.rb @@ -22,6 +22,7 @@ class MilestonesFinder items = Milestone.all items = by_groups_and_projects(items) items = by_title(items) + items = by_search_title(items) items = by_state(items) order(items) @@ -43,6 +44,14 @@ class MilestonesFinder end # rubocop: enable CodeReuse/ActiveRecord + def by_search_title(items) + if params[:search_title].present? + items.search_title(params[:search_title]) + else + items + end + end + def by_state(items) Milestone.filter_by_state(items, params[:state]) end diff --git a/app/graphql/mutations/merge_requests/base.rb b/app/graphql/mutations/merge_requests/base.rb index 54f01c99d78..7d0cb777ad1 100644 --- a/app/graphql/mutations/merge_requests/base.rb +++ b/app/graphql/mutations/merge_requests/base.rb @@ -25,7 +25,8 @@ module Mutations def find_object(project_path:, iid:) project = resolve_project(full_path: project_path) - resolver = Resolvers::MergeRequestResolver.new(object: project, context: context) + resolver = Resolvers::MergeRequestsResolver + .single.new(object: project, context: context) resolver.resolve(iid: iid) end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 459933af9d3..063def75d38 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -2,5 +2,12 @@ module Resolvers class BaseResolver < GraphQL::Schema::Resolver + def self.single + @single ||= Class.new(self) do + def resolve(**args) + super.first + end + end + end end end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 4ab3c13787a..b98d8bd1fff 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -2,8 +2,37 @@ module Resolvers class IssuesResolver < BaseResolver - extend ActiveSupport::Concern + argument :iid, GraphQL::ID_TYPE, + required: false, + description: 'The IID of the issue, e.g., "1"' + argument :iids, [GraphQL::ID_TYPE], + required: false, + description: 'The list of IIDs of issues, e.g., [1, 2]' + argument :state, Types::IssuableStateEnum, + required: false, + description: "Current state of Issue" + argument :label_name, GraphQL::STRING_TYPE.to_list_type, + required: false, + description: "Labels applied to the Issue" + argument :created_before, Types::TimeType, + required: false, + description: "Issues created before this date" + argument :created_after, Types::TimeType, + required: false, + description: "Issues created after this date" + argument :updated_before, Types::TimeType, + required: false, + description: "Issues updated before this date" + argument :updated_after, Types::TimeType, + required: false, + description: "Issues updated after this date" + argument :closed_before, Types::TimeType, + required: false, + description: "Issues closed before this date" + argument :closed_after, Types::TimeType, + required: false, + description: "Issues closed after this date" argument :search, GraphQL::STRING_TYPE, required: false argument :sort, Types::Sort, @@ -18,6 +47,7 @@ module Resolvers # Will need to be be made group & namespace aware with # https://gitlab.com/gitlab-org/gitlab-ce/issues/54520 args[:project_id] = project.id + args[:iids] ||= [args[:iid]].compact IssuesFinder.new(context[:current_user], args).execute end diff --git a/app/graphql/resolvers/merge_request_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb index d047ce9e3a1..90795c797ac 100644 --- a/app/graphql/resolvers/merge_request_resolver.rb +++ b/app/graphql/resolvers/merge_requests_resolver.rb @@ -1,19 +1,30 @@ # frozen_string_literal: true module Resolvers - class MergeRequestResolver < BaseResolver + class MergeRequestsResolver < BaseResolver argument :iid, GraphQL::ID_TYPE, - required: true, - description: 'The IID of the merge request, e.g., "1"' + required: false, + description: 'The IID of the merge request, e.g., "1"' + + argument :iids, [GraphQL::ID_TYPE], + required: false, + description: 'The list of IIDs of issues, e.g., [1, 2]' type Types::MergeRequestType, null: true alias_method :project, :object - # rubocop: disable CodeReuse/ActiveRecord - def resolve(iid:) + def resolve(**args) return unless project.present? + args[:iids] ||= [args[:iid]].compact + + args[:iids].map { |iid| batch_load(iid) } + .select(&:itself) # .compact doesn't work on BatchLoader + end + + # rubocop: disable CodeReuse/ActiveRecord + def batch_load(iid) BatchLoader.for(iid.to_s).batch(key: project) do |iids, loader, args| args[:key].merge_requests.where(iid: iids).each do |mr| loader.call(mr.iid.to_s, mr) diff --git a/app/graphql/types/issuable_state_enum.rb b/app/graphql/types/issuable_state_enum.rb new file mode 100644 index 00000000000..f2f6d6c6cab --- /dev/null +++ b/app/graphql/types/issuable_state_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + class IssuableStateEnum < BaseEnum + graphql_name 'IssuableState' + description 'State of a GitLab issue or merge request' + + value 'opened' + value 'closed' + value 'locked' + end +end diff --git a/app/graphql/types/issue_state_enum.rb b/app/graphql/types/issue_state_enum.rb new file mode 100644 index 00000000000..6521407fc9d --- /dev/null +++ b/app/graphql/types/issue_state_enum.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class IssueStateEnum < IssuableStateEnum + graphql_name 'IssueState' + description 'State of a GitLab issue' + end +end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index a8f2f7914a8..5ad3ea52930 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -11,22 +11,20 @@ module Types field :iid, GraphQL::ID_TYPE, null: false field :title, GraphQL::STRING_TYPE, null: false field :description, GraphQL::STRING_TYPE, null: true - field :state, GraphQL::STRING_TYPE, null: false + field :state, IssueStateEnum, null: false field :author, Types::UserType, null: false, - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } do - authorize :read_user - end + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }, + authorize: :read_user field :assignees, Types::UserType.connection_type, null: true field :labels, Types::LabelType.connection_type, null: true field :milestone, Types::MilestoneType, null: true, - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } do - authorize :read_milestone - end + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }, + authorize: :read_milestone field :due_date, Types::TimeType, null: true field :confidential, GraphQL::BOOLEAN_TYPE, null: false diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb new file mode 100644 index 00000000000..92f52726ab3 --- /dev/null +++ b/app/graphql/types/merge_request_state_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + class MergeRequestStateEnum < IssuableStateEnum + graphql_name 'MergeRequestState' + description 'State of a GitLab merge request' + + value 'merged' + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index fb740b6fb1c..1ed27a14e33 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -12,7 +12,7 @@ module Types field :iid, GraphQL::ID_TYPE, null: false field :title, GraphQL::STRING_TYPE, null: false field :description, GraphQL::STRING_TYPE, null: true - field :state, GraphQL::STRING_TYPE, null: true + field :state, MergeRequestStateEnum, null: false field :created_at, Types::TimeType, null: false field :updated_at, Types::TimeType, null: false field :source_project, Types::ProjectType, null: true @@ -38,8 +38,8 @@ module Types field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false - field :diff_head_sha, GraphQL::STRING_TYPE, null: true - field :merge_commit_message, GraphQL::STRING_TYPE, null: true + field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true, deprecation_reason: "Renamed to defaultMergeCommitMessage" + field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true @@ -48,9 +48,7 @@ module Types field :downvotes, GraphQL::INT_TYPE, null: false field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false - field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline do - authorize :read_pipeline - end + field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline, authorize: :read_pipeline field :pipelines, Types::Ci::PipelineType.connection_type, resolver: Resolvers::MergeRequestPipelinesResolver end diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb index ab37c282fe5..e9a4ea9157b 100644 --- a/app/graphql/types/permission_types/project.rb +++ b/app/graphql/types/permission_types/project.rb @@ -8,7 +8,7 @@ module Types abilities :change_namespace, :change_visibility_level, :rename_project, :remove_project, :archive_project, :remove_fork_project, :remove_pages, :read_project, :create_merge_request_in, - :read_wiki, :read_project_member, :create_issue, :upload_file, + :read_wiki, :read_project_member, :create_issue, :upload_file, :read_cycle_analytics, :download_code, :download_wiki_code, :fork_project, :create_project_snippet, :read_commit_status, :request_access, :create_pipeline, :create_pipeline_schedule, diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 050706f97be..b96c2f3afb2 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -16,7 +16,6 @@ module Types field :description, GraphQL::STRING_TYPE, null: true - field :default_branch, GraphQL::STRING_TYPE, null: true field :tag_list, GraphQL::STRING_TYPE, null: true field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true @@ -59,25 +58,34 @@ module Types end field :import_status, GraphQL::STRING_TYPE, null: true - field :ci_config_path, GraphQL::STRING_TYPE, null: true field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true + field :merge_requests, + Types::MergeRequestType.connection_type, + null: true, + resolver: Resolvers::MergeRequestsResolver, + authorize: :read_merge_request + field :merge_request, Types::MergeRequestType, null: true, - resolver: Resolvers::MergeRequestResolver do - authorize :read_merge_request - end + resolver: Resolvers::MergeRequestsResolver.single, + authorize: :read_merge_request field :issues, Types::IssueType.connection_type, null: true, resolver: Resolvers::IssuesResolver + field :issue, + Types::IssueType, + null: true, + resolver: Resolvers::IssuesResolver.single + field :pipelines, Types::Ci::PipelineType.connection_type, null: false, diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 7c41716b82a..954bcc0a5a3 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -7,9 +7,8 @@ module Types field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver, - description: "Find a project" do - authorize :read_project - end + description: "Find a project", + authorize: :read_project field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 473c90c882c..023e44258b7 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -28,7 +28,7 @@ module AppearancesHelper def brand_header_logo if current_appearance&.header_logo? - image_tag current_appearance.header_logo_path + image_tag current_appearance.header_logo_path, class: 'brand-header-logo' else render 'shared/logo.svg' end @@ -40,4 +40,36 @@ module AppearancesHelper render 'shared/logo_type.svg' end end + + def header_message + return unless current_appearance&.show_header? + + class_names = [] + class_names << 'with-performance-bar' if performance_bar_enabled? + + render_message(:header_message, class_names) + end + + def footer_message + return unless current_appearance&.show_footer? + + render_message(:footer_message) + end + + private + + def render_message(field_sym, class_names = []) + class_names << field_sym.to_s.dasherize + + content_tag :div, class: class_names, style: message_style do + markdown_field(current_appearance, field_sym) + end + end + + def message_style + style = [] + style << "background-color: #{current_appearance.message_background_color};" + style << "color: #{current_appearance.message_font_color}" + style.join + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9efa84b02f0..ffa5719fefb 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -214,12 +214,19 @@ module ApplicationHelper class_names = [] class_names << 'issue-boards-page' if current_controller?(:boards) class_names << 'with-performance-bar' if performance_bar_enabled? - + class_names << system_message_class class_names end - # EE feature: System header and footer, unavailable in CE def system_message_class + class_names = [] + + return class_names unless appearance + + class_names << 'with-system-header' if appearance.show_header? + class_names << 'with-system-footer' if appearance.show_footer? + + class_names end # Returns active css class when condition returns true @@ -292,4 +299,10 @@ module ApplicationHelper snippets: snippets_project_autocomplete_sources_path(object) } end + + private + + def appearance + ::Appearance.current + end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index c8e4e2e3df9..e635f608237 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -150,6 +150,7 @@ module ApplicationSettingsHelper :email_author_in_body, :enabled_git_access_protocol, :enforce_terms, + :first_day_of_week, :gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast, @@ -231,7 +232,8 @@ module ApplicationSettingsHelper :web_ide_clientside_preview_enabled, :diff_max_patch_bytes, :commit_email_hostname, - :protected_ci_variables + :protected_ci_variables, + :local_markdown_version ] end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 654fb9d9987..2b1d6f49878 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -16,6 +16,13 @@ module AuthHelper PROVIDERS_WITH_ICONS.include?(name.to_s) end + def qa_class_for_provider(provider) + { + saml: 'qa-saml-login-button', + github: 'qa-github-login-button' + }[provider.to_sym] + end + def auth_providers Gitlab::Auth::OAuth::Provider.providers end @@ -31,7 +38,7 @@ module AuthHelper def form_based_provider_with_highest_priority @form_based_provider_with_highest_priority ||= begin form_based_provider_priority.each do |provider_regexp| - highest_priority = form_based_providers.find { |provider| provider.match?(provider_regexp) } + highest_priority = form_based_providers.find { |provider| provider.match?(provider_regexp) } break highest_priority unless highest_priority.nil? end end diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index 516c8a353ea..67e7e475920 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -9,41 +9,4 @@ module AutoDevopsHelper !project.repository.gitlab_ci_yml && !project.ci_service end - - def auto_devops_warning_message(project) - if missing_auto_devops_service?(project) - params = { - kubernetes: link_to('Kubernetes cluster', project_clusters_path(project)) - } - - if missing_auto_devops_domain?(project) - _('Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly.') % params - else - _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params - end - elsif missing_auto_devops_domain?(project) - _('Auto Review Apps and Auto Deploy need a domain name to work correctly.') - end - end - - # rubocop: disable CodeReuse/ActiveRecord - def cluster_ingress_ip(project) - project - .cluster_ingresses - .where("external_ip is not null") - .limit(1) - .pluck(:external_ip) - .first - end - # rubocop: enable CodeReuse/ActiveRecord - - private - - def missing_auto_devops_domain?(project) - !(project.auto_devops || project.build_auto_devops)&.has_domain? - end - - def missing_auto_devops_service?(project) - !project.deployment_platform&.active? - end end diff --git a/app/helpers/count_helper.rb b/app/helpers/count_helper.rb index 13839474e1f..62bb2e4da23 100644 --- a/app/helpers/count_helper.rb +++ b/app/helpers/count_helper.rb @@ -13,7 +13,7 @@ module CountHelper # memberships, and deducting 1 for each root of the fork network. # This might be inacurate as the root of the fork network might have been deleted. # - # This makes querying this information a lot more effecient and it should be + # This makes querying this information a lot more efficient and it should be # accurate enough for the instance wide statistics def approximate_fork_count_with_delimiters(count_data) fork_network_count = count_data[ForkNetwork] diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index b3935ae350d..365b94f5a3e 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -11,7 +11,6 @@ module EnvironmentsHelper { "endpoint" => folder_project_environments_path(@project, @folder, format: :json), "folder-name" => @folder, - "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, "can-read-environment" => can?(current_user, :read_environment, @project).to_s } end diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index 49171df1433..3d494c3de6a 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -8,7 +8,9 @@ module ImportHelper end def sanitize_project_name(name) - name.gsub(/[^\w\-]/, '-') + # For personal projects in Bitbucket in the form ~username, we can + # just drop that leading tilde. + name.gsub(/\A~+/, '').gsub(/[^\w\-]/, '-') end def import_project_target(owner, name) @@ -16,10 +18,8 @@ module ImportHelper "#{namespace}/#{name}" end - def provider_project_link(provider, full_path) - url = __send__("#{provider}_project_url", full_path) # rubocop:disable GitlabSecurity/PublicSend - - link_to full_path, url, target: '_blank', rel: 'noopener noreferrer' + def provider_project_link_url(provider_url, full_path) + Gitlab::Utils.append_path(provider_url, full_path) end def import_will_timeout_message(_ci_cd_only) @@ -44,10 +44,6 @@ module ImportHelper _('Please wait while we import the repository for you. Refresh at will.') end - def import_github_title - _('Import repositories from GitHub') - end - def import_github_authorize_message _('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:') end @@ -71,30 +67,4 @@ module ImportHelper _('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link } end end - - def import_githubish_choose_repository_message - _('Choose which repositories you want to import.') - end - - def import_all_githubish_repositories_button_label - _('Import all repositories') - end - - private - - def github_project_url(full_path) - Gitlab::Utils.append_path(github_root_url, full_path) - end - - def github_root_url - strong_memoize(:github_url) do - provider = Gitlab::Auth::OAuth::Provider.config_for('github') - - provider&.dig('url').presence || 'https://github.com' - end - end - - def gitea_project_url(full_path) - Gitlab::Utils.append_path(@gitea_host_url, full_path) - end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 5f7147508c7..af28e6fcb93 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -200,7 +200,7 @@ module IssuablesHelper author_output end - output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip', title: _('1st contribution!')) + output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip prepend-left-4', title: _('1st contribution!')) output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-sm-none d-md-inline-block prepend-left-8") output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none") @@ -209,7 +209,7 @@ module IssuablesHelper end def issuable_labels_tooltip(labels, limit: 5) - first, last = labels.partition.with_index { |_, i| i < limit } + first, last = labels.partition.with_index { |_, i| i < limit } if labels && labels.any? label_names = first.collect { |label| label.fetch(:title) } @@ -268,7 +268,7 @@ module IssuablesHelper issuableRef: issuable.to_reference, markdownPreviewPath: preview_markdown_path(parent), markdownDocsPath: help_page_path('user/markdown'), - markdownVersion: issuable.cached_markdown_version, + lockVersion: issuable.lock_version, issuableTemplates: issuable_templates(issuable), initialTitleHtml: markdown_field(issuable, :title), initialTitleText: issuable.title, diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 39f661b5f0c..bd53add80ca 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -227,6 +227,10 @@ module LabelsHelper "#{action} at #{level} level" end + def labels_sorted_by_title(labels) + labels.sort_by(&:title) + end + # Required for Banzai::Filter::LabelReferenceFilter module_function :render_colored_label, :text_color_for_bg, :escape_once end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 0d638b850b4..66f4b7b3f30 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -116,7 +116,6 @@ module MarkupHelper def markup(file_name, text, context = {}) context[:project] ||= @project - context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled? html = context.delete(:rendered) || markup_unsafe(file_name, text, context) prepare_for_rendering(html, context) end @@ -132,7 +131,6 @@ module MarkupHelper page_slug: wiki_page.slug, issuable_state_filter_enabled: true ) - context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled? html = case wiki_page.format @@ -187,10 +185,6 @@ module MarkupHelper end end - def commonmark_for_repositories_enabled? - Feature.enabled?(:commonmark_for_repositories, default_enabled: true) - end - private # Return +text+, truncated to +max_chars+ characters, excluding any HTML diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index 6c65e573307..ea3bcfc791a 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -5,11 +5,8 @@ module NamespacesHelper params.dig(:project, :namespace_id) || params[:namespace_id] end - # rubocop: disable CodeReuse/ActiveRecord def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false) - groups ||= current_user.manageable_groups - .eager_load(:route) - .order('routes.path') + groups ||= current_user.manageable_groups_with_routes users = [current_user.namespace] selected_id = selected @@ -43,7 +40,6 @@ module NamespacesHelper grouped_options_for_select(options, selected_id) end - # rubocop: enable CodeReuse/ActiveRecord def namespace_icon(namespace, size = 40) if namespace.is_a?(Group) diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 033686823a2..aaf38cbfe70 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -85,7 +85,7 @@ module NotesHelper diffs_project_merge_request_path(discussion.project, discussion.noteable, path_params) elsif discussion.for_commit? - anchor = discussion.line_code if discussion.diff_discussion? + anchor = discussion.diff_discussion? ? discussion.line_code : "note_#{discussion.first_note.id}" project_commit_path(discussion.project, discussion.noteable, anchor: anchor) end @@ -171,7 +171,6 @@ module NotesHelper registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), markdownDocsPath: help_page_path('user/markdown'), - markdownVersion: issuable.cached_markdown_version, quickActionsDocsPath: help_page_path('user/project/quick_actions'), closePath: close_issuable_path(issuable), reopenPath: reopen_issuable_path(issuable), diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index f4f46b0fe96..eed529f93db 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -43,6 +43,17 @@ module PreferencesHelper ] end + def first_day_of_week_choices + [ + [_('Sunday'), 0], + [_('Monday'), 1] + ] + end + + def first_day_of_week_choices_with_default + first_day_of_week_choices.unshift([_('System default (%{default})') % { default: default_first_day_of_week }, nil]) + end + def user_application_theme @user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class end @@ -51,6 +62,10 @@ module PreferencesHelper Gitlab::ColorSchemes.for_user(current_user).css_class end + def language_choices + Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] } + end + private # Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too @@ -66,4 +81,8 @@ module PreferencesHelper def excluded_dashboard_choices ['operations'] end + + def default_first_day_of_week + first_day_of_week_choices.rassoc(Gitlab::CurrentSettings.first_day_of_week).first + end end diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index df318de740a..5a42e581867 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -25,4 +25,8 @@ module ProfilesHelper end end end + + def user_profile? + params[:controller] == 'users' + end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index b0d3d509f5d..c400302cda3 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -265,10 +265,6 @@ module ProjectsHelper link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer' end - def legacy_render_context(params) - params[:legacy_render] ? { markdown_engine: :redcarpet } : {} - end - def explore_projects_tab? current_page?(explore_projects_path) || current_page?(trending_explore_projects_path) || @@ -305,7 +301,8 @@ module ProjectsHelper nav_tabs << :container_registry end - if project.builds_enabled? && can?(current_user, :read_pipeline, project) + # Pipelines feature is tied to presence of builds + if can?(current_user, :read_build, project) nav_tabs << :pipelines end @@ -327,7 +324,7 @@ module ProjectsHelper def external_nav_tabs(project) [].tap do |tabs| tabs << :external_issue_tracker if project.external_issue_tracker - tabs << :external_wiki if project.has_external_wiki? + tabs << :external_wiki if project.external_wiki end end diff --git a/app/helpers/release_blog_post_helper.rb b/app/helpers/release_blog_post_helper.rb deleted file mode 100644 index 31b5b7edc39..00000000000 --- a/app/helpers/release_blog_post_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module ReleaseBlogPostHelper - def blog_post_url - Gitlab::ReleaseBlogPost.instance.blog_post_url - end -end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 02762897c89..07ec129dea3 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -128,7 +128,9 @@ module SortingHelper sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, sort_value_recently_updated => sort_title_recently_updated, - sort_value_oldest_updated => sort_title_oldest_updated + sort_value_oldest_updated => sort_title_oldest_updated, + sort_value_recently_last_activity => sort_title_recently_last_activity, + sort_value_oldest_last_activity => sort_title_oldest_last_activity } end @@ -317,6 +319,14 @@ module SortingHelper s_('SortOptions|Most stars') end + def sort_title_oldest_last_activity + s_('SortOptions|Oldest last activity') + end + + def sort_title_recently_last_activity + s_('SortOptions|Recent last activity') + end + # Values. def sort_value_access_level_asc 'access_level_asc' @@ -445,4 +455,12 @@ module SortingHelper def sort_value_most_stars 'stars_desc' end + + def sort_value_oldest_last_activity + 'last_activity_on_asc' + end + + def sort_value_recently_last_activity + 'last_activity_on_desc' + end end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 73c1402eae5..73ca17c6605 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -74,6 +74,15 @@ module UsersHelper Gitlab.config.gitlab.impersonation_enabled end + def user_badges_in_admin_section(user) + [].tap do |badges| + badges << { text: s_('AdminUsers|Blocked'), variant: 'danger' } if user.blocked? + badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin? + badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external? + badges << { text: s_("AdminUsers|It's you!"), variant: nil } if current_user == user + end + end + private def get_profile_tabs diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 654ae211310..d2e334fb856 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -74,6 +74,7 @@ module Emails @new_issue = new_issue @new_project = new_issue.project + @can_access_project = recipient.can?(:read_project, @new_project) mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason)) end diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 0d9c6a4a1f0..1e01f1d17e6 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -5,7 +5,8 @@ class ActiveSession attr_accessor :created_at, :updated_at, :session_id, :ip_address, - :browser, :os, :device_name, :device_type + :browser, :os, :device_name, :device_type, + :is_impersonated def current?(session) return false if session_id.nil? || session.id.nil? @@ -31,7 +32,8 @@ class ActiveSession device_type: client.device_type, created_at: user.current_sign_in_at || timestamp, updated_at: timestamp, - session_id: session_id + session_id: session_id, + is_impersonated: request.session[:impersonator_id].present? ) redis.pipelined do diff --git a/app/models/appearance.rb b/app/models/appearance.rb index ff1ecfda684..b9ad676ca47 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -8,12 +8,19 @@ class Appearance < ActiveRecord::Base cache_markdown_field :description cache_markdown_field :new_project_guidelines + cache_markdown_field :header_message, pipeline: :broadcast_message + cache_markdown_field :footer_message, pipeline: :broadcast_message validates :logo, file_size: { maximum: 1.megabyte } validates :header_logo, file_size: { maximum: 1.megabyte } + validates :message_background_color, allow_blank: true, color: true + validates :message_font_color, allow_blank: true, color: true validate :single_appearance_row, on: :create + default_value_for :message_background_color, '#E75E40' + default_value_for :message_font_color, '#FFFFFF' + mount_uploader :logo, AttachmentUploader mount_uploader :header_logo, AttachmentUploader mount_uploader :favicon, FaviconUploader @@ -41,6 +48,14 @@ class Appearance < ActiveRecord::Base logo_system_path(favicon, 'favicon') end + def show_header? + header_message.present? + end + + def show_footer? + footer_message.present? + end + private def logo_system_path(logo, mount_type) diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 71fbba5b328..a3d662d8250 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -2,4 +2,22 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + + def self.id_in(ids) + where(id: ids) + end + + def self.safe_find_or_create_by!(*args) + safe_find_or_create_by(*args).tap do |record| + record.validate! unless record.persisted? + end + end + + def self.safe_find_or_create_by(*args) + transaction(requires_new: true) do + find_or_create_by(*args) + end + rescue ActiveRecord::RecordNotUnique + retry + end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 88746375c67..daadf9427ba 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -193,6 +193,10 @@ class ApplicationSetting < ActiveRecord::Base allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds } + validates :local_markdown_version, + allow_nil: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 65536 } + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end @@ -246,6 +250,7 @@ class ApplicationSetting < ActiveRecord::Base dsa_key_restriction: 0, ecdsa_key_restriction: 0, ed25519_key_restriction: 0, + first_day_of_week: 0, gitaly_timeout_default: 55, gitaly_timeout_fast: 10, gitaly_timeout_medium: 30, @@ -303,7 +308,8 @@ class ApplicationSetting < ActiveRecord::Base usage_stats_set_by_user_id: nil, diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, commit_email_hostname: default_commit_email_hostname, - protected_ci_variables: false + protected_ci_variables: false, + local_markdown_version: 0 } end diff --git a/app/models/board.rb b/app/models/board.rb index a137863456c..758a71d6903 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -21,6 +21,10 @@ class Board < ActiveRecord::Base group_id.present? end + def project_board? + project_id.present? + end + def backlog_list lists.merge(List.backlog).take end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 29aa00a66d9..5450d40ea95 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -2,11 +2,13 @@ module Ci class Bridge < CommitStatus + include Ci::Processable include Importable include AfterCommitQueue include Gitlab::Utils::StrongMemoize belongs_to :project + belongs_to :trigger_request validates :ref, presence: true def self.retry(bridge, current_user) @@ -23,6 +25,21 @@ module Ci .fabricate! end + def schedulable? + false + end + + def action? + false + end + + def artifacts? + false + end + + def expanded_environment_name + end + def predefined_variables raise NotImplementedError end @@ -30,5 +47,9 @@ module Ci def execute_hooks raise NotImplementedError end + + def to_partial_path + 'projects/generic_commit_statuses/generic_commit_status' + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index cfdb3c0d719..c902e49ee6d 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -3,6 +3,8 @@ module Ci class Build < CommitStatus prepend ArtifactMigratable + include Ci::Processable + include Ci::Metadatable include TokenAuthenticatable include AfterCommitQueue include ObjectStorage::BackgroundMove @@ -36,25 +38,34 @@ module Ci has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id end - has_one :metadata, class_name: 'Ci::BuildMetadata', autosave: true has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build accepts_nested_attributes_for :runner_session - delegate :timeout, to: :metadata, prefix: true, allow_nil: true delegate :url, to: :runner_session, prefix: true, allow_nil: true delegate :terminal_specification, to: :runner_session, allow_nil: true delegate :gitlab_deploy_token, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true + delegate :merge_request?, to: :pipeline ## - # The "environment" field for builds is a String, and is the unexpanded name! + # Since Gitlab 11.5, deployments records started being created right after + # `ci_builds` creation. We can look up a relevant `environment` through + # `deployment` relation today. This is much more efficient than expanding + # environment name with variables. + # (See more https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22380) # + # However, we have to still expand environment name if it's a stop action, + # because `deployment` persists information for start action only. + # + # We will follow up this by persisting expanded name in build metadata or + # persisting stop action in database. def persisted_environment return unless has_environment? strong_memoize(:persisted_environment) do - Environment.find_by(name: expanded_environment_name, project: project) + deployment&.environment || + Environment.find_by(name: expanded_environment_name, project: project) end end @@ -132,7 +143,6 @@ module Ci before_save :ensure_token before_destroy { unscoped_project } - before_create :ensure_metadata after_create unless: :importing? do |build| run_after_commit { BuildHooksWorker.perform_async(build.id) } end @@ -260,10 +270,6 @@ module Ci end end - def ensure_metadata - metadata || build_metadata(project: project) - end - def detailed_status(current_user) Gitlab::Ci::Status::Build::Factory .new(self, current_user) @@ -283,18 +289,6 @@ module Ci self.name == 'pages' end - # degenerated build is one that cannot be run by Runner - def degenerated? - self.options.blank? - end - - def degenerate! - Build.transaction do - self.update!(options: nil, yaml_variables: nil) - self.metadata&.destroy - end - end - def archived? return true if degenerated? @@ -448,11 +442,13 @@ module Ci # All variables, including persisted environment variables. # def variables - Gitlab::Ci::Variables::Collection.new - .concat(persisted_variables) - .concat(scoped_variables) - .concat(persisted_environment_variables) - .to_runner_variables + strong_memoize(:variables) do + Gitlab::Ci::Variables::Collection.new + .concat(persisted_variables) + .concat(scoped_variables) + .concat(persisted_environment_variables) + .to_runner_variables + end end ## @@ -638,26 +634,6 @@ module Ci super || project.try(:build_coverage_regex) end - def when - read_attribute(:when) || 'on_success' - end - - def options - read_metadata_attribute(:options, :config_options, {}) - end - - def yaml_variables - read_metadata_attribute(:yaml_variables, :config_variables, []) - end - - def options=(value) - write_metadata_attribute(:options, :config_options, value) - end - - def yaml_variables=(value) - write_metadata_attribute(:yaml_variables, :config_variables, value) - end - def user_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables if user.blank? @@ -959,20 +935,5 @@ module Ci def project_destroyed? project.pending_delete? end - - def read_metadata_attribute(legacy_key, metadata_key, default_value = nil) - read_attribute(legacy_key) || metadata&.read_attribute(metadata_key) || default_value - end - - def write_metadata_attribute(legacy_key, metadata_key, value) - # save to metadata or this model depending on the state of feature flag - if Feature.enabled?(:ci_build_metadata_config) - ensure_metadata.write_attribute(metadata_key, value) - write_attribute(legacy_key, nil) - else - write_attribute(legacy_key, value) - metadata&.write_attribute(metadata_key, nil) - end - end end end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 38390f49217..cd8eb774cf5 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -10,7 +10,7 @@ module Ci self.table_name = 'ci_builds_metadata' - belongs_to :build, class_name: 'Ci::Build' + belongs_to :build, class_name: 'CommitStatus' belongs_to :project before_create :set_build_project diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index da08214963f..33e61cd2111 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -18,7 +18,7 @@ module Ci FailedToPersistDataError = Class.new(StandardError) # Note: The ordering of this enum is related to the precedence of persist store. - # The bottom item takes the higest precedence, and the top item takes the lowest precedence. + # The bottom item takes the highest precedence, and the top item takes the lowest precedence. enum data_store: { redis: 1, database: 2, diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 11c88200c37..789bb293811 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -73,6 +73,8 @@ module Ci where(file_type: types) end + scope :expired, -> (limit) { where('expire_at < ?', Time.now).limit(limit) } + delegate :filename, :exists?, :open, to: :file enum file_type: { diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 30a957b4117..d4586219333 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -12,6 +12,10 @@ module Ci include AtomicInternalId include EnumWithNil include HasRef + include ShaAttribute + + sha_attribute :source_sha + sha_attribute :target_sha belongs_to :project, inverse_of: :all_pipelines belongs_to :user @@ -25,6 +29,8 @@ module Ci has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline + has_many :processables, -> { processables }, + class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent has_many :variables, class_name: 'Ci::PipelineVariable' @@ -45,6 +51,8 @@ module Ci has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id' + has_one :chat_data, class_name: 'Ci::PipelineChatData' + accepts_nested_attributes_for :variables, reject_if: :persisted? delegate :id, to: :project, prefix: true @@ -187,6 +195,22 @@ module Ci .sort_by_merge_request_pipelines end + scope :triggered_by_merge_request, -> (merge_request) do + where(source: :merge_request, merge_request: merge_request) + end + + scope :detached_merge_request_pipelines, -> (merge_request) do + triggered_by_merge_request(merge_request).where(target_sha: nil) + end + + scope :merge_request_pipelines, -> (merge_request) do + triggered_by_merge_request(merge_request).where.not(target_sha: nil) + end + + scope :mergeable_merge_request_pipelines, -> (merge_request) do + triggered_by_merge_request(merge_request).where(target_sha: merge_request.target_branch_sha) + end + # Returns the pipelines in descending order (= newest first), optionally # limited to a number of references. # @@ -313,7 +337,7 @@ module Ci def ordered_stages return legacy_stages unless complete? - if Feature.enabled?('ci_pipeline_persisted_stages') + if Feature.enabled?('ci_pipeline_persisted_stages', default_enabled: true) stages else legacy_stages @@ -620,6 +644,8 @@ module Ci variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s) if merge_request? && merge_request + variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s) + variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s) variables.concat(merge_request.predefined_variables) end end @@ -648,9 +674,9 @@ module Ci def all_merge_requests @all_merge_requests ||= if merge_request? - project.merge_requests.where(id: merge_request_id) + MergeRequest.where(id: merge_request_id) else - project.merge_requests.where(source_branch: ref) + MergeRequest.where(source_project_id: project_id, source_branch: ref) end end @@ -685,9 +711,18 @@ module Ci end end + # Returns the modified paths. + # + # The returned value is + # * Array: List of modified paths that should be evaluated + # * nil: Modified path can not be evaluated def modified_paths strong_memoize(:modified_paths) do - push_details.modified_paths + if merge_request? + merge_request.modified_paths + elsif branch_updated? + push_details.modified_paths + end end end @@ -695,6 +730,22 @@ module Ci ref == project.default_branch end + def triggered_by_merge_request? + merge_request? && merge_request_id.present? + end + + def detached_merge_request_pipeline? + triggered_by_merge_request? && target_sha.nil? + end + + def merge_request_pipeline? + triggered_by_merge_request? && target_sha.present? + end + + def mergeable_merge_request_pipeline? + triggered_by_merge_request? && target_sha == merge_request.target_branch_sha + end + private def ci_yaml_from_repo diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb new file mode 100644 index 00000000000..8d37500fec5 --- /dev/null +++ b/app/models/ci/pipeline_chat_data.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class PipelineChatData < ActiveRecord::Base + self.table_name = 'ci_pipeline_chat_data' + + belongs_to :chat_name + + validates :pipeline_id, presence: true + validates :chat_name_id, presence: true + validates :response_url, presence: true + end +end diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 2994aaae4aa..4be4fdb1ff2 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -22,6 +22,7 @@ module Ci schedule: 4, api: 5, external: 6, + chat: 8, merge_request: 10 } end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 5aae31de6e2..d82e11bbb89 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -97,6 +97,7 @@ module Ci scope :order_contacted_at_asc, -> { order(contacted_at: :asc) } scope :order_created_at_desc, -> { order(created_at: :desc) } + scope :with_tags, -> { preload(:tags) } validate :tag_constraints validates :access_level, presence: true diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 58f3fe2460a..0389945191e 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -14,6 +14,7 @@ module Ci has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id has_many :builds, foreign_key: :stage_id + has_many :bridges, foreign_key: :stage_id with_options unless: :importing? do validates :project, presence: true diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 26bf73f4dd8..fa7ce363531 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -6,7 +6,6 @@ module Clusters include PrometheusAdapter VERSION = '6.7.3' - READY_STATUS = [:installed, :updating, :updated, :update_errored].freeze self.table_name = 'clusters_applications_prometheus' @@ -25,10 +24,6 @@ module Clusters end end - def ready? - READY_STATUS.include?(status_name) - end - def chart 'stable/prometheus' end @@ -53,11 +48,11 @@ module Clusters end def upgrade_command(values) - ::Gitlab::Kubernetes::Helm::UpgradeCommand.new( - name, + ::Gitlab::Kubernetes::Helm::InstallCommand.new( + name: name, version: VERSION, - chart: chart, rbac: cluster.platform_kubernetes_rbac?, + chart: chart, files: files_with_replaced_values(values) ) end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index f17da0bb7b1..941551dadaa 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ActiveRecord::Base - VERSION = '0.1.45'.freeze + VERSION = '0.2.0'.freeze self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index a2c48973fa5..be3e6a05e1e 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -18,6 +18,7 @@ module Clusters Applications::Knative.application_name => Applications::Knative }.freeze DEFAULT_ENVIRONMENT = '*'.freeze + KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'.freeze belongs_to :user @@ -49,7 +50,7 @@ module Clusters validates :name, cluster_name: true validates :cluster_type, presence: true - validates :domain, allow_nil: true, hostname: { allow_numeric_hostname: true, require_valid_tld: true } + validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true } validate :restrict_modification, on: :update validate :no_groups, unless: :group_type? @@ -65,6 +66,9 @@ module Clusters delegate :available?, to: :application_ingress, prefix: true, allow_nil: true delegate :available?, to: :application_prometheus, prefix: true, allow_nil: true delegate :available?, to: :application_knative, prefix: true, allow_nil: true + delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true + + alias_attribute :base_domain, :domain enum cluster_type: { instance_type: 1, @@ -95,7 +99,7 @@ module Clusters where('NOT EXISTS (?)', subquery) end - scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.installed) } + scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.available) } scope :preload_knative, -> { preload( @@ -193,8 +197,42 @@ module Clusters project_type? end + def kube_ingress_domain + @kube_ingress_domain ||= domain.presence || instance_domain || legacy_auto_devops_domain + end + + def predefined_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless kube_ingress_domain + + variables.append(key: KUBE_INGRESS_BASE_DOMAIN, value: kube_ingress_domain) + end + end + private + def instance_domain + @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain + end + + # To keep backward compatibility with AUTO_DEVOPS_DOMAIN + # environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN + # is set if AUTO_DEVOPS_DOMAIN is set on any of the following options: + # ProjectAutoDevops#Domain, project variables or group variables, + # as the AUTO_DEVOPS_DOMAIN is needed for CI_ENVIRONMENT_URL + # + # This method should is scheduled to be removed on + # https://gitlab.com/gitlab-org/gitlab-ce/issues/56959 + def legacy_auto_devops_domain + if project_type? + project&.auto_devops&.domain.presence || + project.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence || + project.group&.variables&.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence + elsif group_type? + group.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence + end + end + def restrict_modification if provider&.on_creation? errors.add(:base, "cannot modify during creation") diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index a556dd5ad8b..1273ed83abe 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -6,7 +6,14 @@ module Clusters extend ActiveSupport::Concern included do - scope :installed, -> { where(status: self.state_machines[:status].states[:installed].value) } + scope :available, -> do + where( + status: [ + self.state_machines[:status].states[:installed].value, + self.state_machines[:status].states[:updated].value + ] + ) + end state_machine :status, initial: :not_installable do state :not_installable, value: -2 @@ -20,7 +27,7 @@ module Clusters state :update_errored, value: 6 event :make_scheduled do - transition [:installable, :errored] => :scheduled + transition [:installable, :errored, :installed, :updated, :update_errored] => :scheduled end event :make_installing do @@ -29,18 +36,16 @@ module Clusters event :make_installed do transition [:installing] => :installed + transition [:updating] => :updated end event :make_errored do - transition any => :errored + transition any - [:updating] => :errored + transition [:updating] => :update_errored end event :make_updating do - transition [:installed, :updated, :update_errored] => :updating - end - - event :make_updated do - transition [:updating] => :updated + transition [:installed, :updated, :update_errored, :scheduled] => :updating end event :make_update_errored do @@ -74,6 +79,10 @@ module Clusters end end + def updateable? + installed? || updated? || update_errored? + end + def available? installed? || updated? end diff --git a/app/models/clusters/concerns/application_version.rb b/app/models/clusters/concerns/application_version.rb index ccad74dc35a..db94e8e08c9 100644 --- a/app/models/clusters/concerns/application_version.rb +++ b/app/models/clusters/concerns/application_version.rb @@ -7,11 +7,15 @@ module Clusters included do state_machine :status do - after_transition any => [:installing] do |application| - application.update(version: application.class.const_get(:VERSION)) + before_transition any => [:installed, :updated] do |application| + application.version = application.class.const_get(:VERSION) end end end + + def update_available? + version != self.class.const_get(:VERSION) + end end end end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 1cc170c8c4d..814fc591408 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -41,8 +41,9 @@ module Clusters validate :no_namespace, unless: :allow_user_defined_namespace? # We expect to be `active?` only when enabled and cluster is created (the api_url is assigned) - validates :api_url, url: true, presence: true + validates :api_url, public_url: true, presence: true validates :token, presence: true + validates :ca_cert, certificate: true, allow_blank: true, if: :ca_cert_changed? validate :prevent_modification, on: :update @@ -98,6 +99,8 @@ module Clusters .append(key: 'KUBE_NAMESPACE', value: actual_namespace) .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true) end + + variables.concat(cluster.predefined_variables) end end @@ -154,7 +157,7 @@ module Clusters def build_kube_client! raise "Incomplete settings" unless api_url - raise "No namespace" if cluster.project_type? && actual_namespace.empty? # can probably remove this line once we remove #actual_namespace + raise "No namespace" if cluster.project_type? && actual_namespace.empty? # can probably remove this line once we remove #actual_namespace unless (username && password) || token raise "Either username/password or token is required to access API" diff --git a/app/models/commit.rb b/app/models/commit.rb index 01f4c58daa1..f412d252e5c 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -11,6 +11,7 @@ class Commit include Mentionable include Referable include StaticModel + include Presentable include ::Gitlab::Utils::StrongMemoize attr_mentionable :safe_message, pipeline: :single_line @@ -304,7 +305,9 @@ class Commit end def last_pipeline - @last_pipeline ||= pipelines.last + strong_memoize(:last_pipeline) do + pipelines.last + end end def status(ref = nil) @@ -376,7 +379,7 @@ class Commit end def merge_commit? - parents.size > 1 + parent_ids.size > 1 end def merged_merge_request(current_user) diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index 885f61beb05..a9a2e9c81eb 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -3,6 +3,7 @@ # A collection of Commit instances for a specific project and Git reference. class CommitCollection include Enumerable + include Gitlab::Utils::StrongMemoize attr_reader :project, :ref, :commits @@ -19,12 +20,18 @@ class CommitCollection commits.each(&block) end - def committers - emails = commits.reject(&:merge_commit?).map(&:committer_email).uniq + def authors + emails = without_merge_commits.map(&:author_email).uniq User.by_any_email(emails) end + def without_merge_commits + strong_memoize(:without_merge_commits) do + commits.reject(&:merge_commit?) + end + end + # Sets the pipeline status for every commit. # # Setting this status ahead of time removes the need for running a query for diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 0f50bd39131..7f6562b63e5 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -41,6 +41,7 @@ class CommitStatus < ActiveRecord::Base scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } + scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) } # We use `CommitStatusEnums.failure_reasons` here so that EE can more easily # extend this `Hash` with new values. diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 002f3e17891..1a8570b80c3 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -13,7 +13,6 @@ module CacheMarkdownField extend ActiveSupport::Concern # Increment this number every time the renderer changes its output - CACHE_REDCARPET_VERSION = 3 CACHE_COMMONMARK_VERSION_START = 10 CACHE_COMMONMARK_VERSION = 14 @@ -42,18 +41,6 @@ module CacheMarkdownField end end - class MarkdownEngine - def self.from_version(version = nil) - return :common_mark if version.nil? || version == 0 - - if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - :redcarpet - else - :common_mark - end - end - end - def skip_project_check? false end @@ -71,7 +58,7 @@ module CacheMarkdownField # Banzai is less strict about authors, so don't always have an author key context[:author] = self.author if self.respond_to?(:author) - context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version) + context[:markdown_engine] = :common_mark context end @@ -128,12 +115,27 @@ module CacheMarkdownField end def latest_cached_markdown_version - return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version + @latest_cached_markdown_version ||= (CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) | local_version + end - if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START - CacheMarkdownField::CACHE_REDCARPET_VERSION + def local_version + # because local_markdown_version is stored in application_settings which + # uses cached_markdown_version too, we check explicitly to avoid + # endless loop + return local_markdown_version if has_attribute?(:local_markdown_version) + + settings = Gitlab::CurrentSettings.current_application_settings + + # Following migrations are not properly isolated and + # use real models (by calling .ghost method), in these migrations + # local_markdown_version attribute doesn't exist yet, so we + # use a default value: + # db/migrate/20170825104051_migrate_issues_to_ghost_user.rb + # db/migrate/20171114150259_merge_requests_author_id_foreign_key.rb + if settings.respond_to?(:local_markdown_version) + settings.local_markdown_version else - CacheMarkdownField::CACHE_COMMONMARK_VERSION + 0 end end @@ -178,7 +180,9 @@ module CacheMarkdownField # author and project invalidate the cache in all circumstances. define_method(invalidation_method) do changed_fields = changed_attributes.keys - invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY] + invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY] + invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html") + !invalidations.empty? || !cached_html_up_to_date?(markdown_field) end end diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb new file mode 100644 index 00000000000..9eed9492b37 --- /dev/null +++ b/app/models/concerns/ci/metadatable.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Ci + ## + # This module implements methods that need to read and write + # metadata for CI/CD entities. + # + module Metadatable + extend ActiveSupport::Concern + + included do + has_one :metadata, class_name: 'Ci::BuildMetadata', + foreign_key: :build_id, + inverse_of: :build, + autosave: true + + delegate :timeout, to: :metadata, prefix: true, allow_nil: true + before_create :ensure_metadata + end + + def ensure_metadata + metadata || build_metadata(project: project) + end + + def degenerated? + self.options.blank? + end + + def degenerate! + self.class.transaction do + self.update!(options: nil, yaml_variables: nil) + self.metadata&.destroy + end + end + + def options + read_metadata_attribute(:options, :config_options, {}) + end + + def yaml_variables + read_metadata_attribute(:yaml_variables, :config_variables, []) + end + + def options=(value) + write_metadata_attribute(:options, :config_options, value) + end + + def yaml_variables=(value) + write_metadata_attribute(:yaml_variables, :config_variables, value) + end + + private + + def read_metadata_attribute(legacy_key, metadata_key, default_value = nil) + read_attribute(legacy_key) || metadata&.read_attribute(metadata_key) || default_value + end + + def write_metadata_attribute(legacy_key, metadata_key, value) + # save to metadata or this model depending on the state of feature flag + if Feature.enabled?(:ci_build_metadata_config) + ensure_metadata.write_attribute(metadata_key, value) + write_attribute(legacy_key, nil) + else + write_attribute(legacy_key, value) + metadata&.write_attribute(metadata_key, nil) + end + end + end +end diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb new file mode 100644 index 00000000000..1c78b1413a8 --- /dev/null +++ b/app/models/concerns/ci/processable.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Ci + ## + # This module implements methods that need to be implemented by CI/CD + # entities that are supposed to go through pipeline processing + # services. + # + # + module Processable + def schedulable? + raise NotImplementedError + end + + def action? + raise NotImplementedError + end + + def when + read_attribute(:when) || 'on_success' + end + + def expanded_environment_name + raise NotImplementedError + end + end +end diff --git a/app/models/concerns/closed_at_filterable.rb b/app/models/concerns/closed_at_filterable.rb new file mode 100644 index 00000000000..239c2e47611 --- /dev/null +++ b/app/models/concerns/closed_at_filterable.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ClosedAtFilterable + extend ActiveSupport::Concern + + included do + scope :closed_before, ->(date) { where(scoped_table[:closed_at].lteq(date)) } + scope :closed_after, ->(date) { where(scoped_table[:closed_at].gteq(date)) } + + def self.scoped_table + arel_table.alias(table_name) + end + end +end diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb index 1e3afd641ed..f862031bce0 100644 --- a/app/models/concerns/fast_destroy_all.rb +++ b/app/models/concerns/fast_destroy_all.rb @@ -11,7 +11,7 @@ # it is difficult to accomplish it. # # This module defines a format to use `delete_all` and delete associated external data. -# Here is an exmaple +# Here is an example # # Situation # - `Project` has many `Ci::BuildTraceChunk` through `Ci::Build` diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index b92643f87f8..0d2be4c61ab 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -85,11 +85,11 @@ module HasStatus scope :running, -> { where(status: 'running') } scope :pending, -> { where(status: 'pending') } scope :success, -> { where(status: 'success') } - scope :failed, -> { where(status: 'failed') } - scope :canceled, -> { where(status: 'canceled') } - scope :skipped, -> { where(status: 'skipped') } - scope :manual, -> { where(status: 'manual') } - scope :scheduled, -> { where(status: 'scheduled') } + scope :failed, -> { where(status: 'failed') } + scope :canceled, -> { where(status: 'canceled') } + scope :skipped, -> { where(status: 'skipped') } + scope :manual, -> { where(status: 'manual') } + scope :scheduled, -> { where(status: 'scheduled') } scope :alive, -> { where(status: [:created, :pending, :running]) } scope :created_or_pending, -> { where(status: [:created, :pending]) } scope :running_or_pending, -> { where(status: [:running, :pending]) } diff --git a/app/models/concerns/iid_routes.rb b/app/models/concerns/iid_routes.rb index b7f99e845ca..3eeb29b6595 100644 --- a/app/models/concerns/iid_routes.rb +++ b/app/models/concerns/iid_routes.rb @@ -4,7 +4,7 @@ module IidRoutes ## # This automagically enforces all related routes to use `iid` instead of `id` # If you want to use `iid` for some routes and `id` for other routes, this module should not to be included, - # instead you should define `iid` or `id` explictly at each route generators. e.g. pipeline_path(project.id, pipeline.iid) + # instead you should define `iid` or `id` explicitly at each route generators. e.g. pipeline_path(project.id, pipeline.iid) def to_param iid.to_s end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 0d363ec68b7..429a63f83cc 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -23,6 +23,7 @@ module Issuable include Sortable include CreatedAtFilterable include UpdatedAtFilterable + include ClosedAtFilterable # This object is used to gather issuable meta data for displaying # upvotes, downvotes, notes and closing merge requests count for issues and merge requests @@ -136,10 +137,18 @@ module Issuable # This method uses ILIKE on PostgreSQL and LIKE on MySQL. # # query - The search query as a String + # matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma. # # Returns an ActiveRecord::Relation. - def full_search(query) - fuzzy_search(query, [:title, :description]) + def full_search(query, matched_columns: 'title,description') + allowed_columns = [:title, :description] + matched_columns = matched_columns.to_s.split(',').map(&:to_sym) + matched_columns &= allowed_columns + + # Matching title or description if the matched_columns did not contain any allowed columns. + matched_columns = [:title, :description] if matched_columns.empty? + + fuzzy_search(query, matched_columns) end def sort_by_attribute(method, excluded_labels: []) @@ -270,26 +279,29 @@ module Issuable def to_hook_data(user, old_associations: {}) changes = previous_changes - old_labels = old_associations.fetch(:labels, []) - old_assignees = old_associations.fetch(:assignees, []) - if old_labels != labels - changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)] - end + if old_associations + old_labels = old_associations.fetch(:labels, []) + old_assignees = old_associations.fetch(:assignees, []) - if old_assignees != assignees - if self.is_a?(Issue) - changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] - else - changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs] + if old_labels != labels + changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)] end - end - if self.respond_to?(:total_time_spent) - old_total_time_spent = old_associations.fetch(:total_time_spent, nil) + if old_assignees != assignees + if self.is_a?(Issue) + changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)] + else + changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs] + end + end - if old_total_time_spent != total_time_spent - changes[:total_time_spent] = [old_total_time_spent, total_time_spent] + if self.respond_to?(:total_time_spent) + old_total_time_spent = old_associations.fetch(:total_time_spent, nil) + + if old_total_time_spent != total_time_spent + changes[:total_time_spent] = [old_total_time_spent, total_time_spent] + end end end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 055ffe04646..39372c4f68b 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -46,12 +46,19 @@ module Milestoneish end end + def merge_requests_visible_to_user(user) + memoize_per_user(user, :merge_requests_visible_to_user) do + MergeRequestsFinder.new(user, {}) + .execute.where(milestone_id: milestoneish_id) + end + end + def sorted_issues(user) issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority') end - def sorted_merge_requests - merge_requests.sort_by_attribute('label_priority') + def sorted_merge_requests(user) + merge_requests_visible_to_user(user).sort_by_attribute('label_priority') end def upcoming? diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 29476654bf7..3c74034b527 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -1,9 +1,18 @@ # frozen_string_literal: true module Noteable - # Names of all implementers of `Noteable` that support resolvable notes. + extend ActiveSupport::Concern + + # `Noteable` class names that support resolvable notes. RESOLVABLE_TYPES = %w(MergeRequest).freeze + class_methods do + # `Noteable` class names that support replying to individual notes. + def replyable_types + %w(Issue MergeRequest) + end + end + def base_class_name self.class.base_class.name end @@ -26,6 +35,10 @@ module Noteable DiscussionNote.noteable_types.include?(base_class_name) end + def supports_replying_to_individual_notes? + supports_discussions? && self.class.replyable_types.include?(base_class_name) + end + def supports_suggestion? false end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index d3572875fb3..de77ca3e963 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -76,7 +76,7 @@ module ReactiveCaching begin data = Rails.cache.read(full_reactive_cache_key(*args)) - yield data if data.present? + yield data unless data.nil? rescue InvalidateReactiveCache refresh_reactive_cache!(*args) nil diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index e51b4e22c96..a479bef993c 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -16,6 +16,8 @@ module ShaAttribute # the column is the correct type. In production it should behave like any other attribute. # See https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/5502 for more discussion def validate_binary_column_exists!(name) + return unless database_exists? + unless table_exists? warn "WARNING: sha_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations" return @@ -35,5 +37,13 @@ module ShaAttribute Gitlab::AppLogger.error "ShaAttribute initialization: #{error.message}" raise end + + def database_exists? + ActiveRecord::Base.connection + + true + rescue + false + end end end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index 603d4d62578..f147ce8ad6b 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -9,9 +9,11 @@ require 'task_list/filter' # # Used by MergeRequest and Issue module Taskable - COMPLETED = 'completed'.freeze - INCOMPLETE = 'incomplete'.freeze - ITEM_PATTERN = %r{ + COMPLETED = 'completed'.freeze + INCOMPLETE = 'incomplete'.freeze + COMPLETE_PATTERN = /(\[[xX]\])/.freeze + INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze + ITEM_PATTERN = %r{ ^ \s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list \s+ # whitespace prefix has to be always presented for a list item diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb index d79c0eae77e..6c6febd186c 100644 --- a/app/models/concerns/with_uploads.rb +++ b/app/models/concerns/with_uploads.rb @@ -27,40 +27,14 @@ module WithUploads included do has_many :uploads, as: :model - has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, class_name: 'Upload', as: :model + has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, + class_name: 'Upload', as: :model, + dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent - # TODO: when feature flag is removed, we can use just dependent: destroy - # option on :file_uploads - before_destroy :remove_file_uploads - - use_fast_destroy :file_uploads, if: :fast_destroy_enabled? + use_fast_destroy :file_uploads end def retrieve_upload(_identifier, paths) uploads.find_by(path: paths) end - - private - - # mounted uploads are deleted in carrierwave's after_commit hook, - # but FileUploaders which are not mounted must be deleted explicitly and - # it can not be done in after_commit because FileUploader requires loads - # associated model on destroy (which is already deleted in after_commit) - def remove_file_uploads - fast_destroy_enabled? ? delete_uploads : destroy_uploads - end - - def delete_uploads - file_uploads.delete_all(:delete_all) - end - - def destroy_uploads - file_uploads.find_each do |upload| - upload.destroy - end - end - - def fast_destroy_enabled? - Feature.enabled?(:fast_destroy_uploads, self) - end end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 2c08a8e1acf..cf057d774cf 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ContainerRepository < ActiveRecord::Base + include Gitlab::Utils::StrongMemoize + belongs_to :project validates :name, length: { minimum: 0, allow_nil: false } @@ -8,6 +10,8 @@ class ContainerRepository < ActiveRecord::Base delegate :client, to: :registry + scope :ordered, -> { order(:name) } + # rubocop: disable CodeReuse/ServiceClass def registry @registry ||= begin @@ -39,11 +43,12 @@ class ContainerRepository < ActiveRecord::Base end def tags - return @tags if defined?(@tags) return [] unless manifest && manifest['tags'] - @tags = manifest['tags'].map do |tag| - ContainerRegistry::Tag.new(self, tag) + strong_memoize(:tags) do + manifest['tags'].sort.map do |tag| + ContainerRegistry::Tag.new(self, tag) + end end end diff --git a/app/models/dashboard_group_milestone.rb b/app/models/dashboard_group_milestone.rb index 9bcc95e35a5..74aa04ab7d0 100644 --- a/app/models/dashboard_group_milestone.rb +++ b/app/models/dashboard_group_milestone.rb @@ -11,11 +11,12 @@ class DashboardGroupMilestone < GlobalMilestone @group_name = milestone.group.full_name end - def self.build_collection(groups) - Milestone.of_groups(groups.select(:id)) + def self.build_collection(groups, params) + milestones = Milestone.of_groups(groups.select(:id)) .reorder_by_due_date_asc .order_by_name_asc .active - .map { |m| new(m) } + milestones = milestones.search_title(params[:search_title]) if params[:search_title].present? + milestones.map { |m| new(m) } end end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index dbc7b6e67be..32529ebf71d 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -116,6 +116,10 @@ class Discussion false end + def can_convert_to_discussion? + false + end + def new_discussion? notes.length == 1 end diff --git a/app/models/email.rb b/app/models/email.rb index b6a977dfa22..3ce6e792fa8 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -15,7 +15,7 @@ class Email < ActiveRecord::Base after_commit :update_invalid_gpg_signatures, if: -> { previous_changes.key?('confirmed_at') } devise :confirmable - self.reconfirmable = false # currently email can't be changed, no need to reconfirm + self.reconfirmable = false # currently email can't be changed, no need to reconfirm delegate :username, to: :user diff --git a/app/models/environment.rb b/app/models/environment.rb index cdfe3b7c023..87bdb52b58b 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -50,6 +50,14 @@ class Environment < ActiveRecord::Base end scope :in_review_folder, -> { where(environment_type: "review") } scope :for_name, -> (name) { where(name: name) } + + ## + # Search environments which have names like the given query. + # Do not set a large limit unless you've confirmed that it works on gitlab.com scale. + scope :for_name_like, -> (query, limit: 5) do + where('name LIKE ?', "#{sanitize_sql_like(query)}%").limit(limit) + end + scope :for_project, -> (project) { where(project_id: project) } scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) } @@ -70,6 +78,10 @@ class Environment < ActiveRecord::Base end end + def self.pluck_names + pluck(:name) + end + def predefined_variables Gitlab::Ci::Variables::Collection.new .append(key: 'CI_ENVIRONMENT_NAME', value: name) @@ -231,6 +243,10 @@ class Environment < ActiveRecord::Base self.environment_type || self.name end + def name_without_type + @name_without_type ||= name.delete_prefix("#{environment_type}/") + end + def deployment_platform strong_memoize(:deployment_platform) do project.deployment_platform(environment: self.name) diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 7f4947ba27a..57283a78ea9 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -8,9 +8,13 @@ module ErrorTracking belongs_to :project - validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true } + validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true - validate :validate_api_url_path + validates :api_url, presence: true, if: :enabled + + validate :validate_api_url_path, if: :enabled + + validates :token, presence: true, if: :enabled attr_encrypted :token, mode: :per_attribute_iv, @@ -19,6 +23,31 @@ module ErrorTracking after_save :clear_reactive_cache! + def project_name + super || project_name_from_slug + end + + def organization_name + super || organization_name_from_slug + end + + def project_slug + project_slug_from_api_url + end + + def organization_slug + organization_slug_from_api_url + end + + def self.build_api_url_from(api_host:, project_slug:, organization_slug:) + uri = Addressable::URI.parse("#{api_host}/api/0/projects/#{organization_slug}/#{project_slug}/") + uri.path = uri.path.squeeze('/') + + uri.to_s + rescue Addressable::URI::InvalidURIError + api_host + end + def sentry_client Sentry::Client.new(api_url, token) end @@ -29,15 +58,21 @@ module ErrorTracking def list_sentry_issues(opts = {}) with_reactive_cache('list_issues', opts.stringify_keys) do |result| - { issues: result } + result end end + def list_sentry_projects + { projects: sentry_client.list_projects } + end + def calculate_reactive_cache(request, opts) case request when 'list_issues' - sentry_client.list_issues(**opts.symbolize_keys) + { issues: sentry_client.list_issues(**opts.symbolize_keys) } end + rescue Sentry::Client::Error => e + { error: e.message } end # http://HOST/api/0/projects/ORG/PROJECT @@ -47,13 +82,53 @@ module ErrorTracking url.sub('api/0/projects/', '') end + def api_host + return if api_url.blank? + + # This returns http://example.com/ + Addressable::URI.join(api_url, '/').to_s + end + private + def project_name_from_slug + @project_name_from_slug ||= project_slug_from_api_url&.titleize + end + + def organization_name_from_slug + @organization_name_from_slug ||= organization_slug_from_api_url&.titleize + end + + def project_slug_from_api_url + extract_slug(:project) + end + + def organization_slug_from_api_url + extract_slug(:organization) + end + + def extract_slug(capture) + return if api_url.blank? + + begin + url = Addressable::URI.parse(api_url) + rescue Addressable::URI::InvalidURIError + return nil + end + + @slug_match ||= url.path.match(%r{^/api/0/projects/+(?<organization>[^/]+)/+(?<project>[^/|$]+)}) || {} + @slug_match[capture] + end + def validate_api_url_path - unless URI(api_url).path.starts_with?('/api/0/projects') - errors.add(:api_url, 'path needs to start with /api/0/projects') + return if api_url.blank? + + begin + unless Addressable::URI.parse(api_url).path.starts_with?('/api/0/projects') + errors.add(:api_url, 'path needs to start with /api/0/projects') + end + rescue Addressable::URI::InvalidURIError end - rescue URI::InvalidURIError end end end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 4e82f3fed27..fd17745b035 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -27,6 +27,7 @@ class GlobalMilestone items = Milestone.of_projects(projects) .reorder_by_due_date_asc .order_by_name_asc + items = items.search_title(params[:search_title]) if params[:search_title].present? Milestone.filter_by_state(items, params[:state]).map { |m| new(m) } end diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 0816778deae..7f9ff7bbda6 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class GpgSignature < ActiveRecord::Base +class GpgSignature < ApplicationRecord include ShaAttribute sha_attribute :commit_sha @@ -33,6 +33,11 @@ class GpgSignature < ActiveRecord::Base ) end + def self.safe_create!(attributes) + create_with(attributes) + .safe_find_or_create_by!(commit_sha: attributes[:commit_sha]) + end + def gpg_key=(model) case model when GpgKey diff --git a/app/models/group.rb b/app/models/group.rb index 22814e35ca8..52f503404af 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -98,7 +98,7 @@ class Group < Namespace def select_for_project_authorization if current_scope.joins_values.include?(:shared_projects) joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id') - .where('project_namespace.share_with_group_lock = ?', false) + .where('project_namespace.share_with_group_lock = ?', false) .select("projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level") else super diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb index a58537de319..97cb26c6ea9 100644 --- a/app/models/group_milestone.rb +++ b/app/models/group_milestone.rb @@ -5,9 +5,10 @@ class GroupMilestone < GlobalMilestone def self.build_collection(group, projects, params) params = - { state: params[:state] } + { state: params[:state], search_title: params[:search_title] } project_milestones = Milestone.of_projects(projects) + project_milestones = project_milestones.search_title(params[:search_title]) if params[:search_title].present? child_milestones = Milestone.filter_by_state(project_milestones, params[:state]) grouped_milestones = child_milestones.group_by(&:title) diff --git a/app/models/identity.rb b/app/models/identity.rb index d63dd432426..acdde4f296b 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -8,7 +8,7 @@ class Identity < ActiveRecord::Base validates :provider, presence: true validates :extern_uid, allow_blank: true, uniqueness: { scope: UniquenessScopes.scopes, case_sensitive: false } - validates :user_id, uniqueness: { scope: UniquenessScopes.scopes } + validates :user, uniqueness: { scope: UniquenessScopes.scopes } before_save :ensure_normalized_extern_uid, if: :extern_uid_changed? after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider? diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb index 07ee7470ea2..b4a661ae5b4 100644 --- a/app/models/individual_note_discussion.rb +++ b/app/models/individual_note_discussion.rb @@ -13,6 +13,18 @@ class IndividualNoteDiscussion < Discussion true end + def can_convert_to_discussion? + noteable.supports_replying_to_individual_notes? && Feature.enabled?(:reply_to_individual_notes) + end + + def convert_to_discussion!(save: false) + first_note.becomes!(Discussion.note_class).to_discussion.tap do + # Save needs to be called on first_note instead of the transformed note + # because of https://gitlab.com/gitlab-org/gitlab-ce/issues/57324 + first_note.save if save + end + end + def reply_attributes super.tap { |attrs| attrs.delete(:discussion_id) } end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index e7168d49db9..e75c6eb2331 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -66,6 +66,17 @@ class InternalId < ActiveRecord::Base InternalIdGenerator.new(subject, scope, usage, init).generate end + # Flushing records is generally safe in a sense that those + # records are going to be re-created when needed. + # + # A filter condition has to be provided to not accidentally flush + # records for all projects. + def flush_records!(filter) + raise ArgumentError, "filter cannot be empty" if filter.blank? + + where(filter).delete_all + end + def available? @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization end @@ -111,7 +122,7 @@ class InternalId < ActiveRecord::Base # Generates next internal id and returns it def generate - InternalId.transaction do + subject.transaction do # Create a record in internal_ids if one does not yet exist # and increment its last value # @@ -125,7 +136,7 @@ class InternalId < ActiveRecord::Base # # Note this will acquire a ROW SHARE lock on the InternalId record def track_greatest(new_value) - InternalId.transaction do + subject.transaction do (lookup || create_record).track_greatest_and_save!(new_value) end end @@ -148,7 +159,7 @@ class InternalId < ActiveRecord::Base # violation. We can safely roll-back the nested transaction and perform # a lookup instead to retrieve the record. def create_record - InternalId.transaction(requires_new: true) do + subject.transaction(requires_new: true) do InternalId.create!( **scope, usage: usage_value, diff --git a/app/models/issue.rb b/app/models/issue.rb index 5c4ecbfdf4e..0b46e949052 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -26,6 +26,8 @@ class Issue < ActiveRecord::Base DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze + SORTING_PREFERENCE_FIELD = :issues_sort + belongs_to :project belongs_to :moved_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' @@ -64,6 +66,7 @@ class Issue < ActiveRecord::Base scope :preload_associations, -> { preload(:labels, project: :namespace) } scope :public_only, -> { where(confidential: false) } + scope :confidential_only, -> { where(confidential: true) } after_save :expire_etag_cache after_save :ensure_metrics, unless: :imported? diff --git a/app/models/member.rb b/app/models/member.rb index 18667b2e986..8e071a8ff21 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -76,7 +76,7 @@ class Member < ActiveRecord::Base scope :maintainers, -> { active.where(access_level: MAINTAINER) } scope :masters, -> { maintainers } # @deprecated scope :owners, -> { active.where(access_level: OWNER) } - scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) } + scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) } scope :owners_and_masters, -> { owners_and_maintainers } # @deprecated scope :with_user, -> (user) { where(user: user) } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7206d858dae..ad4d5be48d4 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -21,6 +21,8 @@ class MergeRequest < ActiveRecord::Base self.reactive_cache_refresh_interval = 10.minutes self.reactive_cache_lifetime = 10.minutes + SORTING_PREFERENCE_FIELD = :merge_requests_sort + ignore_column :locked_at, :ref_fetched, :deleted_at @@ -69,7 +71,7 @@ class MergeRequest < ActiveRecord::Base serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize - after_create :ensure_merge_request_diff, unless: :importing? + after_create :ensure_merge_request_diff after_update :clear_memoized_shas after_update :reload_diff_if_branch_changed after_save :ensure_metrics @@ -284,12 +286,12 @@ class MergeRequest < ActiveRecord::Base work_in_progress?(title) ? title : "WIP: #{title}" end - def committers - @committers ||= commits.committers + def commit_authors + @commit_authors ||= commits.authors end def authors - User.from_union([committers, User.where(id: self.author_id)]) + User.from_union([commit_authors, User.where(id: self.author_id)]) end # Verifies if title has changed not taking into account WIP prefix @@ -762,6 +764,16 @@ class MergeRequest < ActiveRecord::Base true end + def mergeable_to_ref? + return false if merged? + return false if broken? + + # Given the `merge_ref_path` will have the same + # state the `target_branch` would have. Ideally + # we need to check if it can be merged to it. + project.repository.can_be_merged?(diff_head_sha, target_branch) + end + def ff_merge_possible? project.repository.ancestor?(target_branch_sha, diff_head_sha) end @@ -937,7 +949,7 @@ class MergeRequest < ActiveRecord::Base self.target_project.repository.branch_exists?(self.target_branch) end - def merge_commit_message(include_description: false) + def default_merge_commit_message(include_description: false) closes_issues_references = visible_closing_issues_for.map do |issue| issue.to_reference(target_project) end @@ -957,6 +969,13 @@ class MergeRequest < ActiveRecord::Base message.join("\n\n") end + # Returns the oldest multi-line commit message, or the MR title if none found + def default_squash_commit_message + strong_memoize(:default_squash_commit_message) do + commits.without_merge_commits.reverse.find(&:description?)&.safe_message || title + end + end + def reset_merge_when_pipeline_succeeds return unless merge_when_pipeline_succeeds? @@ -965,6 +984,7 @@ class MergeRequest < ActiveRecord::Base if merge_params merge_params.delete('should_remove_source_branch') merge_params.delete('commit_message') + merge_params.delete('squash_commit_message') end self.save @@ -1067,6 +1087,10 @@ class MergeRequest < ActiveRecord::Base "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head" end + def merge_ref_path + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/merge" + end + def in_locked_state begin lock_mr @@ -1312,7 +1336,7 @@ class MergeRequest < ActiveRecord::Base def base_pipeline @base_pipeline ||= project.ci_pipelines .order(id: :desc) - .find_by(sha: diff_base_sha) + .find_by(sha: diff_base_sha, ref: target_branch) end def discussions_rendered_on_frontend? diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index a3029a54604..351a662ae83 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -7,19 +7,23 @@ class MergeRequestDiff < ActiveRecord::Base include IgnorableColumn include EachBatch include Gitlab::Utils::StrongMemoize + include ObjectStorage::BackgroundMove # Don't display more than 100 commits at once COMMITS_SAFE_SIZE = 100 - ignore_column :st_commits, - :st_diffs - belongs_to :merge_request + manual_inverse_association :merge_request, :merge_request_diff - has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) } + has_many :merge_request_diff_files, + -> { order(:merge_request_diff_id, :relative_order) }, + inverse_of: :merge_request_diff + has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) } + validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true + state_machine :state, initial: :empty do event :clean do transition any => :without_files @@ -45,10 +49,14 @@ class MergeRequestDiff < ActiveRecord::Base scope :recent, -> { order(id: :desc).limit(100) } + mount_uploader :external_diff, ExternalDiffUploader + # All diff information is collected from repository after object is created. # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? + after_save :update_external_diff_store, if: :external_diff_changed? + def self.find_by_diff_refs(diff_refs) find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha) end @@ -241,10 +249,97 @@ class MergeRequestDiff < ActiveRecord::Base end end + # Carrierwave defines `write_uploader` dynamically on this class, so `super` + # does not work. Alias the carrierwave method so we can call it when needed + alias_method :carrierwave_write_uploader, :write_uploader + + # The `external_diff`, `external_diff_store`, and `stored_externally` + # columns were introduced in GitLab 11.8, but some background migration specs + # use factories that rely on current code with an old schema. Without these + # `has_attribute?` guards, they fail with a `MissingAttributeError`. + # + # For more details, see: https://gitlab.com/gitlab-org/gitlab-ce/issues/44990 + + def write_uploader(column, identifier) + carrierwave_write_uploader(column, identifier) if has_attribute?(column) + end + + def update_external_diff_store + update_column(:external_diff_store, external_diff.object_store) if + has_attribute?(:external_diff_store) + end + + def external_diff_changed? + super if has_attribute?(:external_diff) + end + + def stored_externally + super if has_attribute?(:stored_externally) + end + alias_method :stored_externally?, :stored_externally + + # If enabled, yields the external file containing the diff. Otherwise, yields + # nil. This method is not thread-safe, but it *is* re-entrant, which allows + # multiple merge_request_diff_files to load their data efficiently + def opening_external_diff + return yield(nil) unless stored_externally? + return yield(@external_diff_file) if @external_diff_file + + external_diff.open do |file| + begin + @external_diff_file = file + + yield(@external_diff_file) + ensure + @external_diff_file = nil + end + end + end + private def create_merge_request_diff_files(diffs) - rows = diffs.map.with_index do |diff, index| + rows = + if has_attribute?(:external_diff) && Gitlab.config.external_diffs.enabled + build_external_merge_request_diff_files(diffs) + else + build_merge_request_diff_files(diffs) + end + + # Faster inserts + Gitlab::Database.bulk_insert('merge_request_diff_files', rows) + end + + def build_external_merge_request_diff_files(diffs) + rows = build_merge_request_diff_files(diffs) + tempfile = build_external_diff_tempfile(rows) + + self.external_diff = tempfile + self.stored_externally = true + + rows + ensure + tempfile&.unlink + end + + def build_external_diff_tempfile(rows) + Tempfile.open(external_diff.filename) do |file| + rows.inject(0) do |offset, row| + data = row.delete(:diff) + row[:external_diff_offset] = offset + row[:external_diff_size] = data.size + + file.write(data) + + offset + data.size + end + + file + end + end + + def build_merge_request_diff_files(diffs) + diffs.map.with_index do |diff, index| diff_hash = diff.to_hash.merge( binary: false, merge_request_diff_id: self.id, @@ -261,18 +356,20 @@ class MergeRequestDiff < ActiveRecord::Base end end end - - Gitlab::Database.bulk_insert('merge_request_diff_files', rows) end def load_diffs(options) - collection = merge_request_diff_files + # Ensure all diff files operate on the same external diff file instance if + # present. This reduces file open/close overhead. + opening_external_diff do + collection = merge_request_diff_files - if paths = options[:paths] - collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths) - end + if paths = options[:paths] + collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths) + end - Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options) + Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options) + end end def load_commits diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index a9f110bec5c..e8d936e265c 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -4,7 +4,7 @@ class MergeRequestDiffFile < ActiveRecord::Base include Gitlab::EncodingHelper include DiffFile - belongs_to :merge_request_diff + belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files def utf8_diff return '' if diff.blank? @@ -13,6 +13,16 @@ class MergeRequestDiffFile < ActiveRecord::Base end def diff - binary? ? super.unpack('m0').first : super + content = + if merge_request_diff&.stored_externally? + merge_request_diff.opening_external_diff do |file| + file.seek(external_diff_offset) + file.read(external_diff_size) + end + else + super + end + + binary? ? content.unpack('m0').first : content end end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index b21edce3aad..26cfdc5ef30 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -28,7 +28,7 @@ class Milestone < ActiveRecord::Base has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) } has_many :issues - has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues + has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -77,7 +77,7 @@ class Milestone < ActiveRecord::Base alias_attribute :name, :title class << self - # Searches for milestones matching the given query. + # Searches for milestones with a matching title or description. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. # @@ -88,6 +88,17 @@ class Milestone < ActiveRecord::Base fuzzy_search(query, [:title, :description]) end + # Searches for milestones with a matching title. + # + # This method uses ILIKE on PostgreSQL and LIKE on MySQL. + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. + def search_title(query) + fuzzy_search(query, [:title]) + end + def filter_by_state(milestones, state) case state when 'closed' then milestones.closed diff --git a/app/models/namespace.rb b/app/models/namespace.rb index a0bebc5e9a2..f7592532c5b 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Namespace < ActiveRecord::Base +class Namespace < ApplicationRecord include CacheMarkdownField include Sortable include Gitlab::VisibilityLevel diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 6da3bb7bfb7..ecbeb24ee0a 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -40,9 +40,12 @@ module Network # Get commits from repository # def collect_commits - find_commits(count_to_display_commit_in_center).map do |commit| - # Decorate with app/model/network/commit.rb - Network::Commit.new(commit) + # https://gitlab.com/gitlab-org/gitlab-ce/issues/58013 + Gitlab::GitalyClient.allow_n_plus_1_calls do + find_commits(count_to_display_commit_in_center).map do |commit| + # Decorate with app/model/network/commit.rb + Network::Commit.new(commit) + end end end diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 9f16eefe074..481c1d963c6 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -153,7 +153,7 @@ class NotificationRecipient user.global_notification_setting end - # Returns the notificaton_setting of the lowest group in hierarchy with non global level + # Returns the notification_setting of the lowest group in hierarchy with non global level def closest_non_global_group_notification_settting return unless @group return if indexed_group_notification_settings.empty? diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 73a58f2420e..ed78a46eaf3 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -2,8 +2,11 @@ class PersonalAccessToken < ActiveRecord::Base include Expirable + include IgnorableColumn include TokenAuthenticatable - add_authentication_token_field :token, digest: true, fallback: true + + add_authentication_token_field :token, digest: true + ignore_column :token REDIS_EXPIRY_TIME = 3.minutes diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 34220c1b450..4635fc72dc7 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -96,7 +96,9 @@ class PoolRepository < ActiveRecord::Base @object_pool ||= Gitlab::Git::ObjectPool.new( shard.name, disk_path + '.git', - source_project.repository.raw) + source_project.repository.raw, + source_project.full_path + ) end def inspect diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb index 0e667dac21e..5f0f313b7f9 100644 --- a/app/models/programming_language.rb +++ b/app/models/programming_language.rb @@ -3,4 +3,10 @@ class ProgrammingLanguage < ActiveRecord::Base validates :name, presence: true validates :color, allow_blank: false, color: true + + # Returns all programming languages which match the given name (case + # insensitively). + scope :with_name_case_insensitive, ->(name) do + where(arel_table[:name].matches(sanitize_sql_like(name))) + end end diff --git a/app/models/project.rb b/app/models/project.rb index 1023b40a608..b016a65f0bb 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -147,7 +147,6 @@ class Project < ActiveRecord::Base has_one :pipelines_email_service has_one :irker_service has_one :pivotaltracker_service - has_one :hipchat_service has_one :flowdock_service has_one :assembla_service has_one :asana_service @@ -161,6 +160,7 @@ class Project < ActiveRecord::Base has_one :pushover_service has_one :jira_service has_one :redmine_service + has_one :youtrack_service has_one :custom_issue_tracker_service has_one :bugzilla_service has_one :gitlab_issue_tracker_service, inverse_of: :project @@ -249,10 +249,10 @@ class Project < ActiveRecord::Base has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :commit_statuses - # The relation :all_pipelines is intented to be used when we want to get the + # The relation :all_pipelines is intended to be used when we want to get the # whole list of pipelines associated to the project has_many :all_pipelines, class_name: 'Ci::Pipeline', inverse_of: :project - # The relation :ci_pipelines is intented to be used when we want to get only + # The relation :ci_pipelines is intended to be used when we want to get only # those pipeline which are directly related to CI. There are # other pipelines, like webide ones, that we won't retrieve # if we use this relation. @@ -306,6 +306,7 @@ class Project < ActiveRecord::Base delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings delegate :group_clusters_enabled?, to: :group, allow_nil: true delegate :root_ancestor, to: :namespace, allow_nil: true + delegate :last_pipeline, to: :commit, allow_nil: true # Validations validates :creator, presence: true, on: :create @@ -389,6 +390,16 @@ class Project < ActiveRecord::Base with_project_feature.where(project_features: { access_level_attribute => level }) } + # Picks projects which use the given programming language + scope :with_programming_language, ->(language_name) do + lang_id_query = ProgrammingLanguage + .with_name_case_insensitive(language_name) + .select(:id) + + joins(:repository_languages) + .where(repository_languages: { programming_language_id: lang_id_query }) + end + scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) } @@ -578,6 +589,22 @@ class Project < ActiveRecord::Base end end + def all_pipelines + if builds_enabled? + super + else + super.external + end + end + + def ci_pipelines + if builds_enabled? + super + else + super.external + end + end + # returns all ancestor-groups upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned def ancestors_upto(top = nil, hierarchy_order: nil) @@ -730,11 +757,13 @@ class Project < ActiveRecord::Base end def import_url=(value) - return super(value) unless Gitlab::UrlSanitizer.valid?(value) - - import_url = Gitlab::UrlSanitizer.new(value) - super(import_url.sanitized_url) - create_or_update_import_data(credentials: import_url.credentials) + if Gitlab::UrlSanitizer.valid?(value) + import_url = Gitlab::UrlSanitizer.new(value) + super(import_url.sanitized_url) + create_or_update_import_data(credentials: import_url.credentials) + else + super(value) + end end def import_url @@ -1058,7 +1087,7 @@ class Project < ActiveRecord::Base # rubocop: disable CodeReuse/ServiceClass def create_labels Label.templates.each do |label| - params = label.attributes.except('id', 'template', 'created_at', 'updated_at') + params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type') Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true) end end @@ -1187,7 +1216,7 @@ class Project < ActiveRecord::Base "#{web_url}.git" end - # Is overriden in EE + # Is overridden in EE def lfs_http_url_to_repo(_) http_url_to_repo end @@ -1268,7 +1297,7 @@ class Project < ActiveRecord::Base # Forked import is handled asynchronously return if forked? && !force - if gitlab_shell.create_repository(repository_storage, disk_path) + if gitlab_shell.create_project_repository(self) repository.after_create true else @@ -1594,6 +1623,13 @@ class Project < ActiveRecord::Base def after_import repository.after_import wiki.repository.after_import + + # The import assigns iid values on its own, e.g. by re-using GitHub ids. + # Flush existing InternalId records for this project for consistency reasons. + # Those records are going to be recreated with the next normal creation + # of a model instance (e.g. an Issue). + InternalId.flush_records!(project: self) + import_state.finish import_state.remove_jid update_project_counter_caches @@ -1698,11 +1734,19 @@ class Project < ActiveRecord::Base .append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path) .append(key: 'CI_PROJECT_URL', value: web_url) .append(key: 'CI_PROJECT_VISIBILITY', value: visibility) + .concat(pages_variables) .concat(container_registry_variables) .concat(auto_devops_variables) .concat(api_variables) end + def pages_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables.append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host) + variables.append(key: 'CI_PAGES_URL', value: pages_url) + end + end + def api_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_API_V4_URL', value: API::Helpers::Version.new('v4').root_url) @@ -1795,7 +1839,7 @@ class Project < ActiveRecord::Base # Set repository as writable again def set_repository_writable! with_lock do - update_column(repository_read_only, false) + update_column(:repository_read_only, false) end end @@ -1882,6 +1926,14 @@ class Project < ActiveRecord::Base persisted? && path_changed? end + def human_merge_method + if merge_method == :ff + 'Fast-forward' + else + merge_method.to_s.humanize + end + end + def merge_method if self.merge_requests_ff_only_enabled :ff @@ -2039,6 +2091,10 @@ class Project < ActiveRecord::Base pool_repository&.link_repository(repository) end + def has_pool_repository? + pool_repository.present? + end + private def merge_requests_allowing_collaboration(source_branch = nil) diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index 2253ad7b543..e353a6443c4 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -24,6 +24,12 @@ class ProjectAutoDevops < ActiveRecord::Base domain.present? || instance_domain.present? end + # From 11.8, AUTO_DEVOPS_DOMAIN has been replaced by KUBE_INGRESS_BASE_DOMAIN. + # See Clusters::Cluster#predefined_variables and https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580 + # for more info. + # + # Suppport AUTO_DEVOPS_DOMAIN is scheduled to be removed on + # https://gitlab.com/gitlab-org/gitlab-ce/issues/52363 def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| if has_domain? diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index a252052200a..71f5607dbdb 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -80,19 +80,27 @@ class BambooService < CiService private - def get_build_result_index - # When Bamboo returns multiple results for a given changeset, arbitrarily assume the most relevant result to be the last one. - -1 + def get_build_result(response) + return if response.code != 200 + + # May be nil if no result, a single result hash, or an array if multiple results for a given changeset. + result = response.dig('results', 'results', 'result') + + # In case of multiple results, arbitrarily assume the last one is the most relevant. + return result.last if result.is_a?(Array) + + result end def read_build_page(response) + result = get_build_result(response) key = - if response.code != 200 || response.dig('results', 'results', 'size') == '0' + if result.blank? # If actual build link can't be determined, send user to build summary page. build_key else # If actual build link is available, go to build result page. - response.dig('results', 'results', 'result', get_build_result_index, 'planResultKey', 'key') + result.dig('planResultKey', 'key') end build_url("browse/#{key}") @@ -101,11 +109,15 @@ class BambooService < CiService def read_commit_status(response) return :error unless response.code == 200 || response.code == 404 - status = if response.code == 404 || response.dig('results', 'results', 'size') == '0' - 'Pending' - else - response.dig('results', 'results', 'result', get_build_result_index, 'buildState') - end + result = get_build_result(response) + status = + if result.blank? + 'Pending' + else + result.dig('buildState') + end + + return :error unless status.present? if status.include?('Success') 'success' diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb index 6dae4f3a4a6..80aa2101509 100644 --- a/app/models/project_services/deployment_service.rb +++ b/app/models/project_services/deployment_service.rb @@ -11,7 +11,7 @@ class DeploymentService < Service %w() end - def predefined_variables + def predefined_variables(project:) [] end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb deleted file mode 100644 index a69b7b4c4b6..00000000000 --- a/app/models/project_services/hipchat_service.rb +++ /dev/null @@ -1,311 +0,0 @@ -# frozen_string_literal: true - -class HipchatService < Service - include ActionView::Helpers::SanitizeHelper - - MAX_COMMITS = 3 - HIPCHAT_ALLOWED_TAGS = %w[ - a b i strong em br img pre code - table th tr td caption colgroup col thead tbody tfoot - ul ol li dl dt dd - ].freeze - - prop_accessor :token, :room, :server, :color, :api_version - boolean_accessor :notify_only_broken_pipelines, :notify - validates :token, presence: true, if: :activated? - - def initialize_properties - if properties.nil? - self.properties = {} - self.notify_only_broken_pipelines = true - end - end - - def title - 'HipChat' - end - - def description - 'Private group chat and IM' - end - - def self.to_param - 'hipchat' - end - - def fields - [ - { type: 'text', name: 'token', placeholder: 'Room token', required: true }, - { type: 'text', name: 'room', placeholder: 'Room name or ID' }, - { type: 'checkbox', name: 'notify' }, - { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) }, - { type: 'text', name: 'api_version', - placeholder: 'Leave blank for default (v2)' }, - { type: 'text', name: 'server', - placeholder: 'Leave blank for default. https://hipchat.example.com' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' } - ] - end - - def self.supported_events - %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - message = create_message(data) - return unless message.present? - - gate[room].send('GitLab', message, message_options(data)) # rubocop:disable GitlabSecurity/PublicSend - end - - def test(data) - begin - result = execute(data) - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result } - end - - private - - def gate - options = { api_version: api_version.present? ? api_version : 'v2' } - options[:server_url] = server unless server.blank? - @gate ||= HipChat::Client.new(token, options) - end - - def message_options(data = nil) - { notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) } - end - - def create_message(data) - object_kind = data[:object_kind] - - case object_kind - when "push", "tag_push" - create_push_message(data) - when "issue" - create_issue_message(data) unless update?(data) - when "merge_request" - create_merge_request_message(data) unless update?(data) - when "note" - create_note_message(data) - when "pipeline" - create_pipeline_message(data) if should_pipeline_be_notified?(data) - end - end - - def render_line(text) - markdown(text.lines.first.chomp, pipeline: :single_line) if text - end - - def create_push_message(push) - ref_type = Gitlab::Git.tag_ref?(push[:ref]) ? 'tag' : 'branch' - ref = Gitlab::Git.ref_name(push[:ref]) - - before = push[:before] - after = push[:after] - - message = [] - message << "#{push[:user_name]} " - - if Gitlab::Git.blank_ref?(before) - message << "pushed new #{ref_type} <a href=\""\ - "#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"\ - " to #{project_link}\n" - elsif Gitlab::Git.blank_ref?(after) - message << "removed #{ref_type} <b>#{ref}</b> from <a href=\"#{project.web_url}\">#{project_name}</a> \n" - else - message << "pushed to #{ref_type} <a href=\""\ - "#{project.web_url}/commits/#{CGI.escape(ref)}\">#{ref}</a> " - message << "of <a href=\"#{project.web_url}\">#{project.full_name.gsub!(/\s/, '')}</a> " - message << "(<a href=\"#{project.web_url}/compare/#{before}...#{after}\">Compare changes</a>)" - - push[:commits].take(MAX_COMMITS).each do |commit| - message << "<br /> - #{render_line(commit[:message])} (<a href=\"#{commit[:url]}\">#{commit[:id][0..5]}</a>)" - end - - if push[:commits].count > MAX_COMMITS - message << "<br />... #{push[:commits].count - MAX_COMMITS} more commits" - end - end - - message.join - end - - def markdown(text, options = {}) - return "" unless text - - context = { - project: project, - pipeline: :email - } - - Banzai.render(text, context) - - context.merge!(options) - - html = Banzai.render_and_post_process(text, context) - sanitized_html = sanitize(html, tags: HIPCHAT_ALLOWED_TAGS, attributes: %w[href title alt]) - - sanitized_html.truncate(200, separator: ' ', omission: '...') - end - - def create_issue_message(data) - user_name = data[:user][:name] - - obj_attr = data[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - title = render_line(obj_attr[:title]) - state = obj_attr[:state] - issue_iid = obj_attr[:iid] - issue_url = obj_attr[:url] - description = obj_attr[:description] - - issue_link = "<a href=\"#{issue_url}\">issue ##{issue_iid}</a>" - - message = ["#{user_name} #{state} #{issue_link} in #{project_link}: <b>#{title}</b>"] - message << "<pre>#{markdown(description)}</pre>" - - message.join - end - - def create_merge_request_message(data) - user_name = data[:user][:name] - - obj_attr = data[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - merge_request_id = obj_attr[:iid] - state = obj_attr[:state] - description = obj_attr[:description] - title = render_line(obj_attr[:title]) - - merge_request_url = "#{project_url}/merge_requests/#{merge_request_id}" - merge_request_link = "<a href=\"#{merge_request_url}\">merge request !#{merge_request_id}</a>" - message = ["#{user_name} #{state} #{merge_request_link} in " \ - "#{project_link}: <b>#{title}</b>"] - - message << "<pre>#{markdown(description)}</pre>" - message.join - end - - def format_title(title) - "<b>#{render_line(title)}</b>" - end - - def create_note_message(data) - data = HashWithIndifferentAccess.new(data) - user_name = data[:user][:name] - - obj_attr = HashWithIndifferentAccess.new(data[:object_attributes]) - note = obj_attr[:note] - note_url = obj_attr[:url] - noteable_type = obj_attr[:noteable_type] - commit_id = nil - - case noteable_type - when "Commit" - commit_attr = HashWithIndifferentAccess.new(data[:commit]) - commit_id = commit_attr[:id] - subject_desc = commit_id - subject_desc = Commit.truncate_sha(subject_desc) - subject_type = "commit" - title = format_title(commit_attr[:message]) - when "Issue" - subj_attr = HashWithIndifferentAccess.new(data[:issue]) - subject_id = subj_attr[:iid] - subject_desc = "##{subject_id}" - subject_type = "issue" - title = format_title(subj_attr[:title]) - when "MergeRequest" - subj_attr = HashWithIndifferentAccess.new(data[:merge_request]) - subject_id = subj_attr[:iid] - subject_desc = "!#{subject_id}" - subject_type = "merge request" - title = format_title(subj_attr[:title]) - when "Snippet" - subj_attr = HashWithIndifferentAccess.new(data[:snippet]) - subject_id = subj_attr[:id] - subject_desc = "##{subject_id}" - subject_type = "snippet" - title = format_title(subj_attr[:title]) - end - - subject_html = "<a href=\"#{note_url}\">#{subject_type} #{subject_desc}</a>" - message = ["#{user_name} commented on #{subject_html} in #{project_link}: "] - message << title - - message << "<pre>#{markdown(note, ref: commit_id)}</pre>" - message.join - end - - def create_pipeline_message(data) - pipeline_attributes = data[:object_attributes] - pipeline_id = pipeline_attributes[:id] - ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' - ref = pipeline_attributes[:ref] - user_name = (data[:user] && data[:user][:name]) || 'API' - status = pipeline_attributes[:status] - duration = pipeline_attributes[:duration] - - branch_link = "<a href=\"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>" - pipeline_url = "<a href=\"#{project_url}/pipelines/#{pipeline_id}\">##{pipeline_id}</a>" - - "#{project_link}: Pipeline #{pipeline_url} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)" - end - - def message_color(data) - pipeline_status_color(data) || color || 'yellow' - end - - def pipeline_status_color(data) - return unless data && data[:object_kind] == 'pipeline' - - case data[:object_attributes][:status] - when 'success' - 'green' - else - 'red' - end - end - - def project_name - project.full_name.gsub(/\s/, '') - end - - def project_url - project.web_url - end - - def project_link - "<a href=\"#{project_url}\">#{project_name}</a>" - end - - def update?(data) - data[:object_attributes][:action] == 'update' - end - - def humanized_status(status) - case status - when 'success' - 'passed' - else - status - end - end - - def should_pipeline_be_notified?(data) - case data[:object_attributes][:status] - when 'success' - !notify_only_broken_pipelines? - when 'failed' - true - else - false - end - end -end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index a15780c14f9..83fd9a34438 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -59,7 +59,7 @@ class IrkerService < Service ' append "?key=secretpassword" to the URI (Note that due to a bug, if you ' \ ' want to use a password, you have to omit the "#" on the channel). If you ' \ ' specify a default IRC URI to prepend before each recipient, you can just ' \ - ' give a channel name.' }, + ' give a channel name.' }, { type: 'checkbox', name: 'colorize_messages' } ] end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 60cb2d380d5..c68a9d923c8 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -71,7 +71,7 @@ class PrometheusService < MonitoringService end def prometheus_client - RestClient::Resource.new(api_url, max_redirects: 0) if api_url && manual_configuration? && active? + RestClient::Resource.new(api_url, max_redirects: 0) if should_return_client? end def prometheus_available? @@ -83,6 +83,10 @@ class PrometheusService < MonitoringService private + def should_return_client? + api_url && manual_configuration? && active? && valid? + end + def synchronize_service_state self.active = prometheus_available? || manual_configuration? diff --git a/app/models/project_services/slack_slash_commands_service.rb b/app/models/project_services/slack_slash_commands_service.rb index 6c82e088231..6a454070fe2 100644 --- a/app/models/project_services/slack_slash_commands_service.rb +++ b/app/models/project_services/slack_slash_commands_service.rb @@ -22,6 +22,10 @@ class SlackSlashCommandsService < SlashCommandsService end end + def chat_responder + ::Gitlab::Chat::Responder::Slack + end + private def format(text) diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb new file mode 100644 index 00000000000..957be685aea --- /dev/null +++ b/app/models/project_services/youtrack_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class YoutrackService < IssueTrackerService + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? + + prop_accessor :description, :project_url, :issues_url + + # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1 + def self.reference_pattern(only_long: false) + if only_long + /(?<issue>\b[A-Z][A-Za-z0-9_]*-\d+)/ + else + /(?<issue>\b[A-Z][A-Za-z0-9_]*-\d+)|(#{Issue.reference_prefix}(?<issue>\d+))/ + end + end + + def title + 'YouTrack' + end + + def description + if self.properties && self.properties['description'].present? + self.properties['description'] + else + 'YouTrack issue tracker' + end + end + + def self.to_param + 'youtrack' + end + + def fields + [ + { type: 'text', name: 'description', placeholder: description }, + { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, + { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true } + ] + end +end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 559e4f99294..c43bd45a62f 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -60,7 +60,7 @@ class ProjectWiki def wiki @wiki ||= begin gl_repository = Gitlab::GlRepository.gl_repository(project, true) - raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository) + raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path) create_repo!(raw_repository) unless raw_repository.exists? @@ -175,7 +175,7 @@ class ProjectWiki private def create_repo!(raw_repository) - gitlab_shell.create_repository(project.repository_storage, disk_path) + gitlab_shell.create_wiki_repository(project) raise CouldNotCreateWikiError unless raw_repository.exists? diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb index 6f639e5a7b2..6c507c47752 100644 --- a/app/models/releases/link.rb +++ b/app/models/releases/link.rb @@ -6,7 +6,7 @@ module Releases belongs_to :release - validates :url, presence: true, url: true, uniqueness: { scope: :release } + validates :url, presence: true, url: { protocols: %w(http https ftp) }, uniqueness: { scope: :release } validates :name, presence: true, uniqueness: { scope: :release } scope :sorted, -> { order(created_at: :desc) } diff --git a/app/models/repository.rb b/app/models/repository.rb index b47238b52f1..cd761a29618 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -288,13 +288,16 @@ class Repository # Rugged seems to throw a `ReferenceError` when given branch_names rather # than SHA-1 hashes number_commits_behind, number_commits_ahead = - raw_repository.count_commits_between( + raw_repository.diverging_commit_count( @root_ref_hash, branch.dereferenced_target.sha, - left_right: true, max_count: MAX_DIVERGING_COUNT) - { behind: number_commits_behind, ahead: number_commits_ahead } + if number_commits_behind + number_commits_ahead >= MAX_DIVERGING_COUNT + { distance: MAX_DIVERGING_COUNT } + else + { behind: number_commits_behind, ahead: number_commits_ahead } + end end end @@ -525,6 +528,8 @@ class Repository # items is an Array like: [[oid, path], [oid1, path1]] def blobs_at(items) + return [] unless exists? + raw_repository.batch_blobs(items).map { |blob| Blob.decorate(blob, project) } end @@ -612,7 +617,6 @@ class Repository return unless readme context = { project: project } - context[:markdown_engine] = :redcarpet unless MarkupHelper.commonmark_for_repositories_enabled? MarkupHelper.markup_unsafe(readme.name, readme.data, context) end @@ -850,6 +854,12 @@ class Repository end end + def merge_to_ref(user, source_sha, merge_request, target_ref, message) + branch = merge_request.target_branch + + raw.merge_to_ref(user, source_sha, branch, target_ref, message) + end + def ff_merge(user, source, target_branch, merge_request: nil) their_commit_id = commit(source)&.id raise 'Invalid merge source' if their_commit_id.nil? @@ -1029,12 +1039,12 @@ class Repository remote_branch: merge_request.target_branch) end - def squash(user, merge_request) + def squash(user, merge_request, message) raw.squash(user, merge_request.id, branch: merge_request.target_branch, start_sha: merge_request.diff_start_sha, end_sha: merge_request.diff_head_sha, author: merge_request.author, - message: merge_request.title) + message: message) end def update_submodule(user, submodule, commit_sha, message:, branch:) @@ -1103,6 +1113,9 @@ class Repository end def initialize_raw_repository - Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, is_wiki)) + Gitlab::Git::Repository.new(project.repository_storage, + disk_path + '.git', + Gitlab::GlRepository.gl_repository(project, is_wiki), + project.full_path) end end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index e65b3df0fb6..6caab24143b 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -48,7 +48,7 @@ class SentNotification < ActiveRecord::Base end def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {}) - attrs[:in_reply_to_discussion_id] = note.discussion_id + attrs[:in_reply_to_discussion_id] = note.discussion_id if note.part_of_discussion? record(note.noteable, recipient_id, reply_key, attrs) end @@ -99,29 +99,12 @@ class SentNotification < ActiveRecord::Base private def reply_params - attrs = { + { noteable_type: self.noteable_type, noteable_id: self.noteable_id, - commit_id: self.commit_id + commit_id: self.commit_id, + in_reply_to_discussion_id: self.in_reply_to_discussion_id } - - if self.in_reply_to_discussion_id.present? - attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id - else - # Remove in GitLab 10.0, when we will not support replying to SentNotifications - # that don't have `in_reply_to_discussion_id` anymore. - attrs.merge!( - type: self.note_type, - - # LegacyDiffNote - line_code: self.line_code, - - # DiffNote - position: self.position.to_json - ) - end - - attrs end def note_valid diff --git a/app/models/service.rb b/app/models/service.rb index 9dcb0aab0a3..da523bfa426 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -255,7 +255,6 @@ class Service < ActiveRecord::Base external_wiki flowdock hangouts_chat - hipchat irker jira kubernetes @@ -267,6 +266,7 @@ class Service < ActiveRecord::Base prometheus pushover redmine + youtrack slack_slash_commands slack teamcity diff --git a/app/models/snippet.rb b/app/models/snippet.rb index f9b23bbbf6c..f23ddd64fe3 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -50,11 +50,11 @@ class Snippet < ActiveRecord::Base validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values } # Scopes - scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) } + scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) } scope :are_private, -> { where(visibility_level: Snippet::PRIVATE) } scope :are_public, -> { where(visibility_level: Snippet::PUBLIC) } scope :public_and_internal, -> { where(visibility_level: [Snippet::PUBLIC, Snippet::INTERNAL]) } - scope :fresh, -> { order("created_at DESC") } + scope :fresh, -> { order("created_at DESC") } scope :inc_relations_for_view, -> { includes(author: :status) } participant :author diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index 99a0c54a26a..fd23cc9ac87 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -26,6 +26,7 @@ class SshHostKey self.reactive_cache_lifetime = 10.minutes def self.find_by(opts = {}) + opts = HashWithIndifferentAccess.new(opts) return nil unless opts.key?(:id) project_id, url = opts[:id].split(':', 2) @@ -54,7 +55,7 @@ class SshHostKey # Needed for reactive caching def self.primary_key - 'id' + :id end def id diff --git a/app/models/user.rb b/app/models/user.rb index 262b1835e98..ee51c35d576 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,7 +2,7 @@ require 'carrierwave/orm/activerecord' -class User < ActiveRecord::Base +class User < ApplicationRecord extend Gitlab::ConfigHelper include Gitlab::ConfigHelper @@ -145,7 +145,7 @@ class User < ActiveRecord::Base has_many :issue_assignees has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue - has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent + has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'UserCallout' @@ -228,6 +228,9 @@ class User < ActiveRecord::Base delegate :path, to: :namespace, allow_nil: true, prefix: true delegate :notes_filter_for, to: :user_preference delegate :set_notes_filter, to: :user_preference + delegate :first_day_of_week, :first_day_of_week=, to: :user_preference + + accepts_nested_attributes_for :user_preference, update_only: true state_machine :state, initial: :active do event :block do @@ -267,9 +270,12 @@ class User < ActiveRecord::Base scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } + scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) } + scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) } scope :confirmed, -> { where.not(confirmed_at: nil) } scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) } scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) } + scope :with_emails, -> { preload(:emails) } # Limits the users to those that have TODOs, optionally in the given state. # @@ -337,6 +343,8 @@ class User < ActiveRecord::Base case order_method.to_s when 'recent_sign_in' then order_recent_sign_in when 'oldest_sign_in' then order_oldest_sign_in + when 'last_activity_on_desc' then order_recent_last_activity + when 'last_activity_on_asc' then order_oldest_last_activity else order_by(order_method) end @@ -380,7 +388,7 @@ class User < ActiveRecord::Base find_by(id: user_id) end - def filter(filter_name) + def filter_items(filter_name) case filter_name when 'admins' admins @@ -1160,6 +1168,10 @@ class User < ActiveRecord::Base Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants end + def manageable_groups_with_routes + manageable_groups.eager_load(:route).order('routes.path') + end + def namespaces namespace_ids = groups.pluck(:id) namespace_ids.push(namespace.id) diff --git a/app/policies/board_policy.rb b/app/policies/board_policy.rb index 46db008421f..4bf1e7bd3e1 100644 --- a/app/policies/board_policy.rb +++ b/app/policies/board_policy.rb @@ -4,10 +4,12 @@ class BoardPolicy < BasePolicy delegate { @subject.parent } condition(:is_group_board) { @subject.group_board? } + condition(:is_project_board) { @subject.project_board? } - rule { is_group_board ? can?(:read_group) : can?(:read_project) }.enable :read_parent + rule { is_project_board & can?(:read_project) }.enable :read_parent rule { is_group_board & can?(:read_group) }.policy do + enable :read_parent enable :read_milestone enable :read_issue end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index e42d78f47c5..2c90b8a73cd 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -10,6 +10,15 @@ module Ci @subject.project.branch_allows_collaboration?(@user, @subject.ref) end + condition(:external_pipeline, scope: :subject, score: 0) do + @subject.external? + end + + # Disallow users without permissions from accessing internal pipelines + rule { ~can?(:read_build) & ~external_pipeline }.policy do + prevent :read_pipeline + end + rule { protected_ref }.prevent :update_pipeline rule { can?(:public_access) & branch_allows_collaboration }.policy do diff --git a/app/policies/container_repository_policy.rb b/app/policies/container_repository_policy.rb new file mode 100644 index 00000000000..6781c845142 --- /dev/null +++ b/app/policies/container_repository_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ContainerRepositoryPolicy < BasePolicy + delegate { @subject.project } +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index c25766a5af8..55d8da78dc8 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -53,7 +53,6 @@ class GroupPolicy < BasePolicy rule { admin }.enable :read_group rule { has_projects }.policy do - enable :read_group enable :read_label end diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index 16e67a06d56..2b5cca76c20 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -32,4 +32,6 @@ class PersonalSnippetPolicy < BasePolicy enable :create_note enable :award_emoji end + + rule { full_private_access }.enable :read_personal_snippet end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 95ae85ed60c..9ed868f4f2e 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -108,6 +108,10 @@ class ProjectPolicy < BasePolicy condition(:has_clusters, scope: :subject) { clusterable_has_clusters? } condition(:can_have_multiple_clusters) { multiple_clusters_available? } + condition(:internal_builds_disabled) do + !@subject.builds_enabled? + end + features = %w[ merge_requests issues @@ -148,7 +152,6 @@ class ProjectPolicy < BasePolicy enable :remove_fork_project enable :destroy_merge_request enable :destroy_issue - enable :remove_pages enable :set_issue_iid enable :set_issue_created_at @@ -196,7 +199,6 @@ class ProjectPolicy < BasePolicy enable :read_build enable :read_container_image enable :read_pipeline - enable :read_pipeline_schedule enable :read_environment enable :read_deployment enable :read_merge_request @@ -235,6 +237,7 @@ class ProjectPolicy < BasePolicy enable :update_build enable :create_pipeline enable :update_pipeline + enable :read_pipeline_schedule enable :create_pipeline_schedule enable :create_merge_request_from enable :create_wiki @@ -267,6 +270,7 @@ class ProjectPolicy < BasePolicy enable :admin_pages enable :read_pages enable :update_pages + enable :remove_pages enable :read_cluster enable :add_cluster enable :create_cluster @@ -295,6 +299,8 @@ class ProjectPolicy < BasePolicy rule { issues_disabled }.policy do prevent(*create_read_update_admin_destroy(:issue)) + prevent(*create_read_update_admin_destroy(:board)) + prevent(*create_read_update_admin_destroy(:list)) end rule { merge_requests_disabled | repository_disabled }.policy do @@ -320,7 +326,6 @@ class ProjectPolicy < BasePolicy end rule { builds_disabled | repository_disabled }.policy do - prevent(*create_update_admin_destroy(:pipeline)) prevent(*create_read_update_admin_destroy(:build)) prevent(*create_read_update_admin_destroy(:pipeline_schedule)) prevent(*create_read_update_admin_destroy(:environment)) @@ -328,11 +333,22 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:deployment)) end + # There's two separate cases when builds_disabled is true: + # 1. When internal CI is disabled - builds_disabled && internal_builds_disabled + # - We do not prevent the user from accessing Pipelines to allow him to access external CI + # 2. When the user is not allowed to access CI - builds_disabled && ~internal_builds_disabled + # - We prevent the user from accessing Pipelines + rule { (builds_disabled & ~internal_builds_disabled) | repository_disabled }.policy do + prevent(*create_read_update_admin_destroy(:pipeline)) + prevent(*create_read_update_admin_destroy(:commit_status)) + end + rule { repository_disabled }.policy do prevent :push_code prevent :download_code prevent :fork_project prevent :read_commit_status + prevent :read_pipeline prevent(*create_read_update_admin_destroy(:release)) end @@ -359,7 +375,6 @@ class ProjectPolicy < BasePolicy enable :read_merge_request enable :read_note enable :read_pipeline - enable :read_pipeline_schedule enable :read_commit_status enable :read_container_image enable :download_code @@ -378,7 +393,6 @@ class ProjectPolicy < BasePolicy rule { public_builds & can?(:guest_access) }.policy do enable :read_pipeline - enable :read_pipeline_schedule end # These rules are included to allow maintainers of projects to push to certain diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 300f85e1e9d..d60281c8a0b 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -2,6 +2,11 @@ module Ci class BuildRunnerPresenter < SimpleDelegator + include Gitlab::Utils::StrongMemoize + + RUNNER_REMOTE_TAG_PREFIX = 'refs/tags/'.freeze + RUNNER_REMOTE_BRANCH_PREFIX = 'refs/remotes/origin/'.freeze + def artifacts return unless options[:artifacts] @@ -11,6 +16,35 @@ module Ci list.flatten.compact end + def ref_type + if tag + 'tag' + else + 'branch' + end + end + + def git_depth + strong_memoize(:git_depth) do + git_depth = variables&.find { |variable| variable[:key] == 'GIT_DEPTH' }&.dig(:value) + git_depth.to_i + end + end + + def refspecs + specs = [] + + if git_depth > 0 + specs << refspec_for_branch(ref) if branch? || merge_request? + specs << refspec_for_tag(ref) if tag? + else + specs << refspec_for_branch + specs << refspec_for_tag + end + + specs + end + private def create_archive(artifacts) @@ -41,5 +75,13 @@ module Ci } end end + + def refspec_for_branch(ref = '*') + "+#{Gitlab::Git::BRANCH_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_BRANCH_PREFIX}#{ref}" + end + + def refspec_for_tag(ref = '*') + "+#{Gitlab::Git::TAG_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_TAG_PREFIX}#{ref}" + end end end diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb new file mode 100644 index 00000000000..05adbe1d4f5 --- /dev/null +++ b/app/presenters/commit_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CommitPresenter < Gitlab::View::Presenter::Simple + presents :commit + + def status_for(ref) + can?(current_user, :read_commit_status, commit.project) && commit.status(ref) + end + + def any_pipelines? + can?(current_user, :read_pipeline, commit.project) && commit.pipelines.any? + end +end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 44b6ca299ae..c59e73f824c 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -170,6 +170,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated source_branch_exists? && merge_request.can_remove_source_branch?(current_user) end + def can_read_pipeline? + pipeline && can?(current_user, :read_pipeline, pipeline) + end + def mergeable_discussions_state # This avoids calling MergeRequest#mergeable_discussions_state without # considering the state of the MR first. If a MR isn't mergeable, we can diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index ea1d941cf83..4cac90c2567 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -256,7 +256,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated elsif repository.contribution_guide.present? AnchorData.new(false, statistic_icon('doc-text') + _('CONTRIBUTING'), - contribution_guide_path) + contribution_guide_path, + 'default') end end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 7b1a0be75ca..02df1480828 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -4,7 +4,9 @@ class ClusterApplicationEntity < Grape::Entity expose :name expose :status_name, as: :status expose :status_reason + expose :version expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } + expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } end diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb index 59bf35f5aff..cc746698a05 100644 --- a/app/serializers/container_repository_entity.rb +++ b/app/serializers/container_repository_entity.rb @@ -3,7 +3,7 @@ class ContainerRepositoryEntity < Grape::Entity include RequestAwareEntity - expose :id, :path, :location + expose :id, :name, :path, :location, :created_at expose :tags_path do |repository| project_registry_repository_tags_path(project, repository, format: :json) diff --git a/app/serializers/container_tag_entity.rb b/app/serializers/container_tag_entity.rb index 637294877f8..361c073e22e 100644 --- a/app/serializers/container_tag_entity.rb +++ b/app/serializers/container_tag_entity.rb @@ -3,7 +3,7 @@ class ContainerTagEntity < Grape::Entity include RequestAwareEntity - expose :name, :location, :revision, :short_revision, :total_size, :created_at + expose :name, :path, :location, :digest, :revision, :short_revision, :total_size, :created_at expose :destroy_path, if: -> (*) { can_destroy? } do |tag| project_registry_repository_tag_path(project, tag.repository, tag.name) diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index aa1d9e6292c..34ae06278c8 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -24,6 +24,12 @@ class DeploymentEntity < Grape::Entity expose :user, using: UserEntity expose :commit, using: CommitEntity expose :deployable, using: JobEntity - expose :manual_actions, using: JobEntity - expose :scheduled_actions, using: JobEntity + expose :manual_actions, using: JobEntity, if: -> (*) { can_create_deployment? } + expose :scheduled_actions, using: JobEntity, if: -> (*) { can_create_deployment? } + + private + + def can_create_deployment? + can?(request.current_user, :create_deployment, request.project) + end end diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb index 06a8db78476..ede9e04b722 100644 --- a/app/serializers/diff_file_base_entity.rb +++ b/app/serializers/diff_file_base_entity.rb @@ -72,17 +72,20 @@ class DiffFileBaseEntity < Grape::Entity expose :old_path expose :new_path expose :new_file?, as: :new_file - expose :collapsed?, as: :collapsed - expose :text?, as: :text + expose :renamed_file?, as: :renamed_file + expose :deleted_file?, as: :deleted_file + expose :diff_refs + expose :stored_externally?, as: :stored_externally expose :external_storage - expose :renamed_file?, as: :renamed_file - expose :deleted_file?, as: :deleted_file + expose :mode_changed?, as: :mode_changed expose :a_mode expose :b_mode + expose :viewer, using: DiffViewerEntity + private def memoized_submodule_links(diff_file) diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index b0aaec3326d..01ee7af37ed 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -4,12 +4,10 @@ class DiffFileEntity < DiffFileBaseEntity include CommitsHelper include IconsHelper - expose :too_large?, as: :too_large - expose :empty?, as: :empty expose :added_lines expose :removed_lines - expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.text? && options[:merge_request] } do |diff_file| + expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.viewer.collapsed? && options[:merge_request] } do |diff_file| merge_request = options[:merge_request] project = merge_request.target_project @@ -36,10 +34,6 @@ class DiffFileEntity < DiffFileBaseEntity project_blob_path(project, tree_join(diff_file.content_sha, diff_file.new_path)) end - expose :viewer, using: DiffViewerEntity do |diff_file| - diff_file.rich_viewer || diff_file.simple_viewer - end - expose :replaced_view_path, if: -> (_, options) { options[:merge_request] } do |diff_file| image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image' image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha diff --git a/app/serializers/diff_viewer_entity.rb b/app/serializers/diff_viewer_entity.rb index 587fa2347fd..45faca6cb2f 100644 --- a/app/serializers/diff_viewer_entity.rb +++ b/app/serializers/diff_viewer_entity.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true class DiffViewerEntity < Grape::Entity - # Partial name refers directly to a Rails feature, let's avoid - # using this on the frontend. expose :partial_name, as: :name - expose :error do |diff_viewer| - diff_viewer.render_error_message - end + expose :render_error, as: :error + expose :render_error_message, as: :error_message + expose :collapsed?, as: :collapsed end diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 4a7d13915dd..76248e6470e 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -8,6 +8,7 @@ class EnvironmentEntity < Grape::Entity expose :state expose :external_url expose :environment_type + expose :name_without_type expose :last_deployment, using: DeploymentEntity expose :stop_action_available?, as: :has_stop_action diff --git a/app/serializers/error_tracking/project_entity.rb b/app/serializers/error_tracking/project_entity.rb new file mode 100644 index 00000000000..405d87ca0d0 --- /dev/null +++ b/app/serializers/error_tracking/project_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ProjectEntity < Grape::Entity + expose(*Gitlab::ErrorTracking::Project::ACCESSORS) + end +end diff --git a/app/serializers/error_tracking/project_serializer.rb b/app/serializers/error_tracking/project_serializer.rb new file mode 100644 index 00000000000..68724088fff --- /dev/null +++ b/app/serializers/error_tracking/project_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ProjectSerializer < BaseSerializer + entity ErrorTracking::ProjectEntity + end +end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index 084627f9dbe..178e72f4f0a 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -11,4 +11,5 @@ class MergeRequestBasicEntity < Grape::Entity expose :labels, using: LabelEntity expose :assignee, using: API::Entities::UserBasic expose :task_status, :task_status_short + expose :lock_version, :lock_version end diff --git a/app/serializers/merge_request_widget_commit_entity.rb b/app/serializers/merge_request_widget_commit_entity.rb new file mode 100644 index 00000000000..50a5c44a6ad --- /dev/null +++ b/app/serializers/merge_request_widget_commit_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class MergeRequestWidgetCommitEntity < Grape::Entity + expose :safe_message, as: :message + expose :short_id + expose :title +end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 9361c9f987b..2142ceb6122 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -56,10 +56,23 @@ class MergeRequestWidgetEntity < IssuableEntity merge_request.diff_head_sha.presence end - expose :merge_commit_message - expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline + expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? } + expose :merge_pipeline, with: PipelineDetailsEntity, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)} + expose :default_squash_commit_message + expose :default_merge_commit_message + + expose :default_merge_commit_message_with_description do |merge_request| + merge_request.default_merge_commit_message(include_description: true) + end + + expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request| + merge_request.commits.without_merge_commits + end + + expose :commits_count + # Booleans expose :merge_ongoing?, as: :merge_ongoing expose :work_in_progress?, as: :work_in_progress @@ -77,7 +90,6 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :branch_missing?, as: :branch_missing - expose :commits_count expose :cannot_be_merged?, as: :has_conflicts expose :can_be_merged?, as: :can_be_merged expose :mergeable?, as: :mergeable @@ -205,10 +217,6 @@ class MergeRequestWidgetEntity < IssuableEntity ci_environments_status_project_merge_request_path(merge_request.project, merge_request) end - expose :merge_commit_message_with_description do |merge_request| - merge_request.merge_commit_message(include_description: true) - end - expose :diverged_commits_count do |merge_request| if merge_request.open? && merge_request.diverged_from_target_branch? merge_request.diverged_commits_count diff --git a/app/serializers/namespace_basic_entity.rb b/app/serializers/namespace_basic_entity.rb new file mode 100644 index 00000000000..8bcbb2bca60 --- /dev/null +++ b/app/serializers/namespace_basic_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class NamespaceBasicEntity < Grape::Entity + expose :id + expose :full_path +end diff --git a/app/serializers/namespace_serializer.rb b/app/serializers/namespace_serializer.rb new file mode 100644 index 00000000000..bf3f154b558 --- /dev/null +++ b/app/serializers/namespace_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class NamespaceSerializer < BaseSerializer + entity NamespaceBasicEntity +end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index c9669e59199..29b1a6c244b 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -59,7 +59,7 @@ class PipelineEntity < Grape::Entity pipeline.present.failure_reason end - expose :retry_path, if: -> (*) { can_retry? } do |pipeline| + expose :retry_path, if: -> (*) { can_retry? } do |pipeline| retry_project_pipeline_path(pipeline.project, pipeline) end diff --git a/app/serializers/project_import_entity.rb b/app/serializers/project_import_entity.rb new file mode 100644 index 00000000000..9b51af685e7 --- /dev/null +++ b/app/serializers/project_import_entity.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectImportEntity < ProjectEntity + include ImportHelper + + expose :import_source + expose :import_status + expose :human_import_status_name + + expose :provider_link do |project, options| + provider_project_link_url(options[:provider_url], project[:import_source]) + end +end diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb index 23b96c2fc9e..52ac2fa0e09 100644 --- a/app/serializers/project_serializer.rb +++ b/app/serializers/project_serializer.rb @@ -1,5 +1,15 @@ # frozen_string_literal: true class ProjectSerializer < BaseSerializer - entity ProjectEntity + def represent(project, opts = {}) + entity = + case opts[:serializer] + when :import + ProjectImportEntity + else + ProjectEntity + end + + super(project, opts, entity) + end end diff --git a/app/serializers/provider_repo_entity.rb b/app/serializers/provider_repo_entity.rb new file mode 100644 index 00000000000..d70aaa91324 --- /dev/null +++ b/app/serializers/provider_repo_entity.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ProviderRepoEntity < Grape::Entity + include ImportHelper + + expose :id + expose :full_name + expose :owner_name do |provider_repo, options| + owner_name(provider_repo, options[:provider]) + end + + expose :sanitized_name do |provider_repo| + sanitize_project_name(provider_repo[:name]) + end + + expose :provider_link do |provider_repo, options| + provider_project_link_url(options[:provider_url], provider_repo[:full_name]) + end + + private + + def owner_name(provider_repo, provider) + provider_repo.dig(:owner, :login) if provider == :github + end +end diff --git a/app/serializers/provider_repo_serializer.rb b/app/serializers/provider_repo_serializer.rb new file mode 100644 index 00000000000..8a73f6fe6df --- /dev/null +++ b/app/serializers/provider_repo_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ProviderRepoSerializer < BaseSerializer + entity ProviderRepoEntity +end diff --git a/app/serializers/tree_entity.rb b/app/serializers/tree_entity.rb deleted file mode 100644 index 9b7dc80e1d9..00000000000 --- a/app/serializers/tree_entity.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class TreeEntity < Grape::Entity - include RequestAwareEntity - - expose :id, :path, :name, :mode - - expose :icon do |tree| - IconsHelper.file_type_icon_class('folder', tree.mode, tree.name) - end - - expose :url do |tree| - project_tree_path(request.project, File.join(request.ref, tree.path)) - end -end diff --git a/app/serializers/tree_root_entity.rb b/app/serializers/tree_root_entity.rb deleted file mode 100644 index f1cfcd943d8..00000000000 --- a/app/serializers/tree_root_entity.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -# TODO: Inherit from TreeEntity, when `Tree` implements `id` and `name` like `Gitlab::Git::Tree`. -class TreeRootEntity < Grape::Entity - include RequestAwareEntity - - expose :path - - expose :trees, using: TreeEntity - expose :blobs, using: BlobEntity - expose :submodules, using: SubmoduleEntity - - expose :parent_tree_url do |tree| - path = tree.path.sub(%r{\A/}, '') - next unless path.present? - - path_segments = path.split('/') - path_segments.pop - parent_tree_path = path_segments.join('/') - - project_tree_path(request.project, File.join(request.ref, parent_tree_path)) - end - - expose :last_commit_path do |tree| - logs_file_project_ref_path(request.project, request.ref, tree.path) - end -end diff --git a/app/serializers/tree_serializer.rb b/app/serializers/tree_serializer.rb deleted file mode 100644 index 536b8ab1ae2..00000000000 --- a/app/serializers/tree_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class TreeSerializer < BaseSerializer - entity TreeRootEntity -end diff --git a/app/services/applications/create_service.rb b/app/services/applications/create_service.rb index b6c30da4d3a..dff0d9696f8 100644 --- a/app/services/applications/create_service.rb +++ b/app/services/applications/create_service.rb @@ -2,16 +2,16 @@ module Applications class CreateService - # rubocop: disable CodeReuse/ActiveRecord + attr_reader :current_user, :params + def initialize(current_user, params) @current_user = current_user - @params = params.except(:ip_address) + @params = params.except(:ip_address) # rubocop: disable CodeReuse/ActiveRecord end - # rubocop: enable CodeReuse/ActiveRecord # EE would override and use `request` arg def execute(request) - Doorkeeper::Application.create(@params) + Doorkeeper::Application.create(params) end end end diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index f764536e762..e95ba09c006 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -160,7 +160,8 @@ module Auth ## # We still support legacy pipeline triggers which do not have associated # actor. New permissions model and new triggers are always associated with - # an actor, so this should be improved in 10.0 version of GitLab. + # an actor. So this should be improved once + # https://gitlab.com/gitlab-org/gitlab-ce/issues/37452 is resolved. # def build_can_push?(requested_project) # Build can push only to the project from which it originates diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 43a26f4264e..834baeb9643 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -50,7 +50,7 @@ module Boards if move_between_ids attrs[:move_between_ids] = move_between_ids - attrs[:board_group_id] = board.group&.id + attrs[:board_group_id] = board.group&.id end attrs diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb index 609c430caed..e20805d0405 100644 --- a/app/services/boards/lists/destroy_service.rb +++ b/app/services/boards/lists/destroy_service.rb @@ -20,7 +20,7 @@ module Boards # rubocop: disable CodeReuse/ActiveRecord def decrement_higher_lists(list) - board.lists.movable.where('position > ?', list.position) + board.lists.movable.where('position > ?', list.position) .update_all('position = position - 1') end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index f8d8ef04001..8973c5ffc9e 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -7,14 +7,17 @@ module Ci CreateError = Class.new(StandardError) SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, + Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, Gitlab::Ci::Pipeline::Chain::Validate::Abilities, Gitlab::Ci::Pipeline::Chain::Validate::Repository, Gitlab::Ci::Pipeline::Chain::Validate::Config, Gitlab::Ci::Pipeline::Chain::Skip, + Gitlab::Ci::Pipeline::Chain::Limit::Size, Gitlab::Ci::Pipeline::Chain::Populate, - Gitlab::Ci::Pipeline::Chain::Create].freeze + Gitlab::Ci::Pipeline::Chain::Create, + Gitlab::Ci::Pipeline::Chain::Limit::Activity].freeze - def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, &block) + def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, **options, &block) @pipeline = Ci::Pipeline.new command = Gitlab::Ci::Pipeline::Chain::Command.new( @@ -22,7 +25,9 @@ module Ci origin_ref: params[:ref], checkout_sha: params[:checkout_sha], after_sha: params[:after], - before_sha: params[:before], + before_sha: params[:before], # The base SHA of the source branch (i.e merge_request.diff_base_sha). + source_sha: params[:source_sha], # The HEAD SHA of the source branch (i.e merge_request.diff_head_sha). + target_sha: params[:target_sha], # The HEAD SHA of the target branch. trigger_request: trigger_request, schedule: schedule, merge_request: merge_request, @@ -32,7 +37,9 @@ module Ci variables_attributes: params[:variables_attributes], project: project, current_user: current_user, - push_options: params[:push_options]) + push_options: params[:push_options], + chat_data: params[:chat_data], + **extra_options(options)) sequence = Gitlab::Ci::Pipeline::Chain::Sequence .new(pipeline, command, SEQUENCE) @@ -103,5 +110,14 @@ module Ci pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref) end # rubocop: enable CodeReuse/ActiveRecord + + def extra_options(options = {}) + # In Ruby 2.4, even when options is empty, f(**options) doesn't work when f + # doesn't have any parameters. We reproduce the Ruby 2.5 behavior by + # checking explicitly that no arguments are given. + raise ArgumentError if options.any? + + {} # overridden in EE + end end end diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb new file mode 100644 index 00000000000..7d2f5d33fed --- /dev/null +++ b/app/services/ci/destroy_expired_job_artifacts_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Ci + class DestroyExpiredJobArtifactsService + include ::Gitlab::ExclusiveLeaseHelpers + include ::Gitlab::LoopHelpers + + BATCH_SIZE = 100 + LOOP_TIMEOUT = 45.minutes + LOOP_LIMIT = 1000 + EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock' + LOCK_TIMEOUT = 50.minutes + + ## + # Destroy expired job artifacts on GitLab instance + # + # This destroy process cannot run for more than 45 minutes. This is for + # preventing multiple `ExpireBuildArtifactsWorker` CRON jobs run concurrently, + # which is scheduled at every hour. + def execute + in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do + loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do + destroy_batch + end + end + end + + private + + def destroy_batch + artifacts = Ci::JobArtifact.expired(BATCH_SIZE).to_a + + return false if artifacts.empty? + + artifacts.each(&:destroy!) + end + end +end diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index f54574b026b..2dbb7c3917d 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -7,6 +7,8 @@ module Ci def execute if trigger_from_token create_pipeline_from_trigger(trigger_from_token) + elsif job_from_token + create_pipeline_from_job(job_from_token) end end @@ -35,6 +37,14 @@ module Ci end end + def create_pipeline_from_job(job) + # overridden in EE + end + + def job_from_token + # overridden in EE + end + def variables params[:variables].to_h.map do |key, value| { key: key, value: value } diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 446188347df..4a7ce00b8e2 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -10,7 +10,7 @@ module Ci update_retried new_builds = - stage_indexes_of_created_builds.map do |index| + stage_indexes_of_created_processables.map do |index| process_stage(index) end @@ -27,7 +27,7 @@ module Ci return if HasStatus::BLOCKED_STATUS.include?(current_status) if HasStatus::COMPLETED_STATUSES.include?(current_status) - created_builds_in_stage(index).select do |build| + created_processables_in_stage(index).select do |build| Gitlab::OptimisticLocking.retry_lock(build) do |subject| Ci::ProcessBuildService.new(project, @user) .execute(build, current_status) @@ -43,19 +43,19 @@ module Ci # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord - def stage_indexes_of_created_builds - created_builds.order(:stage_idx).pluck('distinct stage_idx') + def stage_indexes_of_created_processables + created_processables.order(:stage_idx).pluck('distinct stage_idx') end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord - def created_builds_in_stage(index) - created_builds.where(stage_idx: index) + def created_processables_in_stage(index) + created_processables.where(stage_idx: index) end # rubocop: enable CodeReuse/ActiveRecord - def created_builds - pipeline.builds.created + def created_processables + pipeline.processables.created end # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index 21ec26ea233..c592d608b89 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -4,7 +4,7 @@ module Clusters module Applications class CheckInstallationProgressService < BaseHelmService def execute - return unless app.installing? + return unless operation_in_progress? case installation_phase when Gitlab::Kubernetes::Pod::SUCCEEDED @@ -16,11 +16,16 @@ module Clusters end rescue Kubeclient::HttpError => e log_error(e) - app.make_errored!("Kubernetes error: #{e.error_code}") unless app.errored? + + app.make_errored!("Kubernetes error: #{e.error_code}") end private + def operation_in_progress? + app.installing? || app.updating? + end + def on_success app.make_installed! ensure @@ -28,13 +33,13 @@ module Clusters end def on_failed - app.make_errored!("Installation failed. Check pod logs for #{install_command.pod_name} for more details.") + app.make_errored!("Operation failed. Check pod logs for #{pod_name} for more details.") end def check_timeout if timeouted? begin - app.make_errored!("Installation timed out. Check pod logs for #{install_command.pod_name} for more details.") + app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.") end else ClusterWaitForAppInstallationWorker.perform_in( @@ -42,20 +47,24 @@ module Clusters end end + def pod_name + install_command.pod_name + end + def timeouted? Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT end def remove_installation_pod - helm_api.delete_pod!(install_command.pod_name) + helm_api.delete_pod!(pod_name) end def installation_phase - helm_api.status(install_command.pod_name) + helm_api.status(pod_name) end def installation_errors - helm_api.log(install_command.pod_name) + helm_api.log(pod_name) end end end diff --git a/app/services/clusters/applications/create_service.rb b/app/services/clusters/applications/create_service.rb index 92c2c1b9834..12f8c849d41 100644 --- a/app/services/clusters/applications/create_service.rb +++ b/app/services/clusters/applications/create_service.rb @@ -27,9 +27,11 @@ module Clusters application.oauth_application = create_oauth_application(application, request) end - application.save! + worker = application.updateable? ? ClusterUpgradeAppWorker : ClusterInstallAppWorker - Clusters::Applications::ScheduleInstallationService.new(application).execute + application.make_scheduled! + + worker.perform_async(application.name, application.id) end end diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb deleted file mode 100644 index d75ba70c27e..00000000000 --- a/app/services/clusters/applications/schedule_installation_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class ScheduleInstallationService - attr_reader :application - - def initialize(application) - @application = application - end - - def execute - application.make_scheduled! - - ClusterInstallAppWorker.perform_async(application.name, application.id) - end - end - end -end diff --git a/app/services/clusters/applications/upgrade_service.rb b/app/services/clusters/applications/upgrade_service.rb new file mode 100644 index 00000000000..a0ece1d2635 --- /dev/null +++ b/app/services/clusters/applications/upgrade_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UpgradeService < BaseHelmService + def execute + return unless app.scheduled? + + begin + app.make_updating! + + # install_command works with upgrades too + # as it basically does `helm upgrade --install` + helm_api.update(install_command) + + ClusterWaitForAppInstallationWorker.perform_in( + ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) + rescue Kubeclient::HttpError => e + log_error(e) + app.make_update_errored!("Kubernetes error: #{e.error_code}") + rescue StandardError => e + log_error(e) + app.make_update_errored!("Can't start upgrade process.") + end + end + end + end +end diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb index f102e00d150..2cb73555d85 100644 --- a/app/services/concerns/exclusive_lease_guard.rb +++ b/app/services/concerns/exclusive_lease_guard.rb @@ -6,9 +6,14 @@ # # `#try_obtain_lease` takes a block which will be run if it was able to # obtain the lease. Implement `#lease_timeout` to configure the timeout -# for the exclusive lease. Optionally override `#lease_key` to set the +# for the exclusive lease. +# +# Optionally override `#lease_key` to set the # lease key, it defaults to the class name with underscores. # +# Optionally override `#lease_release?` to prevent the job to +# be re-executed more often than LEASE_TIMEOUT. +# module ExclusiveLeaseGuard extend ActiveSupport::Concern @@ -23,7 +28,7 @@ module ExclusiveLeaseGuard begin yield lease ensure - release_lease(lease) + release_lease(lease) if lease_release? end end @@ -37,7 +42,11 @@ module ExclusiveLeaseGuard def lease_timeout raise NotImplementedError, - "#{self.class.name} does not implement #{__method__}" + "#{self.class.name} does not implement #{__method__}" + end + + def lease_release? + true end def release_lease(uuid) diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb index 5b408bd96c7..6713b6617ae 100644 --- a/app/services/concerns/users/participable_service.rb +++ b/app/services/concerns/users/participable_service.rb @@ -11,7 +11,7 @@ module Users def noteable_owner return [] unless noteable && noteable.author.present? - [as_hash(noteable.author)] + [user_as_hash(noteable.author)] end def participants_in_noteable @@ -23,21 +23,24 @@ module Users def sorted(users) users.uniq.to_a.compact.sort_by(&:username).map do |user| - as_hash(user) + user_as_hash(user) end end def groups current_user.authorized_groups.sort_by(&:path).map do |group| - count = group.users.count - { username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url } + group_as_hash(group) end end private - def as_hash(user) - { username: user.username, name: user.name, avatar_url: user.avatar_url } + def user_as_hash(user) + { type: user.class.name, username: user.username, name: user.name, avatar_url: user.avatar_url } + end + + def group_as_hash(group) + { type: group.class.name, username: group.full_path, name: group.full_name, avatar_url: group.avatar_url, count: group.users.count } end end end diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb index 65208b07e27..110e589e30d 100644 --- a/app/services/create_branch_service.rb +++ b/app/services/create_branch_service.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class CreateBranchService < BaseService - def execute(branch_name, ref) - create_master_branch if project.empty_repo? + def execute(branch_name, ref, create_master_if_empty: true) + create_master_branch if create_master_if_empty && project.empty_repo? result = ValidateNewBranchService.new(project, current_user) .execute(branch_name) diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb index 988215ffc78..99324638300 100644 --- a/app/services/emails/base_service.rb +++ b/app/services/emails/base_service.rb @@ -2,10 +2,11 @@ module Emails class BaseService - attr_reader :current_user + attr_reader :current_user, :params, :user def initialize(current_user, params = {}) - @current_user, @params = current_user, params.dup + @current_user = current_user + @params = params.dup @user = params.delete(:user) end end diff --git a/app/services/emails/create_service.rb b/app/services/emails/create_service.rb index 56925a724fe..dc06a5caa40 100644 --- a/app/services/emails/create_service.rb +++ b/app/services/emails/create_service.rb @@ -3,12 +3,11 @@ module Emails class CreateService < ::Emails::BaseService def execute(extra_params = {}) - skip_confirmation = @params.delete(:skip_confirmation) + skip_confirmation = params.delete(:skip_confirmation) - email = @user.emails.create(@params.merge(extra_params)) - - email&.confirm if skip_confirmation && current_user.admin? - email + user.emails.create(params.merge(extra_params)).tap do |email| + email&.confirm if skip_confirmation && current_user.admin? + end end end end diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb index 4cc35cfa4a8..a6c6bec9598 100644 --- a/app/services/error_tracking/list_issues_service.rb +++ b/app/services/error_tracking/list_issues_service.rb @@ -6,15 +6,19 @@ module ErrorTracking DEFAULT_LIMIT = 20 def execute - return error('not enabled') unless enabled? - return error('access denied') unless can_read? + return error('Error Tracking is not enabled') unless enabled? + return error('Access denied', :unauthorized) unless can_read? result = project_error_tracking_setting .list_sentry_issues(issue_status: issue_status, limit: limit) # our results are not yet ready unless result - return error('not ready', :no_content) + return error('Not ready. Try again later', :no_content) + end + + if result[:error].present? + return error(result[:error], :bad_request) end success(issues: result[:issues]) diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb new file mode 100644 index 00000000000..c6e8be0f2be --- /dev/null +++ b/app/services/error_tracking/list_projects_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ErrorTracking + class ListProjectsService < ::BaseService + def execute + return error('access denied') unless can_read? + + setting = project_error_tracking_setting + + unless setting.valid? + return error(setting.errors.full_messages.join(', '), :bad_request) + end + + begin + result = setting.list_sentry_projects + rescue Sentry::Client::Error => e + return error(e.message, :bad_request) + rescue Sentry::Client::SentryError => e + return error(e.message, :unprocessable_entity) + end + + success(projects: result[:projects]) + end + + private + + def project_error_tracking_setting + (project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting| + setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_host: params[:api_host], + organization_slug: nil, + project_slug: nil + ) + + setting.token = params[:token] + setting.enabled = true + end + end + + def can_read? + can?(current_user, :read_sentry_issue, project) + end + end +end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 9ecee7c6156..f387c749a21 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -140,7 +140,7 @@ class GitPushService < BaseService .perform_async(project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref]) EventCreateService.new.push(project, current_user, build_push_data) - Ci::CreatePipelineService.new(project, current_user, build_push_data).execute(:push) + Ci::CreatePipelineService.new(project, current_user, build_push_data).execute(:push, pipeline_options) project.execute_hooks(build_push_data.dup, :push_hooks) project.execute_services(build_push_data.dup, :push_hooks) @@ -231,4 +231,10 @@ class GitPushService < BaseService def last_pushed_commits @last_pushed_commits ||= @push_commits.last(PROCESS_COMMIT_LIMIT) end + + private + + def pipeline_options + {} # to be overridden in EE + end end diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index 03fcf614c64..e39b3603c6c 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -10,7 +10,7 @@ class GitTagPushService < BaseService @push_data = build_push_data EventCreateService.new.push(project, current_user, push_data) - Ci::CreatePipelineService.new(project, current_user, push_data).execute(:push) + Ci::CreatePipelineService.new(project, current_user, push_data).execute(:push, pipeline_options) SystemHooksService.new.execute_hooks(build_system_push_data, :tag_push_hooks) project.execute_hooks(push_data.dup, :tag_push_hooks) @@ -59,4 +59,8 @@ class GitTagPushService < BaseService [], '') end + + def pipeline_options + {} # to be overridden in EE + end end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 24d8400c625..99ead467f74 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -10,6 +10,8 @@ module Groups def execute @group = Group.new(params) + after_build_hook(@group, params) + unless can_use_visibility_level? && can_create_group? return @group end @@ -30,6 +32,10 @@ module Groups private + def after_build_hook(group, params) + # overridden in EE + end + def create_chat_team? Gitlab.config.mattermost.enabled && @chat_team && group.chat_team.nil? end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index de78a3f7b27..787445180f0 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -11,6 +11,8 @@ module Groups return false unless valid_share_with_group_lock_change? + before_assignment_hook(group, params) + group.assign_attributes(params) begin @@ -28,6 +30,10 @@ module Groups private + def before_assignment_hook(group, params) + # overridden in EE + end + def after_update if group.previous_changes.include?(:visibility_level) && group.private? # don't enqueue immediately to prevent todos removal in case of a mistake diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index 885e14bba8f..77f38f8882e 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -20,7 +20,7 @@ module Issuable create_due_date_note if issuable.previous_changes.include?('due_date') create_milestone_note if issuable.previous_changes.include?('milestone_id') - create_labels_note(old_labels) if issuable.labels != old_labels + create_labels_note(old_labels) if old_labels && issuable.labels != old_labels end private diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 805bb5b510d..ef991eaf234 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -235,6 +235,61 @@ class IssuableBaseService < BaseService issuable end + def update_task(issuable) + filter_params(issuable) + + if issuable.changed? || params.present? + issuable.assign_attributes(params.merge(updated_by: current_user, + last_edited_at: Time.now, + last_edited_by: current_user)) + + before_update(issuable) + + if issuable.with_transaction_returning_status { issuable.save } + # We do not touch as it will affect a update on updated_at field + ActiveRecord::Base.no_touching do + Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil) + end + + handle_task_changes(issuable) + invalidate_cache_counts(issuable, users: issuable.assignees.to_a) + after_update(issuable) + execute_hooks(issuable, 'update', old_associations: nil) + end + end + + issuable + end + + # Handle the `update_task` event sent from UI. Attempts to update a specific + # line in the markdown and cached html, bypassing any unnecessary updates or checks. + def update_task_event(issuable) + update_task_params = params.delete(:update_task) + return unless update_task_params + + tasklist_toggler = TaskListToggleService.new(issuable.description, issuable.description_html, + line_source: update_task_params[:line_source], + line_number: update_task_params[:line_number].to_i, + toggle_as_checked: update_task_params[:checked]) + + unless tasklist_toggler.execute + # if we make it here, the data is much newer than we thought it was - fail fast + raise ActiveRecord::StaleObjectError + end + + # by updating the description_html field at the same time, + # the markdown cache won't be considered invalid + params[:description] = tasklist_toggler.updated_markdown + params[:description_html] = tasklist_toggler.updated_markdown_html + + # since we're updating a very specific line, we don't care whether + # the `lock_version` sent from the FE is the same or not. Just + # make sure the data hasn't changed since we queried it + params[:lock_version] = issuable.lock_version + + update_task(issuable) + end + def labels_changing?(old_label_ids, new_label_ids) old_label_ids.sort != new_label_ids.sort end @@ -318,6 +373,10 @@ class IssuableBaseService < BaseService end # override if needed + def handle_task_changes(issuable) + end + + # override if needed def execute_hooks(issuable, action = 'open', params = {}) end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 52b45f1b2ce..3fb2c2b3007 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -57,9 +57,11 @@ module Issues end def issue_params - @issue_params ||= issue_params_with_info_from_discussions.merge(whitelisted_issue_params) + @issue_params ||= build_issue_params end + private + def whitelisted_issue_params if can?(current_user, :admin_issue, project) params.slice(:title, :description, :milestone_id) @@ -67,5 +69,9 @@ module Issues params.slice(:title, :description) end end + + def build_issue_params + issue_params_with_info_from_discussions.merge(whitelisted_issue_params) + end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index e992d682c79..cec5b5734c0 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -8,7 +8,7 @@ module Issues handle_move_between_ids(issue) filter_spam_check_params change_issue_duplicate(issue) - move_issue_to_new_project(issue) || update(issue) + move_issue_to_new_project(issue) || update_task_event(issue) || update(issue) end def update(issue) @@ -63,6 +63,11 @@ module Issues end end + def handle_task_changes(issuable) + todo_service.mark_pending_todos_as_done(issuable, current_user) + todo_service.update_issue(issuable, current_user) + end + def handle_move_between_ids(issue) return unless params[:move_between_ids] @@ -78,6 +83,8 @@ module Issues # rubocop: disable CodeReuse/ActiveRecord def change_issue_duplicate(issue) canonical_issue_id = params.delete(:canonical_issue_id) + return unless canonical_issue_id + canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id) if canonical_issue diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb index 3c0e6196d4f..e73e6476c12 100644 --- a/app/services/labels/promote_service.rb +++ b/app/services/labels/promote_service.rb @@ -49,7 +49,7 @@ module Labels .new(current_user, title: new_label.title, group_id: project.group.id) .execute(skip_authorization: true) .where.not(id: new_label) - .select(:id) # Can't use pluck() to avoid object-creation because of the batching + .select(:id) # Can't use pluck() to avoid object-creation because of the batching end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb index e563447c64c..be33947d0eb 100644 --- a/app/services/labels/update_service.rb +++ b/app/services/labels/update_service.rb @@ -8,6 +8,7 @@ module Labels # returns the updated label def execute(label) + params[:name] = params.delete(:new_name) if params.key?(:new_name) params[:color] = convert_color_name_to_hex if params[:color].present? label.update(params) diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 714b8586737..cf710fef52b 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -19,9 +19,19 @@ module Members current_user: current_user ) - members.each { |member| after_execute(member: member) } + errors = [] - success + members.each do |member| + if member.errors.any? + errors << "#{member.user.username}: #{member.errors.full_messages.to_sentence}" + else + after_execute(member: member) + end + end + + return success unless errors.any? + + error(errors.to_sentence) end private diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index fe19abf50f6..ac51fee0b3f 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -63,6 +63,7 @@ module MergeRequests # UpdateMergeRequestsWorker could be retried by an exception. # MR pipelines should not be recreated in such case. return if merge_request.merge_request_pipeline_exists? + return if merge_request.has_no_commits? Ci::CreatePipelineService .new(merge_request.source_project, user, ref: merge_request.source_branch) diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb new file mode 100644 index 00000000000..095bdca5472 --- /dev/null +++ b/app/services/merge_requests/merge_base_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeBaseService < MergeRequests::BaseService + include Gitlab::Utils::StrongMemoize + + MergeError = Class.new(StandardError) + + attr_reader :merge_request + + # Overridden in EE. + def hooks_validation_pass?(_merge_request) + true + end + + # Overridden in EE. + def hooks_validation_error(_merge_request) + # No-op + end + + def source + if merge_request.squash + squash_sha! + else + merge_request.diff_head_sha + end + end + + private + + # Overridden in EE. + def error_check! + # No-op + end + + def raise_error(message) + raise MergeError, message + end + + def handle_merge_error(*args) + # No-op + end + + def commit_message + params[:commit_message] || + merge_request.default_merge_commit_message + end + + def squash_sha! + strong_memoize(:squash_sha) do + params[:merge_request] = merge_request + squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute + + case squash_result[:status] + when :success + squash_result[:squash_sha] + when :error + raise ::MergeRequests::MergeService::MergeError, squash_result[:message] + end + end + end + end +end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 70a67baa01c..8241e408ce5 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -7,11 +7,7 @@ module MergeRequests # mark merge request as merged and execute all hooks and notifications # Executed when you do merge via GitLab UI # - class MergeService < MergeRequests::BaseService - MergeError = Class.new(StandardError) - - attr_reader :merge_request, :source - + class MergeService < MergeRequests::MergeBaseService delegate :merge_jid, :state, to: :@merge_request def execute(merge_request) @@ -22,7 +18,7 @@ module MergeRequests @merge_request = merge_request - error_check! + validate! merge_request.in_locked_state do if commit @@ -36,27 +32,22 @@ module MergeRequests handle_merge_error(log_message: e.message, save_message_on_model: true) end - def source - return merge_request.diff_head_sha unless merge_request.squash - - squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute(merge_request) + private - case squash_result[:status] - when :success - squash_result[:squash_sha] - when :error - raise ::MergeRequests::MergeService::MergeError, squash_result[:message] - end + def validate! + authorization_check! + error_check! end - # Overridden in EE. - def hooks_validation_pass?(_merge_request) - true + def authorization_check! + unless @merge_request.can_be_merged_by?(current_user) + raise_error('You are not allowed to merge this merge request') + end end - private - def error_check! + super + error = if @merge_request.should_be_rebased? 'Only fast-forward merge is allowed for your project. Please update your source branch' @@ -66,7 +57,7 @@ module MergeRequests 'No source for merge' end - raise MergeError, error if error + raise_error(error) if error end def commit @@ -76,22 +67,20 @@ module MergeRequests if commit_id log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}") else - raise MergeError, 'Conflicts detected during merge' + raise_error('Conflicts detected during merge') end merge_request.update!(merge_commit_sha: commit_id) end def try_merge - message = params[:commit_message] || merge_request.merge_commit_message - - repository.merge(current_user, source, merge_request, message) + repository.merge(current_user, source, merge_request, commit_message) rescue Gitlab::Git::PreReceiveError => e handle_merge_error(log_message: e.message) - raise MergeError, 'Something went wrong during merge pre-receive hook' + raise_error('Something went wrong during merge pre-receive hook') rescue => e handle_merge_error(log_message: e.message) - raise MergeError, 'Something went wrong during merge' + raise_error('Something went wrong during merge') ensure merge_request.update!(in_progress_merge_commit_sha: nil) end diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb new file mode 100644 index 00000000000..586652ae44e --- /dev/null +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module MergeRequests + # Performs the merge between source SHA and the target branch. Instead + # of writing the result to the MR target branch, it targets the `target_ref`. + # + # Ideally this should leave the `target_ref` state with the same state the + # target branch would have if we used the regular `MergeService`, but without + # every side-effect that comes with it (MR updates, mails, source branch + # deletion, etc). This service should be kept idempotent (i.e. can + # be executed regardless of the `target_ref` current state). + # + class MergeToRefService < MergeRequests::MergeBaseService + def execute(merge_request) + @merge_request = merge_request + + validate! + + commit_id = commit + + raise_error('Conflicts detected during merge') unless commit_id + + success(commit_id: commit_id) + rescue MergeError => error + error(error.message) + end + + private + + def validate! + authorization_check! + error_check! + end + + def error_check! + super + + error = + if Feature.disabled?(:merge_to_tmp_merge_ref_path, project) + 'Feature is not enabled' + elsif !merge_method_supported? + "#{project.human_merge_method} to #{target_ref} is currently not supported." + elsif !hooks_validation_pass?(merge_request) + hooks_validation_error(merge_request) + elsif @merge_request.should_be_rebased? + 'Fast-forward merge is not possible. Please update your source branch.' + elsif !@merge_request.mergeable_to_ref? + "Merge request is not mergeable to #{target_ref}" + elsif !source + 'No source for merge' + end + + raise_error(error) if error + end + + def authorization_check! + unless Ability.allowed?(current_user, :admin_merge_request, project) + raise_error("You are not allowed to merge to this ref") + end + end + + def target_ref + merge_request.merge_ref_path + end + + def commit + repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message) + rescue Gitlab::Git::PreReceiveError => error + raise MergeError, error.message + end + + def merge_method_supported? + [:merge, :rebase_merge].include?(project.merge_method) + end + end +end diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index a439a380255..9d1a5d5e6d4 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -2,15 +2,10 @@ module MergeRequests class SquashService < MergeRequests::WorkingCopyBaseService - def execute(merge_request) - @merge_request = merge_request - @repository = target_project.repository - - squash || error('Failed to squash. Should be done manually.') - end - - def squash - if merge_request.commits_count < 2 + def execute + # If performing a squash would result in no change, then + # immediately return a success message without performing a squash + if merge_request.commits_count < 2 && message.nil? return success(squash_sha: merge_request.diff_head_sha) end @@ -18,7 +13,13 @@ module MergeRequests return error('Squash task canceled: another squash is already in progress.') end - squash_sha = repository.squash(current_user, merge_request) + squash! || error('Failed to squash. Should be done manually.') + end + + private + + def squash! + squash_sha = repository.squash(current_user, merge_request, message || merge_request.default_squash_commit_message) success(squash_sha: squash_sha) rescue => e @@ -26,5 +27,17 @@ module MergeRequests log_error(e.message) false end + + def repository + target_project.repository + end + + def merge_request + params[:merge_request] + end + + def message + params[:squash_commit_message].presence + end end end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 86a04587f79..8112c2a4299 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -21,7 +21,7 @@ module MergeRequests end handle_wip_event(merge_request) - update(merge_request) + update_task_event(merge_request) || update(merge_request) end # rubocop:disable Metrics/AbcSize @@ -83,6 +83,11 @@ module MergeRequests end # rubocop:enable Metrics/AbcSize + def handle_task_changes(merge_request) + todo_service.mark_pending_todos_as_done(merge_request, current_user) + todo_service.update_merge_request(merge_request, current_user) + end + def merge_from_quick_action(merge_request) last_diff_sha = params.delete(:merge) return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index bae98ede561..541f3e0d23c 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -15,6 +15,8 @@ module Notes return note end + discussion = discussion.convert_to_discussion! if discussion.can_convert_to_discussion? + params.merge!(discussion.reply_attributes) should_resolve = discussion.resolved? end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index c4546f30235..5a6e7338b42 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -34,6 +34,10 @@ module Notes end if !only_commands && note.save + if note.part_of_discussion? && note.discussion.can_convert_to_discussion? + note.discussion.convert_to_discussion!(save: true) + end + todo_service.new_note(note, current_user) clear_noteable_diffs_cache(note) Suggestions::CreateService.new(note).execute diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index 7ee9732040d..985a03060bd 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -7,9 +7,14 @@ module Notes 'MergeRequest' => MergeRequests::UpdateService, 'Commit' => Commits::TagService }.freeze + private_constant :UPDATE_SERVICES + + def self.update_services + UPDATE_SERVICES + end def self.noteable_update_service(note) - UPDATE_SERVICES[note.noteable_type] + update_services[note.noteable_type] end def self.supported?(note) diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 68cdc69023a..56f11b31110 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -249,6 +249,7 @@ module NotificationRecipientService attr_reader :action attr_reader :previous_assignee attr_reader :skip_current_user + def initialize(target, current_user, action:, custom_action: nil, previous_assignee: nil, skip_current_user: true) @target = target @current_user = current_user @@ -258,9 +259,13 @@ module NotificationRecipientService @skip_current_user = skip_current_user end + def add_watchers + add_project_watchers + end + def build! add_participants(current_user) - add_project_watchers + add_watchers add_custom_notifications # Re-assign is considered as a mention of the new assignee diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index a449a5dc3e9..c1655c38095 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -10,8 +10,7 @@ class PreviewMarkdownService < BaseService text: text, users: users, suggestions: suggestions, - commands: commands.join(' '), - markdown_engine: markdown_engine + commands: commands.join(' ') ) end @@ -49,12 +48,4 @@ class PreviewMarkdownService < BaseService def commands_target_id params[:quick_actions_target_id] end - - def markdown_engine - if params[:legacy_render] - :redcarpet - else - CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i) - end - end end diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb index c3cd9d1ea4a..fafdecb3222 100644 --- a/app/services/projects/after_rename_service.rb +++ b/app/services/projects/after_rename_service.rb @@ -62,7 +62,7 @@ module Projects def rename_or_migrate_repository! success = if migrate_to_hashed_storage? - ::Projects::HashedStorageMigrationService + ::Projects::HashedStorage::MigrationService .new(project, full_path_before) .execute else diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb new file mode 100644 index 00000000000..488290db824 --- /dev/null +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + class CleanupTagsService < BaseService + def execute(container_repository) + return error('feature disabled') unless can_use? + return error('access denied') unless can_admin? + + tags = container_repository.tags + tags_by_digest = group_by_digest(tags) + + tags = without_latest(tags) + tags = filter_by_name(tags) + tags = with_manifest(tags) + tags = order_by_date(tags) + tags = filter_keep_n(tags) + tags = filter_by_older_than(tags) + + deleted_tags = delete_tags(tags, tags_by_digest) + + success(deleted: deleted_tags.map(&:name)) + end + + private + + def delete_tags(tags_to_delete, tags_by_digest) + deleted_digests = group_by_digest(tags_to_delete).select do |digest, tags| + delete_tag_digest(digest, tags, tags_by_digest[digest]) + end + + deleted_digests.values.flatten + end + + def delete_tag_digest(digest, tags, other_tags) + # Issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/21405 + # we have to remove all tags due + # to Docker Distribution bug unable + # to delete single tag + return unless tags.count == other_tags.count + + # delete all tags + tags.map(&:delete) + end + + def group_by_digest(tags) + tags.group_by(&:digest) + end + + def without_latest(tags) + tags.reject(&:latest?) + end + + def with_manifest(tags) + tags.select(&:valid?) + end + + def order_by_date(tags) + now = DateTime.now + tags.sort_by { |tag| tag.created_at || now }.reverse + end + + def filter_by_name(tags) + regex = Gitlab::UntrustedRegexp.new("\\A#{params['name_regex']}\\z") + + tags.select do |tag| + regex.scan(tag.name).any? + end + end + + def filter_keep_n(tags) + tags.drop(params['keep_n'].to_i) + end + + def filter_by_older_than(tags) + return tags unless params['older_than'] + + older_than = ChronicDuration.parse(params['older_than']).seconds.ago + + tags.select do |tag| + tag.created_at && tag.created_at < older_than + end + end + + def can_admin? + can?(current_user, :admin_container_image, project) + end + + def can_use? + Feature.enabled?(:container_registry_cleanup, project, default_enabled: true) + end + end + end +end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 91091c4393d..fc234bafc57 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -38,8 +38,8 @@ module Projects new_params = { visibility_level: allowed_visibility_level, description: @project.description, - name: @project.name, - path: @project.path, + name: target_name, + path: target_path, shared_runners_enabled: @project.shared_runners_enabled, namespace_id: target_namespace.id, fork_network: fork_network, @@ -94,6 +94,14 @@ module Projects Projects::ForksCountService.new(@project).refresh_cache end + def target_path + @target_path ||= @params[:path] || @project.path + end + + def target_name + @target_name ||= @params[:name] || @project.name + end + def target_namespace @target_namespace ||= @params[:namespace] || current_user.namespace end diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb index 1392775f805..e3d5bea0852 100644 --- a/app/services/projects/group_links/create_service.rb +++ b/app/services/projects/group_links/create_service.rb @@ -4,13 +4,19 @@ module Projects module GroupLinks class CreateService < BaseService def execute(group) - return false unless group + return error('Not Found', 404) unless group && can?(current_user, :read_namespace, group) - project.project_group_links.create( + link = project.project_group_links.new( group: group, group_access: params[:link_group_access], expires_at: params[:expires_at] ) + + if link.save + success(link: link) + else + error(link.errors.full_messages.to_sentence, 409) + end end end end diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb new file mode 100644 index 00000000000..761c81d776f --- /dev/null +++ b/app/services/projects/hashed_storage/base_repository_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + # Returned when there is an error with the Hashed Storage migration + RepositoryMigrationError = Class.new(StandardError) + + # Returned when there is an error with the Hashed Storage rollback + RepositoryRollbackError = Class.new(StandardError) + + class BaseRepositoryService < BaseService + include Gitlab::ShellAdapter + + attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki + + def initialize(project, old_disk_path, logger: nil) + @project = project + @logger = logger || Gitlab::AppLogger + @old_disk_path = old_disk_path + @old_wiki_disk_path = "#{old_disk_path}.wiki" + @move_wiki = has_wiki? + end + + protected + + # rubocop: disable CodeReuse/ActiveRecord + def has_wiki? + gitlab_shell.exists?(project.repository_storage, "#{old_wiki_disk_path}.git") + end + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord + def move_repository(from_name, to_name) + from_exists = gitlab_shell.exists?(project.repository_storage, "#{from_name}.git") + to_exists = gitlab_shell.exists?(project.repository_storage, "#{to_name}.git") + + # If we don't find the repository on either original or target we should log that as it could be an issue if the + # project was not originally empty. + if !from_exists && !to_exists + logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..." + return false + elsif !from_exists + # Repository have been moved already. + return true + end + + gitlab_shell.mv_repository(project.repository_storage, from_name, to_name) + end + # rubocop: enable CodeReuse/ActiveRecord + + def rollback_folder_move + move_repository(new_disk_path, old_disk_path) + move_repository("#{new_disk_path}.wiki", old_wiki_disk_path) + end + end + end +end diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb index a1f0302aeb7..03e0685d2cd 100644 --- a/app/services/projects/hashed_storage/migrate_attachments_service.rb +++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb @@ -12,6 +12,7 @@ module Projects @logger = logger || Rails.logger @old_disk_path = old_disk_path @new_disk_path = project.disk_path + @skipped = false end def execute @@ -32,24 +33,29 @@ module Projects result end + def skipped? + @skipped + end + private - def move_folder!(old_disk_path, new_disk_path) - unless File.directory?(old_disk_path) - logger.info("Skipped attachments migration from '#{old_disk_path}' to '#{new_disk_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})") - return + def move_folder!(old_path, new_path) + unless File.directory?(old_path) + logger.info("Skipped attachments migration from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})") + @skipped = true + return true end - if File.exist?(new_disk_path) - logger.error("Cannot migrate attachments from '#{old_disk_path}' to '#{new_disk_path}', target path already exist (PROJECT_ID=#{project.id})") - raise AttachmentMigrationError, "Target path '#{new_disk_path}' already exist" + if File.exist?(new_path) + logger.error("Cannot migrate attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})") + raise AttachmentMigrationError, "Target path '#{new_path}' already exist" end # Create hashed storage base path folder - FileUtils.mkdir_p(File.dirname(new_disk_path)) + FileUtils.mkdir_p(File.dirname(new_path)) - FileUtils.mv(old_disk_path, new_disk_path) - logger.info("Migrated project attachments from '#{old_disk_path}' to '#{new_disk_path}' (PROJECT_ID=#{project.id})") + FileUtils.mv(old_path, new_path) + logger.info("Migrated project attachments from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})") true end diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb index 2d851866a18..9c672283c7e 100644 --- a/app/services/projects/hashed_storage/migrate_repository_service.rb +++ b/app/services/projects/hashed_storage/migrate_repository_service.rb @@ -2,21 +2,7 @@ module Projects module HashedStorage - RepositoryMigrationError = Class.new(StandardError) - - class MigrateRepositoryService < BaseService - include Gitlab::ShellAdapter - - attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki - - def initialize(project, old_disk_path, logger: nil) - @project = project - @logger = logger || Rails.logger - @old_disk_path = old_disk_path - @old_wiki_disk_path = "#{old_disk_path}.wiki" - @move_wiki = has_wiki? - end - + class MigrateRepositoryService < BaseRepositoryService def execute try_to_set_repository_read_only! @@ -61,36 +47,6 @@ module Projects raise RepositoryMigrationError, migration_error end end - - # rubocop: disable CodeReuse/ActiveRecord - def has_wiki? - gitlab_shell.exists?(project.repository_storage, "#{old_wiki_disk_path}.git") - end - # rubocop: enable CodeReuse/ActiveRecord - - # rubocop: disable CodeReuse/ActiveRecord - def move_repository(from_name, to_name) - from_exists = gitlab_shell.exists?(project.repository_storage, "#{from_name}.git") - to_exists = gitlab_shell.exists?(project.repository_storage, "#{to_name}.git") - - # If we don't find the repository on either original or target we should log that as it could be an issue if the - # project was not originally empty. - if !from_exists && !to_exists - logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..." - return false - elsif !from_exists - # Repository have been moved already. - return true - end - - gitlab_shell.mv_repository(project.repository_storage, from_name, to_name) - end - # rubocop: enable CodeReuse/ActiveRecord - - def rollback_folder_move - move_repository(new_disk_path, old_disk_path) - move_repository("#{new_disk_path}.wiki", old_wiki_disk_path) - end end end end diff --git a/app/services/projects/hashed_storage/migration_service.rb b/app/services/projects/hashed_storage/migration_service.rb new file mode 100644 index 00000000000..f132dca61c9 --- /dev/null +++ b/app/services/projects/hashed_storage/migration_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Projects + module HashedStorage + class MigrationService < BaseService + attr_reader :logger, :old_disk_path + + def initialize(project, old_disk_path, logger: nil) + @project = project + @old_disk_path = old_disk_path + @logger = logger || Gitlab::AppLogger + end + + def execute + # Migrate repository from Legacy to Hashed Storage + unless project.hashed_storage?(:repository) + return false unless migrate_repository + end + + # Migrate attachments from Legacy to Hashed Storage + unless project.hashed_storage?(:attachments) + return false unless migrate_attachments + end + + true + end + + private + + def migrate_repository + HashedStorage::MigrateRepositoryService.new(project, old_disk_path, logger: logger).execute + end + + def migrate_attachments + HashedStorage::MigrateAttachmentsService.new(project, old_disk_path, logger: logger).execute + end + end + end +end diff --git a/app/services/projects/hashed_storage_migration_service.rb b/app/services/projects/hashed_storage_migration_service.rb deleted file mode 100644 index a0e734005f8..00000000000 --- a/app/services/projects/hashed_storage_migration_service.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Projects - class HashedStorageMigrationService < BaseService - attr_reader :logger, :old_disk_path - - def initialize(project, old_disk_path, logger: nil) - @project = project - @old_disk_path = old_disk_path - @logger = logger || Rails.logger - end - - def execute - # Migrate repository from Legacy to Hashed Storage - unless project.hashed_storage?(:repository) - return unless HashedStorage::MigrateRepositoryService.new(project, old_disk_path, logger: logger).execute - end - - # Migrate attachments from Legacy to Hashed Storage - unless project.hashed_storage?(:attachments) - HashedStorage::MigrateAttachmentsService.new(project, old_disk_path, logger: logger).execute - end - - true - end - end -end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 5861b803996..7214e9efaf6 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -73,7 +73,7 @@ module Projects project.ensure_repository project.repository.fetch_as_mirror(project.import_url, refmap: refmap) else - gitlab_shell.import_repository(project.repository_storage, project.disk_path, project.import_url) + gitlab_shell.import_project_repository(project) end rescue Gitlab::Shell::Error => e # Expire cache to prevent scenarios such as: diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index abf40b3ad7a..674071ad92a 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -2,6 +2,8 @@ module Projects class UpdatePagesConfigurationService < BaseService + include Gitlab::Utils::StrongMemoize + attr_reader :project def initialize(project) @@ -9,15 +11,25 @@ module Projects end def execute - update_file(pages_config_file, pages_config.to_json) + if file_equals?(pages_config_file, pages_config_json) + return success(reload: false) + end + + update_file(pages_config_file, pages_config_json) reload_daemon - success + success(reload: true) rescue => e error(e.message) end private + def pages_config_json + strong_memoize(:pages_config_json) do + pages_config.to_json + end + end + def pages_config { domains: pages_domains_config, @@ -67,11 +79,6 @@ module Projects end def update_file(file, data) - unless data - FileUtils.remove(file, force: true) - return - end - temp_file = "#{file}.#{SecureRandom.hex(16)}" File.open(temp_file, 'w') do |f| f.write(data) @@ -81,5 +88,18 @@ module Projects # In case if the updating fails FileUtils.remove(temp_file, force: true) end + + def file_equals?(file, data) + existing_data = read_file(file) + data == existing_data.to_s + end + + def read_file(file) + File.open(file, 'r') do |f| + f.read + end + rescue + nil + end end end diff --git a/app/services/prometheus/adapter_service.rb b/app/services/prometheus/adapter_service.rb index a791845ba20..3be958e1613 100644 --- a/app/services/prometheus/adapter_service.rb +++ b/app/services/prometheus/adapter_service.rb @@ -30,7 +30,7 @@ module Prometheus return unless deployment_platform.respond_to?(:cluster) cluster = deployment_platform.cluster - return unless cluster.application_prometheus&.ready? + return unless cluster.application_prometheus&.available? cluster.application_prometheus end diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb index 9b85e13107b..1b13dace5f2 100644 --- a/app/services/protected_branches/api_service.rb +++ b/app/services/protected_branches/api_service.rb @@ -3,16 +3,15 @@ module ProtectedBranches class ApiService < BaseService def create - @push_params = AccessLevelParams.new(:push, params) - @merge_params = AccessLevelParams.new(:merge, params) + ::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute + end - protected_branch_params = { + def protected_branch_params + { name: params[:name], - push_access_levels_attributes: @push_params.access_levels, - merge_access_levels_attributes: @merge_params.access_levels + push_access_levels_attributes: AccessLevelParams.new(:push, params).access_levels, + merge_access_levels_attributes: AccessLevelParams.new(:merge, params).access_levels } - - ::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute end end end diff --git a/app/services/protected_branches/legacy_api_update_service.rb b/app/services/protected_branches/legacy_api_update_service.rb index da8bf2ce02a..7cb8d41818f 100644 --- a/app/services/protected_branches/legacy_api_update_service.rb +++ b/app/services/protected_branches/legacy_api_update_service.rb @@ -6,30 +6,31 @@ # lives in this service. module ProtectedBranches class LegacyApiUpdateService < BaseService + attr_reader :protected_branch, :developers_can_push, :developers_can_merge + def execute(protected_branch) + @protected_branch = protected_branch @developers_can_push = params.delete(:developers_can_push) @developers_can_merge = params.delete(:developers_can_merge) - @protected_branch = protected_branch - protected_branch.transaction do delete_redundant_access_levels - case @developers_can_push + case developers_can_push when true params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }] when false params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::MAINTAINER }] end - case @developers_can_merge + case developers_can_merge when true params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }] when false params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::MAINTAINER }] end - service = ProtectedBranches::UpdateService.new(@project, @current_user, @params) + service = ProtectedBranches::UpdateService.new(project, current_user, params) service.execute(protected_branch) end end @@ -37,12 +38,12 @@ module ProtectedBranches private def delete_redundant_access_levels - unless @developers_can_merge.nil? - @protected_branch.merge_access_levels.destroy_all # rubocop: disable DestroyAll + unless developers_can_merge.nil? + protected_branch.merge_access_levels.destroy_all # rubocop: disable DestroyAll end - unless @developers_can_push.nil? - @protected_branch.push_access_levels.destroy_all # rubocop: disable DestroyAll + unless developers_can_push.nil? + protected_branch.push_access_levels.destroy_all # rubocop: disable DestroyAll end end end diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index cb1bf0a03a5..d6af26d949d 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -2,6 +2,8 @@ module Search class GlobalService + include Gitlab::Utils::StrongMemoize + attr_accessor :current_user, :params attr_reader :default_project_filter @@ -19,11 +21,15 @@ module Search @projects ||= ProjectsFinder.new(current_user: current_user).execute end - def scope - @scope ||= begin - allowed_scopes = %w[issues merge_requests milestones] + def allowed_scopes + strong_memoize(:allowed_scopes) do + %w[issues merge_requests milestones] + end + end - allowed_scopes.delete(params[:scope]) { 'projects' } + def scope + strong_memoize(:scope) do + allowed_scopes.include?(params[:scope]) ? params[:scope] : 'projects' end end end diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb index cc47b46b527..1f720fc835f 100644 --- a/app/services/suggestions/apply_service.rb +++ b/app/services/suggestions/apply_service.rb @@ -11,7 +11,7 @@ module Suggestions return error('Suggestion is not appliable') end - unless latest_diff_refs?(suggestion) + unless latest_source_head?(suggestion) return error('The file has been changed') end @@ -29,12 +29,13 @@ module Suggestions private - # Checks whether the latest diff refs for the branch matches with - # the position refs we're using to update the file content. Since - # the persisted refs are updated async (for MergeRequest), - # it's more consistent to fetch this data directly from the repository. - def latest_diff_refs?(suggestion) - suggestion.position.diff_refs == suggestion.noteable.repository_diff_refs + # Checks whether the latest source branch HEAD matches with + # the position HEAD we're using to update the file content. Since + # the persisted HEAD is updated async (for MergeRequest), + # it's more consistent to fetch this data directly from the + # repository. + def latest_source_head?(suggestion) + suggestion.position.head_sha == suggestion.noteable.source_branch_sha end def file_update_params(suggestion) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index ec6c306227b..ea8ac7e4656 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -360,7 +360,7 @@ module SystemNoteService # author - User performing the change # branch_type - 'source' or 'target' # old_branch - old branch name - # new_branch - new branch nmae + # new_branch - new branch name # # Example Note text: # diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb new file mode 100644 index 00000000000..f6602a35033 --- /dev/null +++ b/app/services/task_list_toggle_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Finds the correct checkbox in the passed in markdown/html and toggles it's state, +# returning the updated markdown/html. +# We don't care if the text has changed above or below the specific checkbox, as long +# the checkbox still exists at exactly the same line number and the text is equal. +# If successful, new values are available in `updated_markdown` and `updated_markdown_html` +class TaskListToggleService + attr_reader :updated_markdown, :updated_markdown_html + + def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:) + @markdown, @markdown_html = markdown, markdown_html + @line_source, @line_number = line_source, line_number + @toggle_as_checked = toggle_as_checked + + @updated_markdown, @updated_markdown_html = nil + end + + def execute + return false unless markdown && markdown_html + + toggle_markdown && toggle_markdown_html + end + + private + + attr_reader :markdown, :markdown_html, :toggle_as_checked + attr_reader :line_source, :line_number + + def toggle_markdown + source_lines = markdown.split("\n") + source_line_index = line_number - 1 + markdown_task = source_lines[source_line_index] + + # The source in the DB could be using either \n or \r\n line endings + return unless markdown_task.chomp == line_source + return unless source_checkbox = Taskable::ITEM_PATTERN.match(markdown_task) + + currently_checked = TaskList::Item.new(source_checkbox[1]).complete? + + # Check `toggle_as_checked` to make sure we don't accidentally replace + # any `[ ]` or `[x]` in the middle of the text + if currently_checked + markdown_task.sub!(Taskable::COMPLETE_PATTERN, '[ ]') unless toggle_as_checked + else + markdown_task.sub!(Taskable::INCOMPLETE_PATTERN, '[x]') if toggle_as_checked + end + + source_lines[source_line_index] = markdown_task + @updated_markdown = source_lines.join("\n") + end + + def toggle_markdown_html + html = Nokogiri::HTML.fragment(markdown_html) + html_checkbox = get_html_checkbox(html) + return unless html_checkbox + + if toggle_as_checked + html_checkbox[:checked] = 'checked' + else + html_checkbox.remove_attribute('checked') + end + + @updated_markdown_html = html.to_html + end + + # When using CommonMark, we should be able to use the embedded `sourcepos` attribute to + # target the exact line in the DOM. + def get_html_checkbox(html) + html.css(".task-list-item[data-sourcepos^='#{line_number}:'] input.task-list-item-checkbox").first + end +end diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index db03ba8756f..e50840a9158 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -26,12 +26,15 @@ module Users def record_activity return if Gitlab::Database.read_only? + today = Date.today + + return if @user.last_activity_on == today + lease = Gitlab::ExclusiveLease.new("acitvity_service:#{@user.id}", timeout: LEASE_TIMEOUT) return unless lease.try_obtain - @user.update_attribute(:last_activity_on, Date.today) - Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@user.id} (username: #{@user.username})") + @user.update_attribute(:last_activity_on, today) end end end diff --git a/app/uploaders/external_diff_uploader.rb b/app/uploaders/external_diff_uploader.rb new file mode 100644 index 00000000000..d2707cd0777 --- /dev/null +++ b/app/uploaders/external_diff_uploader.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ExternalDiffUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.external_diffs + + alias_method :upload, :model + + def filename + "diff-#{model.id}" + end + + def store_dir + dynamic_segment + end + + private + + def dynamic_segment + File.join(model.model_name.plural, "mr-#{model.merge_request_id}") + end +end diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb index a7f8615e9ba..236b7ed2b3d 100644 --- a/app/uploaders/file_mover.rb +++ b/app/uploaders/file_mover.rb @@ -11,6 +11,8 @@ class FileMover end def execute + return unless valid? + move if update_markdown @@ -21,6 +23,12 @@ class FileMover private + def valid? + Pathname.new(temp_file_path).realpath.to_path.start_with?( + (Pathname(temp_file_uploader.root) + temp_file_uploader.base_dir).to_path + ) + end + def move FileUtils.mkdir_p(File.dirname(file_path)) FileUtils.move(temp_file_path, file_path) diff --git a/app/validators/sha_validator.rb b/app/validators/sha_validator.rb new file mode 100644 index 00000000000..085fca4d65d --- /dev/null +++ b/app/validators/sha_validator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ShaValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? || value.match(/\A\h{40}\z/) + + record.errors.add(attribute, 'is not a valid SHA') + end +end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb index d3e32818dc7..3fd015c3cf5 100644 --- a/app/validators/url_validator.rb +++ b/app/validators/url_validator.rb @@ -93,6 +93,12 @@ class UrlValidator < ActiveModel::EachValidator end def allow_setting_local_requests? + # We cannot use Gitlab::CurrentSettings as ApplicationSetting itself + # uses UrlValidator to validate urls. This ends up in a cycle + # when Gitlab::CurrentSettings creates an ApplicationSetting which then + # calls this validator. + # + # See https://gitlab.com/gitlab-org/gitlab-ee/issues/9833 ApplicationSetting.current&.allow_local_requests_from_hooks_and_services? end end diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index 544f09048f5..a5f34d0dab2 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -1,86 +1,97 @@ -= form_for @appearance, url: admin_appearances_path do |f| += form_for @appearance, url: admin_appearances_path, html: { class: 'prepend-top-default' } do |f| = form_errors(@appearance) - %fieldset.app_logo - %legend - Navigation bar: - .form-group.row - = f.label :header_logo, 'Header logo', class: 'col-sm-2 col-form-label pt-0' - .col-sm-10 - - if @appearance.header_logo? - = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' - - if @appearance.persisted? - %br - = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" - %hr - = f.hidden_field :header_logo_cache - = f.file_field :header_logo, class: "" - .hint - Maximum file size is 1MB. Pages are optimized for a 28px tall header logo - %fieldset.app_logo - %legend - Favicon: - .form-group.row - = f.label :favicon, 'Favicon', class: 'col-sm-2 col-form-label pt-0' - .col-sm-10 - - if @appearance.favicon? - = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' - - if @appearance.persisted? - %br - = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" - %hr - = f.hidden_field :favicon_cache - = f.file_field :favicon, class: '' - .hint - Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}. - %br - Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior. + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 Navigation bar - %fieldset.sign-in - %legend - Sign in/Sign up pages: - .form-group.row - = f.label :title, class: 'col-sm-2 col-form-label' - .col-sm-10 - = f.text_field :title, class: "form-control" - .form-group.row - = f.label :description, class: 'col-sm-2 col-form-label' - .col-sm-10 - = f.text_area :description, class: "form-control", rows: 10 - .hint - Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. - .form-group.row - = f.label :logo, class: 'col-sm-2 col-form-label pt-0' - .col-sm-10 - - if @appearance.logo? - = image_tag @appearance.logo_path, class: 'appearance-logo-preview' - - if @appearance.persisted? - %br - = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" - %hr - = f.hidden_field :logo_cache - = f.file_field :logo, class: "" - .hint - Maximum file size is 1MB. Pages are optimized for a 640x360 px logo. + .col-lg-8 + .form-group + = f.label :header_logo, 'Header logo', class: 'col-form-label label-bold pt-0' + %p + - if @appearance.header_logo? + = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' + - if @appearance.persisted? + %br + = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" + %hr + = f.hidden_field :header_logo_cache + = f.file_field :header_logo, class: "" + .hint + Maximum file size is 1MB. Pages are optimized for a 28px tall header logo + %hr + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 Favicon + + .col-lg-8 + .form-group + = f.label :favicon, 'Favicon', class: 'col-form-label label-bold pt-0' + %p + - if @appearance.favicon? + = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' + - if @appearance.persisted? + %br + = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" + %hr + = f.hidden_field :favicon_cache + = f.file_field :favicon, class: '' + .hint + Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}. + %br + Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior. + + = render partial: 'admin/appearances/system_header_footer_form', locals: { form: f } - %fieldset - %legend - New project pages: - .form-group.row - = f.label :new_project_guidelines, class: 'col-sm-2 col-form-label' - .col-sm-10 - = f.text_area :new_project_guidelines, class: "form-control", rows: 10 - .hint - Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. + %hr + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 Sign in/Sign up pages - .form-actions - = f.submit 'Save', class: 'btn btn-success append-right-10' - - if @appearance.persisted? - Preview last save: - = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + .col-lg-8 + .form-group + = f.label :title, class: 'col-form-label label-bold' + = f.text_field :title, class: "form-control" + .form-group + = f.label :description, class: 'col-form-label label-bold' + = f.text_area :description, class: "form-control", rows: 10 + .hint + Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. + .form-group + = f.label :logo, class: 'col-form-label label-bold pt-0' + %p + - if @appearance.logo? + = image_tag @appearance.logo_path, class: 'appearance-logo-preview' + - if @appearance.persisted? + %br + = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" + %hr + = f.hidden_field :logo_cache + = f.file_field :logo, class: "" + .hint + Maximum file size is 1MB. Pages are optimized for a 640x360 px logo. + + %hr + .row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 New project pages + + .col-lg-8 + .form-group + = f.label :new_project_guidelines, class: 'col-form-label label-bold' + %p + = f.text_area :new_project_guidelines, class: "form-control", rows: 10 + .hint + Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}. + + .prepend-top-default.append-bottom-default + = f.submit 'Update appearance settings', class: 'btn btn-success' + - if @appearance.persisted? + Preview last save: + = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - - if @appearance.updated_at - %span.float-right - Last edit #{time_ago_with_tooltip(@appearance.updated_at)} + - if @appearance.updated_at + %span.float-right + Last edit #{time_ago_with_tooltip(@appearance.updated_at)} diff --git a/app/views/admin/appearances/_system_header_footer_form.html.haml b/app/views/admin/appearances/_system_header_footer_form.html.haml new file mode 100644 index 00000000000..ca9d6adebeb --- /dev/null +++ b/app/views/admin/appearances/_system_header_footer_form.html.haml @@ -0,0 +1,24 @@ +- form = local_assigns.fetch(:form) + +%hr +.row + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + = _('System header and footer') + + .col-lg-8 + .form-group + = form.label :header_message, _('Header message'), class: 'col-form-label label-bold' + = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control js-autosize" + .form-group + = form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold' + = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control js-autosize" + .form-group.js-toggle-colors-container + %button.btn.btn-link.js-toggle-colors-link{ type: 'button' } + = _('Customize colors') + .form-group.js-toggle-colors-container.hide + = form.label :message_background_color, _('Background Color'), class: 'col-form-label label-bold' + = form.color_field :message_background_color, class: "form-control" + .form-group.js-toggle-colors-container.hide + = form.label :message_font_color, _('Font Color'), class: 'col-form-label label-bold' + = form.color_field :message_font_color, class: "form-control" diff --git a/app/views/admin/appearances/show.html.haml b/app/views/admin/appearances/show.html.haml index 454b779842c..ccf6f960cf2 100644 --- a/app/views/admin/appearances/show.html.haml +++ b/app/views/admin/appearances/show.html.haml @@ -1,9 +1,4 @@ - page_title "Appearance" - -%h3.page-title - Appearance settings -%p.light - You can modify the look and feel of GitLab here -%hr +- @content_class = "limit-container-width" unless fluid_layout = render 'form' diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 10bc3452d8b..65a24854583 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -15,7 +15,7 @@ = f.number_field :max_attachment_size, class: 'form-control' .form-group = f.label :receive_max_input_size, 'Maximum push size (MB)', class: 'label-light' - = f.number_field :receive_max_input_size, class: 'form-control' + = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field' .form-group = f.label :session_expire_delay, 'Session duration (minutes)', class: 'label-light' = f.number_field :session_expire_delay, class: 'form-control' @@ -46,4 +46,4 @@ = f.label :user_show_add_ssh_key_message, class: 'form-check-label' do Inform users without uploaded SSH keys that they can't push over SSH until one is added - = 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/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml new file mode 100644 index 00000000000..95d016a94a5 --- /dev/null +++ b/app/views/admin/application_settings/_localization.html.haml @@ -0,0 +1,11 @@ += form_for @application_setting, url: admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + = f.label :first_day_of_week, _('Default first day of the week'), class: 'label-bold' + = f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control' + .form-text.text-muted + = _('Default first day of the week in calendars and date pickers.') + + = 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 c6c29ed1f21..7a2bbfcdc4d 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -11,7 +11,6 @@ .form-text.text-muted Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance. - %em (EXPERIMENTAL) .form-group = f.label :repository_storages, 'Storage paths for new projects', class: 'label-bold' = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages), diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index 00000b86ab7..c468d69d7b8 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -56,3 +56,14 @@ = _('Configure Gitaly timeouts.') .settings-content = render 'gitaly' + +%section.settings.as-localization.no-animate#js-localization-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Localization') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Various localization settings.') + .settings-content + = render 'localization' diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index 65e4723afe6..fc9dd29b8ca 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -13,7 +13,7 @@ .settings-content = render 'visibility_and_access' -%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.qa-account-and-limit-settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 = _('Account and limit') diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index 0a688b90f3a..395c469255e 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -22,7 +22,7 @@ %span.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) } = visibility_level_icon(group.visibility_level, fw: false) - .avatar-container.s40 + .avatar-container.rect-avatar.s40 = group_icon(group, class: "avatar s40 d-none d-sm-block") .title = link_to [:admin, group], class: 'group-name' do diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index da2ebb08405..00d255846f9 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -15,7 +15,7 @@ = _('Group info:') %ul.content-list %li - .avatar-container.s60 + .avatar-container.rect-avatar.s60 = group_icon(@group, class: "avatar s60") %li %span.light= _('Name:') @@ -104,7 +104,7 @@ - link_to_help = link_to(_("here"), help_page_path("user/permissions")) = _('Read more about project permissions <strong>%{link_to_help}</strong>').html_safe % { link_to_help: link_to_help } - = form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do + = form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do %div = users_select_tag(:user_ids, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all) .prepend-top-10 diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index 50296a2afe7..5bc695aa7b5 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -19,7 +19,7 @@ .title = link_to(admin_namespace_project_path(project.namespace, project)) do .dash-project-avatar - .avatar-container.s40 + .avatar-container.rect-avatar.s40 = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) %span.project-full-name %span.namespace-name diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index 829d2c8949f..ecf2b1d60ba 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -18,19 +18,19 @@ .table-mobile-content = link_to runner.short_sha, admin_runner_path(runner) - .table-section.section-15 + .table-section.section-20 .table-mobile-header{ role: 'rowheader' }= _('Description') .table-mobile-content.str-truncated.has-tooltip{ title: runner.description } = runner.description - .table-section.section-15 + .table-section.section-10 .table-mobile-header{ role: 'rowheader' }= _('Version') .table-mobile-content.str-truncated.has-tooltip{ title: runner.version } = runner.version .table-section.section-10 .table-mobile-header{ role: 'rowheader' }= _('IP Address') - .table-mobile-content + .table-mobile-content.str-truncated.has-tooltip{ title: runner.ip_address } = runner.ip_address .table-section.section-5 @@ -44,12 +44,12 @@ .table-section.section-5 .table-mobile-header{ role: 'rowheader' }= _('Jobs') .table-mobile-content - = runner.builds.count(:all) + = limited_counter_with_delimiter(runner.builds) .table-section.section-10.section-wrap .table-mobile-header{ role: 'rowheader' }= _('Tags') .table-mobile-content - - runner.tag_list.sort.each do |tag| + - runner.tags.map(&:name).sort.each do |tag| %span.badge.badge-primary = tag diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index e9e4e0847d3..81380587fd2 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -106,8 +106,8 @@ .gl-responsive-table-row.table-row-header{ role: 'row' } .table-section.section-10{ role: 'rowheader' }= _('Type') .table-section.section-10{ role: 'rowheader' }= _('Runner token') - .table-section.section-15{ role: 'rowheader' }= _('Description') - .table-section.section-15{ role: 'rowheader' }= _('Version') + .table-section.section-20{ role: 'rowheader' }= _('Description') + .table-section.section-10{ role: 'rowheader' }= _('Version') .table-section.section-10{ role: 'rowheader' }= _('IP Address') .table-section.section-5{ role: 'rowheader' }= _('Projects') .table-section.section-5{ role: 'rowheader' }= _('Jobs') diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index a4e2c3252af..be7bfa958b2 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -1,36 +1,37 @@ -%li.flex-row - .user-avatar - = image_tag avatar_icon_for_user(user), class: "avatar", alt: '' - .row-main-content - .user-name.row-title.str-truncated-100 - = link_to user.name, [:admin, user], class: "js-user-link", data: { user_id: user.id } - - if user.blocked? - %span.badge.badge-danger blocked - - if user.admin? - %span.badge.badge-success Admin - - if user.external? - %span.badge.badge-secondary External - - if user == current_user - %span It's you! - .row-second-line.str-truncated-100 - = mail_to user.email, user.email - .controls - = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn' - - unless user == current_user - .dropdown.inline - %a.dropdown-new.btn.btn-default#project-settings-button{ href: '#', data: { toggle: 'dropdown' } } +.gl-responsive-table-row{ role: 'row' } + .table-section.section-40 + .table-mobile-header{ role: 'rowheader' } + = _('Name') + .table-mobile-content + = render 'user_detail', user: user + .table-section.section-25 + .table-mobile-header{ role: 'rowheader' } + = _('Created on') + .table-mobile-content + = l(user.created_at.to_date, format: :admin) + .table-section.section-15 + .table-mobile-header{ role: 'rowheader' } + = _('Last activity') + .table-mobile-content + = user.last_activity_on.nil? ? _('Never') : l(user.last_activity_on, format: :admin) + .table-section.section-20.table-button-footer + .table-action-buttons + = link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn btn-default' + - unless user == current_user + %button.dropdown-new.btn.btn-default{ type: 'button', data: { toggle: 'dropdown' } } = icon('cog') = icon('caret-down') %ul.dropdown-menu.dropdown-menu-right %li.dropdown-header - Settings + = _('Settings') %li - if user.ldap_blocked? - %span.small Cannot unblock LDAP blocked users + %span.small + = s_('AdminUsers|Cannot unblock LDAP blocked users') - elsif user.blocked? - = link_to 'Unblock', unblock_admin_user_path(user), method: :put + = link_to _('Unblock'), unblock_admin_user_path(user), method: :put - else - = link_to 'Block', block_admin_user_path(user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put + = link_to _('Block'), block_admin_user_path(user), data: { confirm: "#{s_('AdminUsers|User will be blocked').upcase}! #{_('Are you sure')}?" }, method: :put - if user.access_locked? %li = link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') } @@ -42,7 +43,7 @@ target: '#delete-user-modal', delete_user_url: admin_user_path(user), block_user_url: block_admin_user_path(user), - username: user.name, + username: sanitize_name(user.name), delete_contributions: false }, type: 'button' } = s_('AdminUsers|Delete user') @@ -51,6 +52,6 @@ target: '#delete-user-modal', delete_user_url: admin_user_path(user, hard_delete: true), block_user_url: block_admin_user_path(user), - username: user.name, + username: sanitize_name(user.name), delete_contributions: true }, type: 'button' } = s_('AdminUsers|Delete user and contributions') diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml new file mode 100644 index 00000000000..3319b4bad3a --- /dev/null +++ b/app/views/admin/users/_user_detail.html.haml @@ -0,0 +1,17 @@ +.flex-list + .flex-row + = image_tag avatar_icon_for_user(user), class: 'avatar s32 d-none d-md-flex', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) } + .row-main-content + .row-title.str-truncated-100 + = image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 prepend-top-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) } + = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id } + + = render_if_exists 'admin/users/user_detail_note', user: user + + - user_badges_in_admin_section(user).each do |badge| + - css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present? + %span{ class: css_badge } + = badge[:text] + + .row-second-line.str-truncated-100 + = mail_to user.email, user.email, class: 'text-secondary' diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 600120c4f05..c3d5ce0fe70 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -2,72 +2,78 @@ - page_title "Users" %div{ class: container_class } - .prepend-top-default + .top-area.scrolling-tabs-container.inner-page-scroll-tabs + .fade-left + = icon('angle-left') + .fade-right + = icon('angle-right') + %ul.nav-links.nav.nav-tabs.scrolling-tabs + = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do + = link_to admin_users_path do + = s_('AdminUsers|Active') + %small.badge.badge-pill= limited_counter_with_delimiter(User.active) + = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do + = link_to admin_users_path(filter: "admins") do + = s_('AdminUsers|Admins') + %small.badge.badge-pill= limited_counter_with_delimiter(User.admins) + = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do + = link_to admin_users_path(filter: 'two_factor_enabled') do + = s_('AdminUsers|2FA Enabled') + %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor) + = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do + = link_to admin_users_path(filter: 'two_factor_disabled') do + = s_('AdminUsers|2FA Disabled') + %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor) + = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do + = link_to admin_users_path(filter: 'external') do + = s_('AdminUsers|External') + %small.badge.badge-pill= limited_counter_with_delimiter(User.external) + = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do + = link_to admin_users_path(filter: "blocked") do + = s_('AdminUsers|Blocked') + %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) + = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do + = link_to admin_users_path(filter: "wop") do + = s_('AdminUsers|Without projects') + %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects) + .nav-controls + = render_if_exists 'admin/users/admin_email_users' + = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn btn-success btn-search float-right' + + .filtered-search-block.row-content-block.border-top-0 = form_tag admin_users_path, method: :get do - if params[:filter].present? = hidden_field_tag "filter", h(params[:filter]) .search-holder .search-field-holder - = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false + = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false - if @sort.present? = hidden_field_tag :sort, @sort = icon("search", class: "search-icon") - = button_tag 'Search users' if Rails.env.test? + = button_tag s_('AdminUsers|Search users') if Rails.env.test? .dropdown.user-sort-dropdown - toggle_text = if @sort.present? then users_sort_options_hash[@sort] else sort_title_name end = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-right %li.dropdown-header - Sort by + = s_('AdminUsers|Sort by') %li - users_sort_options_hash.each do |value, title| = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do = title - = link_to 'New user', new_admin_user_path, class: 'btn btn-success btn-search' - .top-area.scrolling-tabs-container.inner-page-scroll-tabs - .fade-left - = icon('angle-left') - .fade-right - = icon('angle-right') - %ul.nav-links.nav.nav-tabs.scrolling-tabs - = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do - = link_to admin_users_path do - Active - %small.badge.badge-pill= limited_counter_with_delimiter(User.active) - = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do - = link_to admin_users_path(filter: "admins") do - Admins - %small.badge.badge-pill= limited_counter_with_delimiter(User.admins) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do - = link_to admin_users_path(filter: 'two_factor_enabled') do - 2FA Enabled - %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor) - = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do - = link_to admin_users_path(filter: 'two_factor_disabled') do - 2FA Disabled - %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor) - = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do - = link_to admin_users_path(filter: 'external') do - External - %small.badge.badge-pill= limited_counter_with_delimiter(User.external) - = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do - = link_to admin_users_path(filter: "blocked") do - Blocked - %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) - = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do - = link_to admin_users_path(filter: "wop") do - Without projects - %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects) + - if @users.empty? + .nothing-here-block.border-top-0 + = s_('AdminUsers|No users found') + - else + .table-holder + .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' } + .table-section.section-40{ role: 'rowheader' }= _('Name') + .table-section.section-25{ role: 'rowheader' }= _('Created on') + .table-section.section-15{ role: 'rowheader' }= _('Last activity') - %ul.flex-list.content-list - - if @users.empty? - %li - .nothing-here-block No users found. - - else = render partial: 'admin/users/user', collection: @users = paginate @users, theme: "gitlab" #delete-user-modal - diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index a758a63dfb3..8d9c083d223 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -3,7 +3,7 @@ - awards_sort(grouped_emojis).each do |emoji, awards| %button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button", class: [(award_state_class(awardable, awards, current_user))], - data: { placement: "bottom", title: award_user_list(awards, current_user) } } + data: { title: award_user_list(awards, current_user) } } = emoji_icon(emoji) %span.award-control-text.js-counter = awards.count @@ -12,7 +12,7 @@ .award-menu-holder.js-award-holder %button.btn.award-control.has-tooltip.js-add-award{ type: 'button', 'aria-label': _('Add reaction'), - data: { title: _('Add reaction'), placement: "bottom" } } + data: { title: _('Add reaction') } } %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley') %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile') diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index 9de9143e8b1..c787d7420b7 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -6,7 +6,7 @@ - tooltip = "#{subject.name} - #{status.status_tooltip}" - if status.has_details? - = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do + = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do %span{ class: klass }= sprite_icon(status.icon) %span.ci-build-text= subject.name @@ -16,5 +16,5 @@ %span.ci-build-text= subject.name - if status.has_action? - = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do + = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do = sprite_icon(status.action_icon, css_class: "icon-action-#{status.action_icon}") diff --git a/app/views/clusters/clusters/_integration_form.html.haml b/app/views/clusters/clusters/_form.html.haml index 4c47e11927e..9fb91a39387 100644 --- a/app/views/clusters/clusters/_integration_form.html.haml +++ b/app/views/clusters/clusters/_form.html.haml @@ -20,12 +20,27 @@ .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") - else = text_field_tag :environment_scope, '*', class: 'col-md-6 form-control disabled', placeholder: s_('ClusterIntegration|Environment scope'), disabled: true - - environment_scope_url = 'https://docs.gitlab.com/ee/user/project/clusters/#setting-the-environment-scope-premium' + - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain') - environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url } .form-text.text-muted %code * = s_("ClusterIntegration| is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe } + .form-group + %h5= s_('ClusterIntegration|Base domain') + = field.text_field :base_domain, class: 'col-md-6 form-control js-select-on-focus qa-base-domain' + .form-text.text-muted + - auto_devops_url = help_page_path('topics/autodevops/index') + - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } + = s_('ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe } + - if @cluster.application_ingress_external_ip.present? + = s_('ClusterIntegration|Alternatively') + %code #{@cluster.application_ingress_external_ip}.nip.io + = s_('ClusterIntegration| can be used instead of a custom domain.') + - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-cluster-ip') + - custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url } + = s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe } + - if can?(current_user, :update_cluster, @cluster) .form-group - = field.submit _('Save changes'), class: 'btn btn-success' + = field.submit _('Save changes'), class: 'btn btn-success qa-save-domain' diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 85d1002243b..a9299af8d78 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -8,5 +8,5 @@ %h4= s_('ClusterIntegration|Did you know?') %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } %a.btn.btn-default{ href: 'https://goo.gl/AaJzRW', target: '_blank', rel: 'noopener noreferrer' } - Apply for credit + = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/clusters/clusters/gcp/_show.html.haml b/app/views/clusters/clusters/gcp/_show.html.haml deleted file mode 100644 index e9f05eaf453..00000000000 --- a/app/views/clusters/clusters/gcp/_show.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -.form-group - %label.append-bottom-10{ for: 'cluster-name' } - = s_('ClusterIntegration|Kubernetes cluster name') - .input-group - %input.form-control.cluster-name.js-select-on-focus{ value: @cluster.name, readonly: true } - %span.input-group-append - = clipboard_button(text: @cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default') - -= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| - = form_errors(@cluster) - - = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| - .form-group - = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL') - .input-group - = platform_kubernetes_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: true - %span.input-group-append - = clipboard_button(text: @cluster.platform_kubernetes.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default') - - .form-group - = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate') - .input-group - = platform_kubernetes_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: true - %span.input-group-append.clipboard-addon - = clipboard_button(text: @cluster.platform_kubernetes.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-blank') - - .form-group - = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token') - .input-group - = platform_kubernetes_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: true - %span.input-group-append - %button.btn.btn-default.input-group-text.js-show-cluster-token{ type: 'button' } - = s_('ClusterIntegration|Show') - = clipboard_button(text: @cluster.platform_kubernetes.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default') - - - if @cluster.allow_user_defined_namespace? - .form-group - = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)') - = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') - - .form-group - .form-check - = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' - = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') - - .form-group - = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 58d0a304363..9bab3bf56aa 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title 'Kubernetes' -- page_title "Kubernetes Clusters" +- breadcrumb_title _('Kubernetes') +- page_title _('Kubernetes Clusters') = render_gcp_signup_offer @@ -9,12 +9,12 @@ - else .top-area.adjust .nav-text - = s_("ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project") + = s_('ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project') = render 'clusters/clusters/buttons' - if @has_ancestor_clusters .bs-callout.bs-callout-info - = s_("ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.") + = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.') %strong = link_to _('More information'), help_page_path('user/group/clusters/', anchor: 'cluster-precedence') diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index eeeef6bd824..6a8af23e5e8 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title 'Kubernetes' -- page_title _("Kubernetes Cluster") +- breadcrumb_title _('Kubernetes') +- page_title _('Kubernetes Cluster') - active_tab = local_assigns.fetch(:active_tab, 'gcp') = javascript_include_tag 'https://apis.google.com/js/api.js' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 89a2dfdd69f..1ef76ef801e 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -1,7 +1,7 @@ - @content_class = "limit-container-width" unless fluid_layout -- add_to_breadcrumbs "Kubernetes Clusters", clusterable.index_path +- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path - breadcrumb_title @cluster.name -- page_title _("Kubernetes Cluster") +- page_title _('Kubernetes Cluster') - manage_prometheus_path = edit_project_service_path(@cluster.project, 'prometheus') if @project - expanded = Rails.env.test? @@ -31,7 +31,7 @@ %section#cluster-integration %h4= @cluster.name = render 'banner' - = render 'integration_form' + = render 'form' .cluster-applications-table#js-cluster-applications @@ -39,19 +39,16 @@ .settings-header %h4= s_('ClusterIntegration|Kubernetes cluster details') %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster') .settings-content - - if @cluster.managed? - = render 'clusters/clusters/gcp/show' - - else - = render 'clusters/clusters/user/show' + = render 'clusters/platforms/kubernetes/form', cluster: @cluster, platform: @cluster.platform_kubernetes, update_cluster_url_path: clusterable.cluster_path(@cluster) %section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) } .settings-header %h4= _('Advanced settings') %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? 'Collapse' : 'Expand' + = expanded ? _('Collapse') : _('Expand') %p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration") .settings-content = render 'advanced_settings' diff --git a/app/views/clusters/clusters/user/_show.html.haml b/app/views/clusters/clusters/user/_show.html.haml deleted file mode 100644 index cac8e72edd3..00000000000 --- a/app/views/clusters/clusters/user/_show.html.haml +++ /dev/null @@ -1,39 +0,0 @@ -= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field| - = form_errors(@cluster) - .form-group - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' - = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') - - = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| - .form-group - = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold' - = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL') - - .form-group - = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold' - = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)') - - .form-group - = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-bold' - .input-group - = platform_kubernetes_field.text_field :token, class: 'form-control js-cluster-token', type: 'password', placeholder: s_('ClusterIntegration|Token'), autocomplete: 'off' - %span.input-group-append.clipboard-addon - .input-group-text - %button.js-show-cluster-token.btn-blank{ type: 'button' } - = s_('ClusterIntegration|Show') - - - if @cluster.allow_user_defined_namespace? - .form-group - = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold' - = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') - - .form-group - .form-check - = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' - = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') - - .form-group - = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/clusters/platforms/kubernetes/_form.html.haml b/app/views/clusters/platforms/kubernetes/_form.html.haml new file mode 100644 index 00000000000..4a452b83112 --- /dev/null +++ b/app/views/clusters/platforms/kubernetes/_form.html.haml @@ -0,0 +1,58 @@ += form_for cluster, url: update_cluster_url_path, as: :cluster do |field| + = form_errors(cluster) + + .form-group + - if cluster.managed? + %label.append-bottom-10{ for: 'cluster-name' } + = s_('ClusterIntegration|Kubernetes cluster name') + .input-group + %input.form-control.cluster-name.js-select-on-focus{ value: cluster.name, readonly: true } + %span.input-group-append + = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default') + - else + = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' + .input-group + = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') + + = field.fields_for :platform_kubernetes, platform do |platform_field| + .form-group + = platform_field.label :api_url, s_('ClusterIntegration|API URL') + .input-group + = platform_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: cluster.managed? + - if cluster.managed? + %span.input-group-append + = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default') + + .form-group + = platform_field.label :ca_cert, s_('ClusterIntegration|CA Certificate') + .input-group + = platform_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: cluster.managed? + - if cluster.managed? + %span.input-group-append.clipboard-addon + = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-blank') + + .form-group + = platform_field.label :token, s_('ClusterIntegration|Token') + .input-group + = platform_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: cluster.managed? + %span.input-group-append + %button.btn.btn-default.input-group-text.js-show-cluster-token{ type: 'button' } + = s_('ClusterIntegration|Show') + - if cluster.managed? + = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default') + + - if cluster.allow_user_defined_namespace? + .form-group + = platform_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)') + = platform_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') + + .form-group + .form-check + = platform_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' + = platform_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' + .form-text.text-muted + = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + + .form-group + = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml index 8d99f84755a..a05d0190efb 100644 --- a/app/views/dashboard/_snippets_head.html.haml +++ b/app/views/dashboard/_snippets_head.html.haml @@ -1,9 +1,9 @@ .page-title-holder %h1.page-title= _('Snippets') - - if current_user + - if current_user && current_user.snippets.any? || @snippets.any? .page-title-controls - = link_to "New snippet", new_snippet_path, class: "btn btn-success", title: "New snippet" + = link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet") .top-area %ul.nav-links.nav.nav-tabs diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 2f7add600e4..19b06ba5cdd 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -1,6 +1,6 @@ - @hide_top_links = true - page_title "Groups" -- header_title "Groups", dashboard_groups_path +- header_title "Groups", dashboard_groups_path = render_if_exists "shared/gold_trial_callout" = render 'dashboard/groups_head' diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index ae0e38bf0ee..13822d36f15 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -13,6 +13,8 @@ .top-area = render 'shared/milestones_filter', counts: @milestone_states + .nav-controls + = render 'shared/milestones/search_form' .milestones %ul.content-list diff --git a/app/views/dashboard/projects/_starred_empty_state.html.haml b/app/views/dashboard/projects/_starred_empty_state.html.haml new file mode 100644 index 00000000000..bea27f1a456 --- /dev/null +++ b/app/views/dashboard/projects/_starred_empty_state.html.haml @@ -0,0 +1,9 @@ +.row.empty-state + .col-12 + .svg-content.svg-250 + = image_tag 'illustrations/starred_empty.svg' + .text-content + %h4.text-center + = s_("StarredProjectsEmptyState|You don't have starred projects yet.") + %p.text-secondary + = s_("StarredProjectsEmptyState|Visit a project page and press on a star icon. Then, you can find the project on this page.") diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index ad08409c8fe..3a45f6df017 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -1,8 +1,8 @@ - @hide_top_links = true - @no_container = true -- breadcrumb_title "Projects" -- page_title "Starred Projects" -- header_title "Projects", dashboard_projects_path +- breadcrumb_title _("Projects") +- page_title _("Starred Projects") +- header_title _("Projects"), dashboard_projects_path = render_if_exists "shared/gold_trial_callout" @@ -13,5 +13,4 @@ - if params[:filter_projects] || any_projects?(@projects) = render 'projects' - else - %h3.page-title You don't have starred projects yet - %p.slead Visit project page and press on star icon and it will appear on this page. + = render 'starred_empty_state' diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index 6eb067da95c..b649fe91c24 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -3,6 +3,14 @@ - header_title "Snippets", dashboard_snippets_path = render 'dashboard/snippets_head' -= render partial: 'snippets/snippets_scope_menu', locals: { include_private: true } +- if current_user.snippets.exists? + = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true } -= render partial: 'snippets/snippets', locals: { link_project: true } +.d-block.d-sm-none + + = link_to _("New snippet"), new_snippet_path, class: "btn btn-success btn-block", title: _("New snippet") + +- if current_user.snippets.exists? + = render partial: 'shared/snippets/list', locals: { link_project: true } +- else + = render 'shared/empty_states/snippets', button_path: new_snippet_path diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 4b8ad5acd5b..dd1edb5fdc9 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -7,7 +7,7 @@ = f.hidden_field :reset_password_token .form-group = f.label 'New password', for: "user_password" - = f.password_field :password, class: "form-control top qa-password-field", required: true, title: 'This field is required' + = f.password_field :password, class: "form-control top qa-password-field", required: true, title: 'This field is required' .form-group = f.label 'Confirm new password', for: "user_password_confirmation" = f.password_field :password_confirmation, class: "form-control bottom qa-password-confirmation", title: 'This field is required', required: true diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index 796c0cadda8..f856773526d 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -1,3 +1,5 @@ +- server = local_assigns.fetch(:server) + = form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do .form-group = label_tag :username, "#{server['label']} Username" diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml index fefdf5f9531..f49cdfbf8da 100644 --- a/app/views/devise/sessions/two_factor.html.haml +++ b/app/views/devise/sessions/two_factor.html.haml @@ -7,7 +7,7 @@ - resource_params = params[resource_name].presence || params = f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0) %div - = f.label 'Two-Factor Authentication code', name: :otp_attempt + = f.label 'Two-Factor Authentication code', name: :otp_attempt = f.text_field :otp_attempt, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.' %p.form-text.text-muted.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes. .prepend-top-20 diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 12271ee5adb..1b583ea85d6 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -5,7 +5,7 @@ .d-flex.justify-content-between.flex-wrap - providers.each do |provider| - has_icon = provider_has_icon?(provider) - = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'btn d-flex align-items-center omniauth-btn text-left oauth-login qa-saml-login-button', id: "oauth-login-#{provider}" do + = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do - if has_icon = provider_image_tag(provider) %span diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 004a3528d4b..9c7ca6ebbd4 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -3,12 +3,12 @@ = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f| .devise-errors = devise_error_messages! - .form-group + .name.form-group = f.label :name, 'Full name', class: 'label-bold' - = f.text_field :name, class: "form-control top qa-new-user-name", required: true, title: "This field is required." + = f.text_field :name, class: "form-control top qa-new-user-name js-block-emoji", required: true, title: _("This field is required.") .username.form-group = f.label :username, class: 'label-bold' - = f.text_field :username, class: "form-control middle qa-new-user-username", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: 'Please create a username with only alphanumeric characters.' + = f.text_field :username, class: "form-control middle qa-new-user-username js-block-emoji", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") %p.validation-error.hide Username is already taken. %p.validation-success.hide Username is available. %p.validation-pending.hide Checking username availability... diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 74791b81ccd..dae9a7acf6b 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -31,7 +31,7 @@ %strong= t scope, scope: [:doorkeeper, :scopes] .text-secondary= t scope, scope: [:doorkeeper, :scope_desc] .form-actions.text-right - = form_tag oauth_authorization_path, method: :delete, class: 'inline' do + = form_tag oauth_authorization_path, method: :delete, class: 'inline' do = hidden_field_tag :client_id, @pre_auth.client.uid = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri = hidden_field_tag :state, @pre_auth.state diff --git a/app/views/email_rejection_mailer/rejection.html.haml b/app/views/email_rejection_mailer/rejection.html.haml index c4ae7befe4e..666dab61f40 100644 --- a/app/views/email_rejection_mailer/rejection.html.haml +++ b/app/views/email_rejection_mailer/rejection.html.haml @@ -1,5 +1,5 @@ %p - Unfortunately, your email message to GitLab could not be processed. + = _("Unfortunately, your email message to GitLab could not be processed.") = markdown @reason = render_if_exists 'shared/additional_email_text' diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml index 0e13b2a6473..8d940ef1293 100644 --- a/app/views/email_rejection_mailer/rejection.text.haml +++ b/app/views/email_rejection_mailer/rejection.text.haml @@ -1,4 +1,4 @@ -Unfortunately, your email message to GitLab could not be processed. += _("Unfortunately, your email message to GitLab could not be processed.") \ = @reason = render_if_exists 'shared/additional_email_text' diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml index 6ae4c334f7f..e1b7804c5a7 100644 --- a/app/views/events/_events.html.haml +++ b/app/views/events/_events.html.haml @@ -1,4 +1,18 @@ +- illustration_path = 'illustrations/profile-page/activity.svg' +- current_user_empty_message_header = s_('UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!') +- primary_button_label = _('New group') +- primary_button_link = new_group_path +- secondary_button_label = _('Explore groups') +- secondary_button_link = explore_groups_path +- visitor_empty_message = _('No activities found') + - if @events.present? = render partial: 'events/event', collection: @events - else - .nothing-here-block= _("No activities found") + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path, + current_user_empty_message_header: current_user_empty_message_header, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + secondary_button_label: secondary_button_label, + secondary_button_link: secondary_button_link, + visitor_empty_message: visitor_empty_message } diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index fb0d2c3b8b0..90ed8e41d32 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -21,6 +21,6 @@ = link_to note.attachment.url, target: '_blank' do = image_tag note.attachment.url, class: 'note-image-attach' - else - = link_to note.attachment.url, target: '_blank', class: 'note-file-attach' do + = link_to note.attachment.url, target: '_blank', class: 'note-file-attach' do %i.fa.fa-paperclip = note.attachment_identifier diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml index 94fc4ac21d2..d23c8301b10 100644 --- a/app/views/explore/snippets/index.html.haml +++ b/app/views/explore/snippets/index.html.haml @@ -7,4 +7,4 @@ - else = render 'explore/head' -= render partial: 'snippets/snippets', locals: { link_project: true } += render partial: 'shared/snippets/list', locals: { link_project: true } diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 88e401081f4..39c0c113793 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -1,17 +1,58 @@ -.group-home-panel.text-center.border-bottom - %div{ class: container_class } - .avatar-container.s70.group-avatar - = group_icon(@group, class: "avatar s70 avatar-tile") - %h1.group-title - = @group.name - %span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } - = visibility_level_icon(@group.visibility_level, fw: false) +- can_create_subgroups = can?(current_user, :create_subgroup, @group) - - if @group.description.present? - .group-home-desc - = markdown_field(@group, :description) +.group-home-panel + .row.mb-3 + .home-panel-title-row.col-md-12.col-lg-6.d-flex + .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none + = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64) + .d-flex.flex-column.flex-wrap.align-items-baseline + .d-inline-flex.align-items-baseline + %h1.home-panel-title.prepend-top-8.append-bottom-5 + = @group.name + %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } + = visibility_level_icon(@group.visibility_level, fw: false, options: {class: 'icon'}) + .home-panel-metadata.d-flex.align-items-center.text-secondary + %span + = _("Group") + - if current_user + %span.access-request-links.prepend-left-8 + = render 'shared/members/access_request_links', source: @group - - if current_user - .group-buttons.d-none.d-sm-block - = render 'shared/members/access_request_buttons', source: @group - = render 'shared/notifications/button', notification_setting: @notification_setting + .home-panel-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end + - if current_user + .group-buttons + = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn' + - if can? current_user, :create_projects, @group + - new_project_label = _("New project") + - new_subgroup_label = _("New subgroup") + - if can_create_subgroups + .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } + %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } } + %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } } + = sprite_icon("arrow-down", css_class: "icon dropdown-btn-icon") + %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } } + %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } } + .menu-item + .icon-container + = icon("check", class: "list-item-checkmark") + .description + %strong= new_project_label + %span= s_("GroupsTree|Create a project in this group.") + %li.divider.droplap-item-ignore + %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } } + .menu-item + .icon-container + = icon("check", class: "list-item-checkmark") + .description + %strong= new_subgroup_label + %span= s_("GroupsTree|Create a subgroup in this group.") + - else + = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success" + + - if @group.description.present? + .group-home-desc.mt-1 + .home-panel-description + .home-panel-description-markdown.read-more-container + = markdown_field(@group, :description) + %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } + = _("Read more") diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 13d584f5f1d..2af3e861587 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -23,7 +23,7 @@ Members with access to %strong= @group.name %span.badge= @members.total_count - = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do + = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group .position-relative.append-right-8 = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index a9ce2fe5ab0..808bb1309b1 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -7,7 +7,7 @@ = render 'shared/issuable/nav', type: :merge_requests - if current_user .nav-controls - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests, with_feature_enabled: 'merge_requests', with_shared: false, include_projects_in_subgroups: true + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests, with_feature_enabled: 'merge_requests', with_shared: false, include_projects_in_subgroups: true = render 'shared/issuable/search_bar', type: :merge_requests diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index 39e3af5f6d2..b0ba846f204 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -1,20 +1,20 @@ -= form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| += form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| + = form_errors(@milestone) .row - = form_errors(@milestone) - .col-md-6 .form-group.row - = f.label :title, "Title", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :title, "Title" .col-sm-10 = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true .form-group.row.milestone-description - = f.label :description, "Description", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :description, "Description" .col-sm-10 = render layout: 'projects/md_preview', locals: { url: group_preview_markdown_path } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...', supports_autocomplete: false - .clearfix - .error-alert - + .clearfix + .error-alert = render "shared/milestones/form_dates", f: f .form-actions @@ -24,4 +24,3 @@ - else = f.submit 'Update milestone', class: "btn-success btn" = link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn btn-cancel" - diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index af4fe8f2ef8..b6fb908c8f6 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -4,6 +4,7 @@ = render 'shared/milestones_filter', counts: @milestone_states .nav-controls + = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @group) = link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-success" diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 0424ece037d..9ed71d19d32 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -20,7 +20,7 @@ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group .form-group.prepend-top-default.append-bottom-20 - .avatar-container.s90 + .avatar-container.rect-avatar.s90 = group_icon(@group, alt: '', class: 'avatar group-avatar s90') = f.label :avatar, _('Group avatar'), class: 'label-bold d-block' = render 'shared/choose_group_avatar_button', f: f diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index cc294f6a931..77fe88dacb7 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,66 +1,41 @@ - @no_container = true - breadcrumb_title _("Details") -- can_create_subgroups = can?(current_user, :create_subgroup, @group) +- @content_class = "limit-container-width" unless fluid_layout = content_for :meta_tags do = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") -= render 'groups/home_panel' - -.groups-listing{ class: container_class, data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } - .top-area.group-nav-container - .group-search - = render "shared/groups/search_form" - - if can? current_user, :create_projects, @group - - new_project_label = _("New project") - - new_subgroup_label = _("New subgroup") - - if can_create_subgroups - .btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } - %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } } - %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } } - = icon("caret-down", class: "dropdown-btn-icon") - %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } } - %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } } - .menu-item - .icon-container - = icon("check", class: "list-item-checkmark") - .description - %strong= new_project_label - %span= s_("GroupsTree|Create a project in this group.") - %li.divider.droplap-item-ignore - %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } } - .menu-item - .icon-container - = icon("check", class: "list-item-checkmark") - .description - %strong= new_subgroup_label - %span= s_("GroupsTree|Create a subgroup in this group.") - - else - = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success" - - .scrolling-tabs-container.inner-page-scroll-tabs - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs - %li.js-subgroups_and_projects-tab - = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do - = _("Subgroups and projects") - %li.js-shared-tab - = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do - = _("Shared projects") - %li.js-archived-tab - = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do - = _("Archived projects") - - .nav-controls - = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash - - .tab-content - #subgroups_and_projects.tab-pane - = render "subgroups_and_projects", group: @group - - #shared.tab-pane - = render "shared_projects", group: @group - - #archived.tab-pane - = render "archived_projects", group: @group +%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } + = render 'groups/home_panel' + + .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } + .top-area.group-nav-container + .scrolling-tabs-container.inner-page-scroll-tabs + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs + %li.js-subgroups_and_projects-tab + = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do + = _("Subgroups and projects") + %li.js-shared-tab + = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do + = _("Shared projects") + %li.js-archived-tab + = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do + = _("Archived projects") + + .nav-controls.d-block.d-md-flex + .group-search + = render "shared/groups/search_form" + + = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash + + .tab-content + #subgroups_and_projects.tab-pane + = render "subgroups_and_projects", group: @group + + #shared.tab-pane + = render "shared_projects", group: @group + + #archived.tab-pane + = render "archived_projects", group: @group diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index b24d6e27536..057225d021f 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -4,7 +4,7 @@ - content_for :page_specific_javascripts do = stylesheet_link_tag 'page_bundles/ide' -#ide.ide-loading{ data: ide_data() } +#ide.ide-loading{ data: ide_data } .text-center = icon('spinner spin 2x') %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index f4a29ed18dc..b05c039c85c 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -1,56 +1,9 @@ - provider = local_assigns.fetch(:provider) - provider_title = Gitlab::ImportSources.title(provider) -%p.light - = import_githubish_choose_repository_message -%hr -%p - = button_tag class: "btn btn-import btn-success js-import-all" do - = import_all_githubish_repositories_button_label - = icon("spinner spin", class: "loading-icon") - -.table-responsive - %table.table.import-jobs - %colgroup.import-jobs-from-col - %colgroup.import-jobs-to-col - %colgroup.import-jobs-status-col - %thead - %tr - %th= _('From %{provider_title}') % { provider_title: provider_title } - %th= _('To GitLab') - %th= _('Status') - %tbody - - @already_added_projects.each do |project| - %tr{ id: "project_#{project.id}", class: project_status_css_class(project.import_status) } - %td - = provider_project_link(provider, project.import_source) - %td - = link_to project.full_path, [project.namespace.becomes(Namespace), project] - %td.job-status - = render 'import/project_status', project: project - - - @repos.each do |repo| - %tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } } - %td - = provider_project_link(provider, repo.full_name) - %td.import-target - %fieldset.row - .input-group - .project-path.input-group-prepend - - if current_user.can_select_namespace? - - selected = params[:namespace_id] || :current_user - - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {} - = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 } - - else - = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true - %span.input-group-prepend - .input-group-text / - = text_field_tag :path, sanitize_project_name(repo.name), class: "input-mini form-control", tabindex: 2, autofocus: true, required: true - %td.import-actions.job-status - = button_tag class: "btn btn-import js-add-to-import" do - = has_ci_cd_only_params? ? _('Connect') : _('Import') - = icon("spinner spin", class: "loading-icon") - -.js-importer-status{ data: { jobs_import_path: url_for([:jobs, :import, provider]), - import_path: url_for([:import, provider]), - ci_cd_only: has_ci_cd_only_params?.to_s } } +#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title, + can_select_namespace: current_user.can_select_namespace?.to_s, + ci_cd_only: has_ci_cd_only_params?.to_s, + repos_path: url_for([:status, :import, provider, format: :json]), + jobs_path: url_for([:realtime_changes, :import, provider, format: :json]), + import_path: url_for([:import, provider, format: :json]) } } diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index ef69197e453..9280f12e187 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -56,7 +56,7 @@ .project-path.input-group-prepend - if current_user.can_select_namespace? - selected = params[:namespace_id] || :extra_group - - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.project_key, path: repo.project_key) } : {} + - opts = current_user.can_create_group? ? { extra_group: Group.new(name: sanitize_project_name(repo.project_key), path: sanitize_project_name(repo.project_key)) } : {} = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 } - else = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 6ff25f2c842..cf32c5c9387 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -4,7 +4,7 @@ - header_title _("Projects"), root_path %h3.page-title - = icon 'github', text: import_github_title + = icon 'github', text: _('Import repositories from GitHub') - if github_import_configured? %p diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index be057be6d1a..ee295e70cce 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -2,7 +2,7 @@ - page_title title - breadcrumb_title title - header_title _("Projects"), root_path -%h3.page-title - = icon 'github', text: import_github_title +%h3.page-title.mb-0 + = icon 'github', class: 'fa-2x', text: _('Import repositories from GitHub') = render 'import/githubish_status', provider: 'github' diff --git a/app/views/import/manifest/status.html.haml b/app/views/import/manifest/status.html.haml index 5b2e1005398..3d4abc32b88 100644 --- a/app/views/import/manifest/status.html.haml +++ b/app/views/import/manifest/status.html.haml @@ -7,7 +7,7 @@ %p = button_tag class: "btn btn-import btn-success js-import-all" do - = import_all_githubish_repositories_button_label + = _('Import all repositories') = icon("spinner spin", class: "loading-icon") .table-responsive diff --git a/app/views/instance_statistics/conversational_development_index/_callout.html.haml b/app/views/instance_statistics/conversational_development_index/_callout.html.haml index 33a4dab1e00..a4256e23979 100644 --- a/app/views/instance_statistics/conversational_development_index/_callout.html.haml +++ b/app/views/instance_statistics/conversational_development_index/_callout.html.haml @@ -2,12 +2,12 @@ .user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } } .bordered-box.landing.content-block %button.btn.btn-default.close.js-close-callout{ type: 'button', - 'aria-label' => 'Dismiss ConvDev introduction' } + 'aria-label' => _('Dismiss ConvDev introduction') } = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') .user-callout-copy %h4 - Introducing Your Conversational Development Index + = _('Introducing Your Conversational Development Index') %p - Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers. + = _('Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.') .svg-container.convdev = custom_icon('convdev_overview') diff --git a/app/views/instance_statistics/conversational_development_index/_card.html.haml b/app/views/instance_statistics/conversational_development_index/_card.html.haml index 57eda06630b..76af55dcf7a 100644 --- a/app/views/instance_statistics/conversational_development_index/_card.html.haml +++ b/app/views/instance_statistics/conversational_development_index/_card.html.haml @@ -9,11 +9,11 @@ .board-card-score .board-card-score-value = format_score(card.instance_score) - .board-card-score-name You + .board-card-score-name= _('You') .board-card-score .board-card-score-value = format_score(card.leader_score) - .board-card-score-name Lead + .board-card-score-name= _('Lead') .board-card-score-big = number_to_percentage(card.percentage_score, precision: 1) .board-card-buttons diff --git a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml index dd795aee135..4e8f34cd574 100644 --- a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml +++ b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml @@ -1,7 +1,7 @@ .container.convdev-empty .col-sm-12.justify-content-center.text-center = custom_icon('convdev_no_data') - %h4 Data is still calculating... + %h4= _('Data is still calculating...') %p - In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index. - = link_to 'Learn more', help_page_path('user/instance_statistics/convdev'), target: '_blank' + = _('In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.') + = link_to _('Learn more'), help_page_path('user/instance_statistics/convdev'), target: '_blank' diff --git a/app/views/instance_statistics/conversational_development_index/index.html.haml b/app/views/instance_statistics/conversational_development_index/index.html.haml index 1e7db4982d6..23f90b876a0 100644 --- a/app/views/instance_statistics/conversational_development_index/index.html.haml +++ b/app/views/instance_statistics/conversational_development_index/index.html.haml @@ -17,9 +17,9 @@ %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" } = number_to_percentage(@metric.average_percentage_score, precision: 1) .convdev-header-subtitle - index + = _('index') %br - score + = _('score') = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/convdev') .convdev-cards.board-card-container diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 08a6359f777..11e83ddfe64 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -11,20 +11,20 @@ %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' } -# Open Graph - http://ogp.me/ - %meta{ property: 'og:type', content: "object" } - %meta{ property: 'og:site_name', content: site_name } - %meta{ property: 'og:title', content: page_title } + %meta{ property: 'og:type', content: "object" } + %meta{ property: 'og:site_name', content: site_name } + %meta{ property: 'og:title', content: page_title } %meta{ property: 'og:description', content: page_description } - %meta{ property: 'og:image', content: page_image } + %meta{ property: 'og:image', content: page_image } %meta{ property: 'og:image:width', content: '64' } %meta{ property: 'og:image:height', content: '64' } - %meta{ property: 'og:url', content: request.base_url + request.fullpath } + %meta{ property: 'og:url', content: request.base_url + request.fullpath } -# Twitter Card - https://dev.twitter.com/cards/types/summary - %meta{ property: 'twitter:card', content: "summary" } - %meta{ property: 'twitter:title', content: page_title } - %meta{ property: 'twitter:description', content: page_description } - %meta{ property: 'twitter:image', content: page_image } + %meta{ property: 'twitter:card', content: "summary" } + %meta{ property: 'twitter:title', content: page_title } + %meta{ property: 'twitter:description', content: page_description } + %meta{ property: 'twitter:image', content: page_image } = page_card_meta_tags %title= page_title(site_name) @@ -33,11 +33,13 @@ = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' = stylesheet_link_tag "application", media: "all" - = stylesheet_link_tag "print", media: "print" - = stylesheet_link_tag "test", media: "all" if Rails.env.test? + = stylesheet_link_tag "print", media: "print" + = stylesheet_link_tag "test", media: "all" if Rails.env.test? = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? = stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab) + = stylesheet_link_tag "highlight/themes/#{user_color_scheme}", media: "all" + = Gon::Base.render_data - if content_for?(:library_javascripts) @@ -61,10 +63,10 @@ %meta{ name: 'theme-color', content: '#474D57' } -# Apple Safari/iOS home screen icons - = favicon_link_tag 'touch-icon-iphone.png', rel: 'apple-touch-icon' - = favicon_link_tag 'touch-icon-ipad.png', rel: 'apple-touch-icon', sizes: '76x76' + = favicon_link_tag 'touch-icon-iphone.png', rel: 'apple-touch-icon' + = favicon_link_tag 'touch-icon-ipad.png', rel: 'apple-touch-icon', sizes: '76x76' = favicon_link_tag 'touch-icon-iphone-retina.png', rel: 'apple-touch-icon', sizes: '120x120' - = favicon_link_tag 'touch-icon-ipad-retina.png', rel: 'apple-touch-icon', sizes: '152x152' + = favicon_link_tag 'touch-icon-ipad-retina.png', rel: 'apple-touch-icon', sizes: '152x152' %link{ rel: 'mask-icon', href: image_path('logo.svg'), color: 'rgb(226, 67, 41)' } -# Windows 8 pinned site tile diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml index ddc1cdb24b5..26fd34347ec 100644 --- a/app/views/layouts/_mailer.html.haml +++ b/app/views/layouts/_mailer.html.haml @@ -49,7 +49,7 @@ %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" } %tbody %tr.line - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" }  + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" } %tr.header %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } = header_logo diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 4373240001e..043cca6ad38 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -5,7 +5,9 @@ = render "layouts/init_auto_complete" if @gfm_form = render "layouts/init_client_detection_flags" = render 'peek/bar' - = render partial: "layouts/header/default", locals: { project: @project, group: @group } + = header_message + = render partial: "layouts/header/default", locals: { project: @project, group: @group } = render 'layouts/page', sidebar: sidebar, nav: nav + = footer_message = yield :scripts_body diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 6003d973c88..2f3c13aaf6e 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -2,6 +2,7 @@ %html.devise-layout-html{ class: system_message_class } = render "layouts/head" %body.ui-indigo.login-page.application.navless.qa-login-page{ data: { page: body_data_page } } + = header_message .page-wrap = render "layouts/header/empty" .login-page-broadcast @@ -34,3 +35,4 @@ = link_to _("Explore"), explore_root_path = link_to _("Help"), help_path = link_to _("About GitLab"), "https://about.gitlab.com/" + = footer_message diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 663e5b24368..6c9c8aa4431 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -2,6 +2,7 @@ %html{ lang: "en", class: system_message_class } = render "layouts/head" %body.ui-indigo.login-page.application.navless + = header_message = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container @@ -15,3 +16,4 @@ = link_to _("Explore"), explore_root_path = link_to _("Help"), help_path = link_to _("About GitLab"), "https://about.gitlab.com/" + = footer_message diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index bfbfeee7c4b..1d40b78fa83 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -1,7 +1,7 @@ - page_title @group.name - page_description @group.description unless page_description - header_title group_title(@group) unless header_title -- nav "group" +- nav "group" - @left_sidebar = true - content_for :page_specific_javascripts do diff --git a/app/views/layouts/header/_empty.html.haml b/app/views/layouts/header/_empty.html.haml index 2dfc787b7a8..348ce18b122 100644 --- a/app/views/layouts/header/_empty.html.haml +++ b/app/views/layouts/header/_empty.html.haml @@ -1,4 +1,2 @@ %header.navbar.fixed-top.navbar-empty - .container - .mx-auto - = brand_header_logo + = brand_header_logo diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 513902890af..cd9128c452b 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -1,12 +1,8 @@ -- show_blog_link = current_user_menu?(:help) && blog_post_url.present? %ul - if current_user_menu?(:help) %li = link_to _("Help"), help_path %li.divider - - if show_blog_link - %li - = link_to _("What's new?"), blog_post_url %li = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index e42251f9ec8..5a66b02c048 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -1,4 +1,4 @@ -%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown" } } +%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown" } } = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do = sprite_icon('plus-square', size: 16) = sprite_icon('angle-down', css_class: 'caret-down') diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index ddd30efe062..f659c89dd30 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -10,7 +10,7 @@ = render "layouts/nav/projects_dropdown/show" - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown" } }) do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown" } }) do %button{ type: 'button', data: { toggle: "dropdown" } } = _('Groups') = sprite_icon('angle-down', css_class: 'caret-down') diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 5f15ba87729..2fdd65f639b 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -207,7 +207,7 @@ = _('Settings') %li.divider.fly-out-top-item = nav_link(path: 'application_settings#show') do - = link_to admin_application_settings_path, title: _('General') do + = link_to admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do %span = _('General') = nav_link(path: 'application_settings#integrations') do diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index bf475c07711..21ea9f3b2f3 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -6,7 +6,7 @@ .nav-sidebar-inner-scroll .context-header = link_to group_path(@group), title: @group.name do - .avatar-container.s40.group-avatar + .avatar-container.rect-avatar.s40.group-avatar = group_icon(@group, class: "avatar s40 avatar-tile") .sidebar-context-title = @group.name @@ -36,7 +36,7 @@ %span = _('Activity') - = render_if_exists 'groups/sidebar/security_dashboard' + = render_if_exists 'groups/sidebar/security_dashboard' # EE-specific - if group_sidebar_link?(:contribution_analytics) = nav_link(path: 'analytics#show') do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index b82c6bc1922..7b492efeb09 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -3,7 +3,7 @@ - can_edit = can?(current_user, :admin_project, @project) .context-header = link_to project_path(@project), title: @project.name do - .avatar-container.s40.project-avatar + .avatar-container.rect-avatar.s40.project-avatar = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40) .sidebar-context-title = @project.name @@ -26,7 +26,7 @@ %span= _('Details') = nav_link(path: 'projects#activity') do - = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do + = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity qa-activity-link' do %span= _('Activity') - if project_nav_tab?(:releases) @@ -146,7 +146,7 @@ - if project_nav_tab? :merge_requests = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do - = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests' do + = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests qa-merge-requests-link' do .nav-icon-container = sprite_icon('git-merge') %span.nav-item-name @@ -170,7 +170,7 @@ = _('CI / CD') %ul.sidebar-sub-level-items - = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts], html_options: { class: "fly-out-top-item" } ) do + = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts], html_options: { class: "fly-out-top-item" }) do = link_to project_pipelines_path(@project) do %strong.fly-out-top-item-name = _('CI / CD') @@ -284,7 +284,7 @@ - if project_nav_tab?(:wiki) - wiki_url = project_wiki_path(@project, :home) = nav_link(controller: :wikis) do - = link_to wiki_url, class: 'shortcuts-wiki' do + = link_to wiki_url, class: 'shortcuts-wiki qa-wiki-link' do .nav-icon-container = sprite_icon('book') %span.nav-item-name diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index 6418500d5d1..5f986c81ff4 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -1,4 +1,4 @@ -- header_title _("Snippets"), snippets_path +- header_title _("Snippets"), snippets_path - content_for :page_specific_javascripts do - if @snippet && current_user diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml index 472c31e9a5e..b766cb1a523 100644 --- a/app/views/notify/issue_moved_email.html.haml +++ b/app/views/notify/issue_moved_email.html.haml @@ -1,6 +1,9 @@ %p Issue was moved to another project. -%p - New issue: - = link_to project_issue_url(@new_project, @new_issue) do - = @new_issue.title +- if @can_access_project + %p + New issue: + = link_to project_issue_url(@new_project, @new_issue) do + = @new_issue.title +- else + You don't have access to the project. diff --git a/app/views/notify/issue_moved_email.text.erb b/app/views/notify/issue_moved_email.text.erb index 66ede43635b..985e689aa9d 100644 --- a/app/views/notify/issue_moved_email.text.erb +++ b/app/views/notify/issue_moved_email.text.erb @@ -1,4 +1,8 @@ Issue was moved to another project. +<% if @can_access_project %> New issue location: <%= project_issue_url(@new_project, @new_issue) %> +<% else %> +You don't have access to the project. +<% end %> diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml index 23ef31a0c85..2bf514d72a5 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -23,9 +23,3 @@ %strong Signed in on = l(active_session.created_at, format: :short) - - - unless is_current_session - .float-right - = link_to profile_active_session_path(active_session.session_id), data: { confirm: 'Are you sure? The device will be signed out of GitLab.' }, method: :delete, class: "btn btn-danger prepend-left-10" do - %span.sr-only Revoke - Revoke diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index d265f3c44ba..4b84835429c 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -1,12 +1,13 @@ -- page_title "New Password" -- header_title "New Password" -%h3.page-title Set up new password +- page_title _('New Password') +- breadcrumb_title _('New Password') + +%h3.page-title= _('Set up new password') %hr = form_for @user, url: profile_password_path, method: :post do |f| %p.slead - Please set a new password before proceeding. + = _('Please set a new password before proceeding.') %br - After a successful password update you will be redirected to login screen. + = _('After a successful password update you will be redirected to login screen.') = form_errors(@user) @@ -22,4 +23,4 @@ .col-sm-10 = f.password_field :password_confirmation, required: true, class: 'form-control' .form-actions - = f.submit 'Set new password', class: "btn btn-success" + = f.submit _('Set new password'), class: 'btn btn-success' diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 7c378633667..bfe1c3ddf33 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -51,11 +51,36 @@ = f.label :dashboard, class: 'label-bold' do Default dashboard = f.select :dashboard, dashboard_choices, {}, class: 'form-control' + + = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific + .form-group = f.label :project_view, class: 'label-bold' do Project overview content = f.select :project_view, project_view_choices, {}, class: 'form-control' .form-text.text-muted Choose what content you want to see on a project’s overview page. + + .col-sm-12 + %hr + + .col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + = _('Localization') + %p + = _('Customize language and region related settings.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank' + .col-lg-8 + .form-group + = f.label :preferred_language, class: 'label-bold' do + = _('Language') + = f.select :preferred_language, language_choices, {}, class: 'select2' + .form-text.text-muted + = s_('Preferences|This feature is experimental and translations are not complete yet') + .form-group + = f.label :first_day_of_week, class: 'label-bold' do + = _('First day of the week') + = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'form-control' .form-group - = f.submit 'Save changes', class: 'btn btn-success' + = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 2629b374e7c..4d3d92d09c0 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -2,7 +2,7 @@ - @content_class = "limit-container-width" unless fluid_layout - gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host -= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f| += bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f| = form_errors(@user) .row @@ -77,10 +77,10 @@ .col-lg-8 .row - if @user.read_only_attribute?(:name) - = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' }, + = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } - else - = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: s_("Profiles|Enter your name, so people you know can recognize you") + = f.text_field :name, label: 'Full name', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' } - if @user.read_only_attribute?(:email) @@ -95,9 +95,6 @@ = f.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)), { help: s_("Profiles|This email will be used for web based operations, such as edits and merges. %{learn_more}").html_safe % { learn_more: commit_email_docs_link } }, control_class: 'select2 input-lg' - = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, - { help: s_("Profiles|This feature is experimental and translations are not complete yet") }, - control_class: 'select2 input-lg' = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") = f.text_field :linkedin, class: 'input-md', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") = f.text_field :twitter, class: 'input-md', placeholder: s_("Profiles|@username") diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 91deffe07c1..409b6dba9ca 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -33,9 +33,9 @@ %p Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page. - if project.export_status == :finished - = link_to 'Download export', download_export_project_path(project), + = link_to 'Download export', download_export_project_path(project), rel: 'nofollow', download: '', method: :get, class: "btn btn-default" - = link_to 'Generate new export', generate_new_export_project_path(project), + = link_to 'Generate new export', generate_new_export_project_path(project), method: :post, class: "btn btn-default" - else = link_to 'Export project', export_project_path(project), diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index e8cc3d6bcf0..bba303c906c 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,17 +1,17 @@ - empty_repo = @project.empty_repo? - show_auto_devops_callout = show_auto_devops_callout?(@project) .project-home-panel{ class: ("empty-project" if empty_repo) } - .project-header.row.append-bottom-8 - .project-title-row.col-md-12.col-lg-6.d-flex - .avatar-container.project-avatar.float-none + .row.append-bottom-8 + .home-panel-title-row.col-md-12.col-lg-6.d-flex + .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64) .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.project-title.qa-project-name + %h1.home-panel-title.prepend-top-8.append-bottom-5.qa-project-name = @project.name - %span.project-visibility.prepend-left-8.d-inline-flex.align-items-baseline.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } + %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) - .project-metadata.d-flex.align-items-center + .home-panel-metadata.d-flex.align-items-center.text-secondary - if can?(current_user, :read_project, @project) %span.text-secondary = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } @@ -19,17 +19,22 @@ %span.access-request-links.prepend-left-8 = render 'shared/members/access_request_links', source: @project - if @project.tag_list.present? - %span.project-topic-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.tag_list.join(', ') : nil } + %span.home-panel-topic-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.tag_list.join(', ') : nil } = sprite_icon('tag', size: 16, css_class: 'icon append-right-4') - = @project.topics_to_show + + - @project.topics_to_show.each do |topic| + %a{ class: 'badge badge-pill badge-secondary append-right-5 str-truncated-30', href: explore_projects_path(tag: topic) } + = topic.titleize + - if @project.has_extra_topics? - = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown } + .text-nowrap + = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown } .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end - if current_user .d-inline-flex - = render 'projects/buttons/notifications', notification_setting: @notification_setting, btn_class: 'btn-xs' + = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs' .count-buttons.d-inline-flex = render 'projects/buttons/star' @@ -44,13 +49,13 @@ - if can?(current_user, :download_code, @project) %nav.project-stats - .nav-links.quick-links.mt-3 + .nav-links.quick-links = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) - .project-home-desc.mt-1 + .home-panel-home-desc.mt-1 - if @project.description.present? - .project-description - .project-description-markdown.read-more-container + .home-panel-description + .home-panel-description-markdown.read-more-container = markdown_field(@project, :description) %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } = _("Read more") diff --git a/app/views/projects/_issuable_by_email.html.haml b/app/views/projects/_issuable_by_email.html.haml index 147e73f047f..0b2d179456d 100644 --- a/app/views/projects/_issuable_by_email.html.haml +++ b/app/views/projects/_issuable_by_email.html.haml @@ -21,7 +21,7 @@ = clipboard_button(target: '#issuable_email', class: 'btn btn-clipboard input-group-text btn-transparent d-none d-sm-block') - if issuable_type == 'issue' - - enter_title_text = _('Enter the issue title') + - enter_title_text = _('Enter the issue title') - enter_description_text = _('Enter the issue description') - else - enter_title_text = _('Enter the merge request title') diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index ba7d3154326..276363df7da 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -43,7 +43,7 @@ = f.label :description, class: 'label-bold' do Project description %span (optional) - = f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" } + = f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" } = f.label :visibility_level, class: 'label-bold' do Visibility Level diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml index 45e1d32980c..de4653dad2c 100644 --- a/app/views/projects/_wiki.html.haml +++ b/app/views/projects/_wiki.html.haml @@ -2,7 +2,7 @@ %div{ class: container_class } .prepend-top-default.append-bottom-default .wiki - = render_wiki_content(@wiki_home, legacy_render_context(params)) + = render_wiki_content(@wiki_home) - else - can_create_wiki = can?(current_user, :create_wiki, @project) .landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] } diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 3f2d96b70e5..4520cca8cf5 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -21,7 +21,7 @@ Write %li - = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id, legacy_render: params[:legacy_render]) do + = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id) do = editing_preview_title(@blob.name) = form_tag(project_update_blob_path(@project, @id), method: :put, class: 'js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths(@project)) do diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index ff460a3831c..66687f087ff 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -2,7 +2,7 @@ .diff-content - if markup?(@blob.name) .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } - = markup(@blob.name, @content, legacy_render_context(params)) + = markup(@blob.name, @content) - else .file-content.code.js-syntax-highlight - unless @diff_lines.empty? diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml index 87aa7c1dbf8..5970d41fdab 100644 --- a/app/views/projects/blob/viewers/_dependency_manager.html.haml +++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml @@ -3,9 +3,4 @@ This project manages its dependencies using %strong= viewer.manager_name - - if viewer.package_name - and defines a #{viewer.package_type} named - %strong< - = link_to_if viewer.package_url.present?, viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer' - = link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml index 120c0540335..df1f3e4e01b 100644 --- a/app/views/projects/blob/viewers/_loading.html.haml +++ b/app/views/projects/blob/viewers/_loading.html.haml @@ -1,2 +1,2 @@ .text-center.prepend-top-default.append-bottom-default - = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…') + = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…', class: 'qa-spinner') diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml index 6edbfd91b21..1a77eb078be 100644 --- a/app/views/projects/blob/viewers/_markup.html.haml +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -1,6 +1,4 @@ - blob = viewer.blob -- context = legacy_render_context(params) -- unless context[:markdown_engine] == :redcarpet - - context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup) +- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {} .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } = markup(blob.name, blob.data, context) diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 4b0ea15335e..91c51d5e091 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -2,6 +2,7 @@ - commit = @repository.commit(branch.dereferenced_target) - bar_graph_width_factor = @max_commits > 0 ? 100.0/@max_commits : 0 - diverging_commit_counts = @repository.diverging_commit_counts(branch) +- number_commits_distance = diverging_commit_counts[:distance] - number_commits_behind = diverging_commit_counts[:behind] - number_commits_ahead = diverging_commit_counts[:ahead] - merge_project = merge_request_source_project_for_project(@project) @@ -28,16 +29,23 @@ = s_('Branches|Cant find HEAD commit for this branch') - if branch.name != @repository.root_ref - .divergence-graph.d-none.d-md-block{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind), - default_branch: @repository.root_ref, - number_commits_ahead: diverging_count_label(number_commits_ahead) } } - .graph-side - .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" } - %span.count.count-behind= diverging_count_label(number_commits_behind) - .graph-separator - .graph-side - .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" } - %span.count.count-ahead= diverging_count_label(number_commits_ahead) + - if number_commits_distance.nil? + .divergence-graph.d-none.d-md-block{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind), + default_branch: @repository.root_ref, + number_commits_ahead: diverging_count_label(number_commits_ahead) } } + .graph-side + .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" } + %span.count.count-behind= diverging_count_label(number_commits_behind) + .graph-separator + .graph-side + .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" } + %span.count.count-ahead= diverging_count_label(number_commits_ahead) + - else + .divergence-graph.d-none.d-md-block{ title: s_('More than %{number_commits_distance} commits different with %{default_branch}') % { number_commits_distance: diverging_count_label(number_commits_distance), + default_branch: @repository.root_ref} } + .graph-side.full + .bar{ style: "width: #{number_commits_distance * bar_graph_width_factor}%" } + %span.count= diverging_count_label(number_commits_distance) .controls.d-none.d-md-block< - if merge_project && create_mr_button?(@repository.root_ref, branch.name) @@ -45,9 +53,8 @@ = _('Merge request') - if branch.name != @repository.root_ref - = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name), + = link_to project_compare_path(@project, @repository.root_ref, branch.name), class: "btn btn-default #{'prepend-left-10' unless merge_project}", - method: :post, title: s_('Branches|Compare') do = s_('Branches|Compare') diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 45515fb492f..bbe0a2c97fd 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -35,8 +35,8 @@ - elsif can_collaborate_with_project?(@project) %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master') - elsif create_mr_from_new_fork - - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'), - notice: edit_in_new_fork_notice, + - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'), + notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) %li= link_to _('New file'), fork_path, method: :post diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 44e9cb84341..9d069c025ba 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -25,7 +25,7 @@ = job.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite') = link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name" - else - .light none + .light= _('none') .icon-container.commit-icon = custom_icon("icon_commit") @@ -33,10 +33,10 @@ = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha" - if job.stuck? - = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.') + = icon('warning', class: 'text-warning has-tooltip', title: _('Job is stuck. Check runners.')) - if retried - = icon('refresh', 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? @@ -44,13 +44,13 @@ %span.badge.badge-primary = tag - if job.try(:trigger_request) - %span.badge.badge-info triggered + %span.badge.badge-info= _('triggered') - if job.try(:allow_failure) - %span.badge.badge-danger allowed to fail + %span.badge.badge-danger= _('allowed to fail') - if job.schedulable? %span.badge.badge-info= s_('DelayedJobs|delayed') - elsif job.action? - %span.badge.badge-info manual + %span.badge.badge-info= _('manual') - if pipeline_link %td @@ -70,7 +70,7 @@ - if job.try(:runner) = runner_link(job.runner) - else - .light none + .light= _('none') - if stage %td @@ -97,11 +97,11 @@ %td .float-right - if can?(current_user, :read_build, job) && job.artifacts? - = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do + = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build' do = sprite_icon('download') - if can?(current_user, :update_build, job) - if job.active? - = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: 'Cancel', class: 'btn btn-build' do + = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif job.scheduled? .btn-group @@ -123,8 +123,8 @@ = sprite_icon('time-out') - elsif allow_retry - if job.playable? && !admin && can?(current_user, :update_build, job) - = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do + = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn btn-build' do = custom_icon('icon_play') - elsif job.retryable? - = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do + = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build' do = icon('repeat') diff --git a/app/views/projects/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml index b4c18374220..59b5b9f8a30 100644 --- a/app/views/projects/ci/lints/_create.html.haml +++ b/app/views/projects/ci/lints/_create.html.haml @@ -1,15 +1,15 @@ - if @status %p - %b Status: - syntax is correct + %b= _("Status:") + = _("syntax is correct") %i.fa.fa-ok.correct-syntax .table-holder %table.table.table-bordered %thead %tr - %th Parameter - %th Value + %th= _("Parameter") + %th= _("Value") %tbody - @stages.each do |stage| - @builds.select { |build| build[:stage] == stage }.each do |build| @@ -22,27 +22,27 @@ %pre= job[:after_script].to_a.join('\n') %br - %b Tag list: + %b= _("Tag list:") = build[:tag_list].to_a.join(", ") %br - %b Only policy: + %b= _("Only policy:") = job[:only].to_a.join(", ") %br - %b Except policy: + %b= _("Except policy:") = job[:except].to_a.join(", ") %br - %b Environment: + %b= _("Environment:") = build[:environment] %br - %b When: + %b= _("When:") = build[:when] - if build[:allow_failure] - %b Allowed to fail + %b= _("Allowed to fail") - else %p - %b Status: - syntax is incorrect + %b= _("Status:") + = _("syntax is incorrect") %i.fa.fa-remove.incorrect-syntax - %b Error: + %b= _("Error:") = @error diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml index cbda6bf2107..7b87664961e 100644 --- a/app/views/projects/ci/lints/show.html.haml +++ b/app/views/projects/ci/lints/show.html.haml @@ -1,9 +1,9 @@ -- page_title "CI Lint" -- page_description "Validate your GitLab CI configuration file" +- page_title _("CI Lint") +- page_description _("Validate your GitLab CI configuration file") - content_for :library_javascripts do = page_specific_javascript_tag('lib/ace.js') -%h2.pt-3.pb-3 Check your .gitlab-ci.yml +%h2.pt-3.pb-3= _("Check your .gitlab-ci.yml") .project-ci-linter = form_tag project_ci_lint_path(@project), method: :post do @@ -11,14 +11,14 @@ .col-sm-12 .file-holder .js-file-title.file-title.clearfix - Content of .gitlab-ci.yml + = _("Contents of .gitlab-ci.yml") #ci-editor.ci-editor= @content = text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true) .col-sm-12 .float-left.prepend-top-10 - = submit_tag('Validate', class: 'btn btn-success submit-yml') + = submit_tag(_('Validate'), class: 'btn btn-success submit-yml') .float-right.prepend-top-10 - = button_tag('Clear', type: 'button', class: 'btn btn-default clear-yml') + = button_tag(_('Clear'), type: 'button', class: 'btn btn-default clear-yml') .row.prepend-top-20 .col-sm-12 diff --git a/app/views/projects/commit/_ajax_signature.html.haml b/app/views/projects/commit/_ajax_signature.html.haml index eb677cff5f0..ae9aef5a9b0 100644 --- a/app/views/projects/commit/_ajax_signature.html.haml +++ b/app/views/projects/commit/_ajax_signature.html.haml @@ -1,2 +1,2 @@ - if commit.has_signature? - %a{ href: 'javascript:void(0)', tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } } + %a{ href: 'javascript:void(0)', tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'top', title: _('GPG signature (loading...)'), 'commit-sha' => commit.sha } } diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml index f6666921a25..41f5fb3dcbd 100644 --- a/app/views/projects/commit/_ci_menu.html.haml +++ b/app/views/projects/commit/_ci_menu.html.haml @@ -1,10 +1,12 @@ +- any_pipelines = @commit.present(current_user: current_user).any_pipelines? + %ul.nav-links.no-top.no-bottom.commit-ci-menu.nav.nav-tabs = nav_link(path: 'commit#show') do = link_to project_commit_path(@project, @commit.id) do - Changes + = _('Changes') %span.badge.badge-pill= @diffs.size - - if can?(current_user, :read_pipeline, @project) + - if any_pipelines = nav_link(path: 'commit#pipelines') do = link_to pipelines_project_commit_path(@project, @commit.id) do - Pipelines + = _('Pipelines') %span.badge.badge-pill.js-pipelines-mr-count= @commit.pipelines.size diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 2a919a767c0..a0db48bf8ff 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -6,8 +6,8 @@ %strong #{ s_('CommitBoxTitle|Commit') } %span.commit-sha= @commit.short_id - = clipboard_button(text: @commit.id, title: _("Copy commit SHA to clipboard")) - %span.d-none.d-sm-inline authored + = clipboard_button(text: @commit.id, title: _('Copy commit SHA to clipboard')) + %span.d-none.d-sm-inline= _('authored') #{time_ago_with_tooltip(@commit.authored_date)} %span= s_('ByAuthor|by') = author_avatar(@commit, size: 24, has_tooltip: false) @@ -43,13 +43,13 @@ = cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false) - if can?(current_user, :push_code, @project) %li.clearfix - = link_to s_("CreateTag|Tag"), new_project_tag_path(@project, ref: @commit) + = link_to s_('CreateTag|Tag'), new_project_tag_path(@project, ref: @commit) %li.divider %li.dropdown-header #{ _('Download') } - unless @commit.parents.length > 1 - %li= link_to s_("DownloadCommit|Email Patches"), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches" - %li= link_to s_("DownloadCommit|Plain Diff"), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff" + %li= link_to s_('DownloadCommit|Email Patches'), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches" + %li= link_to s_('DownloadCommit|Plain Diff'), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff" .commit-box{ data: { project_path: project_path(@project) } } %h3.commit-title @@ -74,8 +74,8 @@ %span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) } = icon('spinner spin') - - if @commit.last_pipeline - - last_pipeline = @commit.last_pipeline + - last_pipeline = @commit.last_pipeline + - if can?(current_user, :read_pipeline, last_pipeline) .well-segment.pipeline-info .status-icon-container = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do @@ -95,8 +95,5 @@ .well-segment = icon('info-circle fw') - This commit is part of merge request - = succeed '.' do - = link_to @merge_request.to_reference, diffs_project_merge_request_path(@project, @merge_request, commit_id: @commit.id) - - Comments created here will be created in the context of that merge request. + - link_to_merge_request = link_to(@merge_request.to_reference, diffs_project_merge_request_path(@project, @merge_request, commit_id: @commit.id)) + = _('This commit is part of merge request %{link_to_merge_request}. Comments created here will be created in the context of that merge request.').html_safe % { link_to_merge_request: link_to_merge_request } diff --git a/app/views/projects/commit/_limit_exceeded_message.html.haml b/app/views/projects/commit/_limit_exceeded_message.html.haml index a264f3517c4..7d3c0582d0b 100644 --- a/app/views/projects/commit/_limit_exceeded_message.html.haml +++ b/app/views/projects/commit/_limit_exceeded_message.html.haml @@ -1,8 +1,8 @@ -.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: "Project has too many #{label_for_message} to search"} } +.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: _('Project has too many %{label_for_message} to search') % { label_for_message: label_for_message } } } .limit-icon - if objects == :branch = sprite_icon('fork', size: 12) - else = icon('tag') .limit-message - %span #{label_for_message.capitalize} unavailable + %span= _('%{label_for_message} unavailable') % { label_for_message: label_for_message.capitalize } diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml index d7bf2dc0cb6..bb843bee7c9 100644 --- a/app/views/projects/commit/_other_user_signature_badge.html.haml +++ b/app/views/projects/commit/_other_user_signature_badge.html.haml @@ -1,6 +1,6 @@ - title = capture do - This commit was signed with a different user's verified signature. + = _("This commit was signed with a different user's verified signature.") -- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true } +- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml index 22ffd66ff8e..d282ab4f520 100644 --- a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml +++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml @@ -1,7 +1,6 @@ - title = capture do - This commit was signed with a verified signature, but the committer email - is <strong>not verified</strong> to belong to the same user. + = _('This commit was signed with a verified signature, but the committer email is <strong>not verified</strong> to belong to the same user.').html_safe -- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true } +- locals = { signature: signature, title: title, label: _('Unverified'), css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index c4d986ef742..1331fa179fc 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -19,10 +19,10 @@ .clearfix = render partial: 'projects/commit/signature_badge_user', locals: { signature: signature } - GPG Key ID: + = _('GPG Key ID:') %span.monospace= signature.gpg_key_primary_keyid - = link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') + = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') %a{ href: 'javascript:void(0)', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = label diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml index 00e1efe0582..294f916d18f 100644 --- a/app/views/projects/commit/_unverified_signature_badge.html.haml +++ b/app/views/projects/commit/_unverified_signature_badge.html.haml @@ -1,6 +1,6 @@ - title = capture do - This commit was signed with an <strong>unverified</strong> signature. + = _('This commit was signed with an <strong>unverified</strong> signature.').html_safe -- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless' } +- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless' } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml index 31408806be7..4964b1b8ee7 100644 --- a/app/views/projects/commit/_verified_signature_badge.html.haml +++ b/app/views/projects/commit/_verified_signature_badge.html.haml @@ -1,7 +1,6 @@ - title = capture do - This commit was signed with a <strong>verified</strong> signature and the - committer email is verified to belong to the same user. + = _('This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.').html_safe -- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'status_success_borderless', show_user: true } +- locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true } = render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml index c66ea873dba..f8c27f4c026 100644 --- a/app/views/projects/commit/pipelines.html.haml +++ b/app/views/projects/commit/pipelines.html.haml @@ -1,4 +1,4 @@ -- page_title 'Pipelines', "#{@commit.title} (#{@commit.short_id})", 'Commits' +- page_title _('Pipelines'), "#{@commit.title} (#{@commit.short_id})", _('Commits') = render 'commit_box' = render 'ci_menu' diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 79e32949db9..34226167288 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -1,19 +1,16 @@ - @no_container = true -- add_to_breadcrumbs "Commits", project_commits_path(@project) +- add_to_breadcrumbs _('Commits'), project_commits_path(@project) - breadcrumb_title @commit.short_id - container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : '' - limited_container_width = fluid_layout ? '' : 'limit-container-width' - @content_class = limited_container_width -- page_title "#{@commit.title} (#{@commit.short_id})", "Commits" +- page_title "#{@commit.title} (#{@commit.short_id})", _('Commits') - page_description @commit.description .container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" - - if @commit.status - = render "ci_menu" - - else - .block-connector - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, is_commit: true + = render "ci_menu" + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-commit" .limited-width-notes = render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 1a74b120c26..0d3c6e7027c 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -6,6 +6,7 @@ - merge_request = local_assigns.fetch(:merge_request, nil) - project = local_assigns.fetch(:project) { merge_request&.project } - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } +- commit_status = commit.present(current_user: current_user).status_for(ref) - link = commit_path(project, commit, merge_request: merge_request) %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } @@ -22,7 +23,7 @@ %span.commit-row-message.d-block.d-sm-none · = commit.short_id - - if commit.status(ref) + - if commit_status .d-block.d-sm-none = render_commit_status(commit, ref: ref) - if commit.description? @@ -45,7 +46,7 @@ - else = render partial: 'projects/commit/ajax_signature', locals: { commit: commit } - - if commit.status(ref) + - if commit_status = render_commit_status(commit, ref: ref) .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index b6bebbabed0..5774b48a054 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -8,7 +8,7 @@ - if @commits.present? = render "projects/commits/commit_list" - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-compare" - else .card.bg-light .center diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 062aa423bde..24d665761cc 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -3,7 +3,7 @@ .settings-header %h4 Deploy Keys - %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' } + %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one. diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index cc2d0d3b2d8..2dba3fcd664 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -2,7 +2,7 @@ - show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true) - can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project) - diff_files = diffs.diff_files -- is_commit = local_assigns.fetch(:is_commit, false) +- diff_page_context = local_assigns.fetch(:diff_page_context, nil) .content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed .files-changed-inner @@ -25,4 +25,4 @@ = render 'projects/diffs/warning', diff_files: diffs .files{ data: { can_create_note: can_create_note } } - = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, is_commit: is_commit } + = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: diff_page_context } diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 5565ae1d98b..855b719dc45 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,11 +1,11 @@ - environment = local_assigns.fetch(:environment, nil) -- is_commit = local_assigns.fetch(:is_commit, false) +- diff_page_context = local_assigns.fetch(:diff_page_context, nil) - file_hash = hexdigest(diff_file.file_path) - image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image' - image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha .diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_file.content_sha) } - .js-file-title.file-title-flex-parent{ class: is_commit ? "is-commit" : "" } + .js-file-title.file-title-flex-parent{ class: diff_page_context } .file-header-content = render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}" diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index a58f736b5b4..1a489bfa275 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -46,7 +46,7 @@ %h5.prepend-top-0= _("Project avatar") .form-group - if @project.avatar? - .avatar-container.s160.append-bottom-15 + .avatar-container.rect-avatar.s160.append-bottom-15 = project_icon(@project, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160) - if @project.avatar_in_git %p.light diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index d66de7ab698..99cbbc11acd 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -2,7 +2,6 @@ - page_title _("Environments") #environments-list-view{ data: { environments_data: environments_list_data, - "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, "can-read-environment" => can?(current_user, :read_environment, @project).to_s, "can-create-environment" => can?(current_user, :create_environment, @project).to_s, "new-environment-path" => new_project_environment_path(@project), diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml index a69146513d8..3f0798a898d 100644 --- a/app/views/projects/forks/_fork_button.html.haml +++ b/app/views/projects/forks/_fork_button.html.haml @@ -5,7 +5,7 @@ .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default.forked = link_to project_path(forked_project) do - if /no_((\w*)_)*avatar/.match(avatar) - = group_icon(namespace, class: "avatar s100 identicon") + = group_icon(namespace, class: "avatar rect-avatar s100 identicon") - else .avatar-container.s100 = image_tag(avatar, class: "avatar s100") @@ -18,7 +18,7 @@ class: ("disabled has-tooltip" unless can_create_project), title: (_('You have reached your project limit') unless can_create_project) do - if /no_((\w*)_)*avatar/.match(avatar) - = group_icon(namespace, class: "avatar s100 identicon") + = group_icon(namespace, class: "avatar rect-avatar s100 identicon") - else .avatar-container.s100 = image_tag(avatar, class: "avatar s100") diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index b0e22a35fe1..60160f521ad 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -55,23 +55,23 @@ #{@commits_graph.authors} = (_("Authors: %{authors}") % { authors: "<strong>#{authors}</strong>" }).html_safe .col-md-6 + %p.slead + = _("Commits per day of month") %div - %p.slead - = _("Commits per day of month") %canvas#month-chart .row .col-md-6 .col-md-6 + %p.slead + = _("Commits per weekday") %div - %p.slead - = _("Commits per weekday") %canvas#weekday-chart .row .col-md-6 .col-md-6 + %p.slead + = _("Commits per day hour (UTC)") %div - %p.slead - = _("Commits per day hour (UTC)") %canvas#hour-chart -# haml-lint:disable InlineJavaScript diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 4917f4b8903..42b6aaa2634 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -5,7 +5,7 @@ = 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' -%section.js-vue-notes-event +%section.issuable-discussion.js-vue-notes-event #js-vue-notes{ data: { notes_data: notes_data(@issue).to_json, noteable_data: serialize_issuable(@issue), noteable_type: 'Issue', diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml index 1e4e9450ffa..1be1087b36f 100644 --- a/app/views/projects/issues/_form.html.haml +++ b/app/views/projects/issues/_form.html.haml @@ -1,4 +1,3 @@ = form_for [@project.namespace.becomes(Namespace), @project, @issue], - html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' }, - data: { markdown_version: @issue.cached_markdown_version } do |f| + html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } do |f| = render 'shared/issuable/form', f: f, issuable: @issue diff --git a/app/views/projects/issues/_import_export.svg b/app/views/projects/issues/_import_export.svg deleted file mode 100644 index 53c35d12f57..00000000000 --- a/app/views/projects/issues/_import_export.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 238 111" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="4" width="82" rx="3" height="28" fill="#fff"/><path id="5" d="m68.926 12.09v-2.41c0-.665-.437-.888-.975-.507l-6.552 4.631c-.542.383-.539.998 0 1.379l6.552 4.631c.542.383.975.154.975-.507v-2.41h4.874c.668 0 1.2-.538 1.2-1.201v-2.406c0-.668-.537-1.201-1.2-1.201h-4.874" fill="#fc8a51"/><path id="6" d="m4 24h74v-20h-74v20m-4-21c0-1.655 1.338-2.996 2.991-2.996h76.02c1.652 0 2.991 1.35 2.991 2.996v22.01c0 1.655-1.338 2.996-2.991 2.996h-76.02c-1.652 0-2.991-1.35-2.991-2.996v-22.01"/><circle id="2" cx="16" cy="14" r="7"/><circle id="0" cx="16" cy="14" r="7"/><mask id="3" width="14" height="14" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="1" width="14" height="14" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><rect width="98" height="111" fill="#fff" rx="6"/><path fill="#e5e5e5" fill-rule="nonzero" d="m4 6.01v98.99c0 1.11.897 2.01 2 2.01h85.998c1.105 0 2-.897 2-2.01v-98.99c0-1.11-.897-2.01-2-2.01h-85.998c-1.105 0-2 .897-2 2.01m-4 0c0-3.318 2.685-6.01 6-6.01h85.998c3.314 0 6 2.689 6 6.01v98.99c0 3.318-2.685 6.01-6 6.01h-85.998c-3.314 0-6-2.689-6-6.01v-98.99"/><rect width="76" height="85" x="11" y="12" fill="#f9f9f9" rx="3"/><g transform="translate(37 59)"><use xlink:href="#4"/><path fill="#e5e5e5" fill-rule="nonzero" d="m4 24h74v-20h-74v20m-4-21c0-1.655 1.338-2.996 2.991-2.996h76.02c1.652 0 2.991 1.35 2.991 2.996v22.01c0 1.655-1.338 2.996-2.991 2.996h-76.02c-1.652 0-2.991-1.35-2.991-2.996v-22.01"/><use fill="#fff" stroke="#6b4fbb" stroke-width="8" mask="url(#1)" xlink:href="#0"/><use xlink:href="#5"/></g><g transform="translate(140)"><path fill="#fff" d="m0 4h94v103h-94z"/><path fill="#e5e5e5" fill-rule="nonzero" d="m0 74v30.993c0 3.318 2.687 6.01 6 6.01h85.998c3.316 0 6-2.69 6-6.01v-98.99c0-3.318-2.687-6.01-6-6.01h-85.998c-3.316 0-6 2.69-6 6.01v.993h4v-.993c0-1.11.896-2.01 2-2.01h85.998c1.105 0 2 .897 2 2.01v98.99c0 1.11-.896 2.01-2 2.01h-85.998c-1.105 0-2-.897-2-2.01v-30.993h-4"/><g fill="#f9f9f9"><rect width="82" height="28" x="8" y="12" rx="3"/><rect width="82" height="28" x="8" y="43" rx="3"/></g></g><g fill-rule="nonzero" transform="translate(148 73)"><use fill="#e5e5e5" xlink:href="#6"/><path fill="#6b4fbb" d="m17 17c1.657 0 3-1.343 3-3 0-1.657-1.343-3-3-3-1.657 0-3 1.343-3 3 0 1.657 1.343 3 3 3m0 4c-3.866 0-7-3.134-7-7 0-3.866 3.134-7 7-7 3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7"/></g><g transform="translate(25 24)"><use xlink:href="#4"/><use fill="#e5e5e5" fill-rule="nonzero" xlink:href="#6"/><use fill="#fff" stroke="#6b4fbb" stroke-width="8" mask="url(#3)" xlink:href="#2"/><use xlink:href="#5"/></g><g transform="translate(107 10)"><use xlink:href="#4"/><use fill="#fc8a51" fill-opacity=".3" fill-rule="nonzero" xlink:href="#6"/><path fill="#6b4fbb" fill-rule="nonzero" d="m16 17c1.657 0 3-1.343 3-3 0-1.657-1.343-3-3-3-1.657 0-3 1.343-3 3 0 1.657 1.343 3 3 3m0 4c-3.866 0-7-3.134-7-7 0-3.866 3.134-7 7-7 3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7" id="7"/><use xlink:href="#5"/></g><g transform="translate(128 41)"><use xlink:href="#4"/><use fill="#fc8a51" fill-opacity=".3" fill-rule="nonzero" xlink:href="#6"/><use xlink:href="#7"/><path fill="#fc8a51" d="m66.926 12.09v-2.41c0-.665-.437-.888-.975-.507l-6.552 4.631c-.542.383-.539.998 0 1.379l6.552 4.631c.542.383.975.154.975-.507v-2.41h4.874c.668 0 1.2-.538 1.2-1.201v-2.406c0-.668-.537-1.201-1.2-1.201h-4.874"/></g></g></svg>
\ No newline at end of file diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 31c72f2f759..ce7c7091c93 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -36,7 +36,7 @@ = issue.due_date.to_s(:medium) - if issue.labels.any? - - issue.labels.each do |label| + - labels_sorted_by_title(issue.labels).each do |label| = link_to_label(label, subject: issue.project, css_class: 'label-link') .issuable-meta diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml index eb46bf5c6a3..310e339ac8d 100644 --- a/app/views/projects/issues/_merge_requests.html.haml +++ b/app/views/projects/issues/_merge_requests.html.haml @@ -12,6 +12,7 @@ %ul.content-list.related-items-list - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id) - @merge_requests.each do |merge_request| + - merge_request = merge_request.present(current_user: current_user) %li.list-item.py-0.px-0 .item-body.issuable-info-container.py-lg-3.px-lg-3.pl-md-3 .item-contents @@ -19,13 +20,13 @@ = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-none d-xl-block append-right-8' } = link_to merge_request.title, merge_request_path(merge_request), { class: 'mr-title-link'} .item-meta - = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-xl-none d-lg-block append-right-5' } + = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-xl-none d-lg-block append-right-5' } %span.d-flex.align-items-center.append-right-8.mr-item-path.item-path-id.mt-0 %span.path-id-text.bold.text-truncate{ data: { toggle: 'tooltip'}, title: merge_request.target_project.full_path } = merge_request.target_project.full_path = merge_request.to_reference %span.mr-ci-status.flex-md-grow-1.justify-content-end.d-flex.ml-md-2 - - if merge_request.head_pipeline + - if merge_request.can_read_pipeline? = render_pipeline_status(merge_request.head_pipeline, tooltip_placement: 'bottom') - elsif has_any_head_pipeline = icon('blank fw') diff --git a/app/views/projects/issues/_merge_requests_status.html.haml b/app/views/projects/issues/_merge_requests_status.html.haml index 38126d6f0c6..90838a75214 100644 --- a/app/views/projects/issues/_merge_requests_status.html.haml +++ b/app/views/projects/issues/_merge_requests_status.html.haml @@ -12,11 +12,14 @@ - mr_status_class = 'closed' - else - mr_status_date = merge_request.created_at - - mr_status_title = _('Opened') + - mr_status_title = mr_status_date ? _('Opened') : _('Open') - mr_status_icon = 'issue-open-m' - mr_status_class = 'open' -- mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span> #{time_ago_in_words(mr_status_date)} ago</div><span class=\"text-tertiary\">#{l(mr_status_date.to_time, format: time_format)}</span>" +- if mr_status_date + - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span> #{time_ago_in_words(mr_status_date)} ago</div><span class=\"text-tertiary\">#{l(mr_status_date.to_time, format: time_format)}</span>" +- else + - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span></div>" -%span.mr-status-wrapper.suggestion-help-hover{ class: css_class, data: { toggle: 'tooltip', placement: 'bottom', html: 'true', title: mr_status_tooltip } } +%span.mr-status-wrapper.suggestion-help-hover{ class: css_class, data: { toggle: 'tooltip', placement: 'bottom', html: 'true', title: mr_status_tooltip } } = sprite_icon(mr_status_icon, size: 16, css_class: "merge-request-status #{mr_status_class}") diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 5374f4a1de0..fbd70cd1906 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -30,7 +30,7 @@ = icon('check', class: 'icon') = _('Create merge request and branch') - %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } } + %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } } .menu-item = icon('check', class: 'icon') = _('Create branch') diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 1df38db9fd4..ffdd96870ef 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -6,7 +6,7 @@ %li - target = @project.repository.find_branch(branch).dereferenced_target - pipeline = @project.pipeline_for(branch, target.sha) if target - - if pipeline + - if can?(current_user, :read_pipeline, pipeline) %span.related-branch-ci-status = render_pipeline_status(pipeline) %span.related-branch-info diff --git a/app/views/projects/issues/import_csv/_modal.html.haml b/app/views/projects/issues/import_csv/_modal.html.haml index 5339c4325b9..86bc54786ad 100644 --- a/app/views/projects/issues/import_csv/_modal.html.haml +++ b/app/views/projects/issues/import_csv/_modal.html.haml @@ -5,8 +5,8 @@ .modal-header %h3 = _('Import issues') - .import-export-svg-container - = render 'projects/issues/import_export.svg' + .svg-content.import-export-svg-container + = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration' %a.close{ href: '#', 'data-dismiss' => 'modal' } × .modal-body .modal-text diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index f048fb91304..3a674da6e87 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -14,8 +14,14 @@ .detail-page-header-body .issuable-status-box.status-box.status-box-issue-closed{ class: issue_button_visibility(@issue, false) } = sprite_icon('mobile-issue-close', size: 16, css_class: 'd-block d-sm-none') - %span.d-none.d-sm-block - Closed + .d-none.d-sm-block + - if @issue.moved? + - moved_link_start = "<a href=\"#{issue_path(@issue.moved_to)}\" class=\"text-white text-underline\">".html_safe + - moved_link_end = '</a>'.html_safe + = s_('IssuableStatus|Closed (%{moved_link_start}moved%{moved_link_end})').html_safe % {moved_link_start: moved_link_start, + moved_link_end: moved_link_end} + - else + = _("Closed") .issuable-status-box.status-box.status-box-open{ class: issue_button_visibility(@issue, true) } = sprite_icon('issue-open-m', size: 16, css_class: 'd-block d-sm-none') %span.d-none.d-sm-block Open @@ -85,7 +91,6 @@ #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } } = render 'new_branch' unless @issue.confidential? - %section.issuable-discussion - = render 'projects/issues/discussion' + = render_if_exists 'projects/issues/discussion' = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml index 13b967beba1..a7c9e54506d 100644 --- a/app/views/projects/merge_requests/_form.html.haml +++ b/app/views/projects/merge_requests/_form.html.haml @@ -1,4 +1,3 @@ = 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| + 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, presenter: @mr_presenter diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index faa070d0389..90916191d97 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -34,7 +34,7 @@ = merge_request.target_branch - if merge_request.labels.any? - - merge_request.labels.each do |label| + - labels_sorted_by_title(merge_request.labels).each do |label| = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link') .issuable-meta @@ -46,7 +46,7 @@ %li.issuable-status.d-none.d-sm-inline-block = icon('ban') CLOSED - - if merge_request.head_pipeline + - if can?(current_user, :read_pipeline, merge_request.head_pipeline) %li.issuable-pipeline-status.d-none.d-sm-inline-block = render_pipeline_status(merge_request.head_pipeline) - if merge_request.open? && merge_request.broken? @@ -56,6 +56,7 @@ - if merge_request.assignee %li = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: _('Assigned to :name')) + = render_if_exists 'projects/merge_requests/approvals_count', merge_request: merge_request = render 'shared/issuable_meta_data', issuable: merge_request diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index d6f340d0ee2..5111c9fab8d 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -7,7 +7,7 @@ - page_card_attributes @merge_request.card_attributes - suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes') -.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } } +.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } } = render "projects/merge_requests/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } @@ -59,6 +59,7 @@ #js-vue-discussion-counter .tab-content#diff-notes-app + #js-diff-file-finder #notes.notes.tab-pane.voting_notes .row %section.col-md-12 diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index ebd3229e42b..5cc6b5a173b 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,27 +1,28 @@ = form_for [@project.namespace.becomes(Namespace), @project, @milestone], - html: {class: 'milestone-form common-note-form js-quick-submit js-requires-input'}, - data: { markdown_version: @milestone.cached_markdown_version } do |f| + html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| = form_errors(@milestone) .row .col-md-6 .form-group.row - = f.label :title, "Title", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :title, _('Title') .col-sm-10 - = f.text_field :title, maxlength: 255, class: "qa-milestone-title form-control", required: true, autofocus: true + = f.text_field :title, maxlength: 255, class: 'qa-milestone-title form-control', required: true, autofocus: true .form-group.row.milestone-description - = f.label :description, "Description", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :description, _('Description') .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do - = render 'projects/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: 'Write milestone description...' + = render 'projects/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: _('Write milestone description...') = render 'shared/notes/hints' .clearfix .error-alert - = render "shared/milestones/form_dates", f: f + = render 'shared/milestones/form_dates', f: f .form-actions - if @milestone.new_record? - = f.submit 'Create milestone', class: "btn-success btn qa-milestone-create-button" - = link_to "Cancel", project_milestones_path(@project), class: "btn btn-cancel" + = f.submit _('Create milestone'), class: 'btn-create btn qa-milestone-create-button' + = link_to _('Cancel'), project_milestones_path(@project), class: 'btn btn-cancel' - else - = f.submit 'Save changes', class: "btn-success btn" - = link_to "Cancel", project_milestone_path(@project, @milestone), class: "btn btn-cancel" + = f.submit _('Save changes'), class: 'btn-success btn' + = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'btn btn-cancel' diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml index 4006a468792..aa564e00af9 100644 --- a/app/views/projects/milestones/edit.html.haml +++ b/app/views/projects/milestones/edit.html.haml @@ -1,14 +1,14 @@ - @no_container = true -- breadcrumb_title "Edit" -- add_to_breadcrumbs "Milestones", project_milestones_path(@project) -- page_title "Edit", @milestone.title, "Milestones" +- breadcrumb_title _('Edit') +- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project) +- page_title _('Edit'), @milestone.title, _('Milestones') %div{ class: container_class } %h3.page-title - Edit Milestone + = _('Edit Milestone') %hr - = render "form" + = render 'form' diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index 57f3c640696..a3414c16d73 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -1,15 +1,16 @@ - @no_container = true -- page_title 'Milestones' +- page_title _('Milestones') %div{ class: container_class } .top-area = render 'shared/milestones_filter', counts: milestone_counts(@project.milestones) .nav-controls + = render 'shared/milestones/search_form' = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @project) - = link_to new_project_milestone_path(@project), class: "btn btn-success qa-new-project-milestone", title: 'New milestone' do - New milestone + = link_to new_project_milestone_path(@project), class: 'btn btn-success qa-new-project-milestone', title: _('New milestone') do + = _('New milestone') .milestones #delete-milestone-modal @@ -20,6 +21,6 @@ - if @milestones.blank? %li - .nothing-here-block No milestones to show + .nothing-here-block= _('No milestones to show') = paginate @milestones, theme: 'gitlab' diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml index 01cc951e8c2..79207fd70b5 100644 --- a/app/views/projects/milestones/new.html.haml +++ b/app/views/projects/milestones/new.html.haml @@ -1,12 +1,12 @@ - @no_container = true -- add_to_breadcrumbs "Milestones", project_milestones_path(@project) -- breadcrumb_title "New" -- page_title "New Milestone" +- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project) +- breadcrumb_title _('New') +- page_title _('New Milestone') %div{ class: container_class } %h3.page-title - New Milestone + = _('New Milestone') %hr - = render "form" + = render 'form' diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 5859de61d71..0542b349e44 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -1,30 +1,30 @@ - @no_container = true -- add_to_breadcrumbs "Milestones", project_milestones_path(@project) +- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project) - breadcrumb_title @milestone.title -- page_title @milestone.title, "Milestones" +- page_title @milestone.title, _('Milestones') - page_description @milestone.description %div{ class: container_class } .detail-page-header.milestone-page-header .status-box{ class: status_box_class(@milestone) } - if @milestone.closed? - Closed + = _('Closed') - elsif @milestone.expired? - Past due + = _('Past due') - elsif @milestone.upcoming? - Upcoming + = _('Upcoming') - else - Open + = _('Open') .header-text-content %span.identifier %strong - Milestone + = _('Milestone') - if @milestone.due_date || @milestone.start_date = milestone_date_range(@milestone) .milestone-buttons - if can?(current_user, :admin_milestone, @project) - = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do - Edit + = link_to edit_project_milestone_path(@project, @milestone), class: 'btn btn-grouped btn-nr' do + = _('Edit') - if @project.group %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', @@ -39,13 +39,13 @@ #promote-milestone-modal - if @milestone.active? - = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" + = link_to _('Close milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: 'btn btn-close btn-nr btn-grouped' - else - = link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped" + = link_to _('Reopen milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: 'btn btn-reopen btn-nr btn-grouped' = render 'shared/milestones/delete_button' - %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" } + %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: '#' } = icon('angle-double-left') .detail-page-description.milestone-detail @@ -62,10 +62,10 @@ - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero? .alert.alert-success.prepend-top-default - %span Assign some issues to this milestone. + %span= _('Assign some issues to this milestone.') - elsif @milestone.complete?(current_user) && @milestone.active? .alert.alert-success.prepend-top-default - %span All issues for this milestone are closed. You may close this milestone now. + %span= _('All issues for this milestone are closed. You may close this milestone now.') = render 'deprecation_message' = render 'shared/milestones/tabs', milestone: @milestone diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 8275996b522..ff7c36c2d5b 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -16,6 +16,9 @@ = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link } %p = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') + %p + - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/getting_started_part_two", anchor: "fork-a-project-to-get-started-from"), target: '_blank' + = _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide } .md = brand_new_project_guidelines %p diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml index ae8c801b705..138e2864bad 100644 --- a/app/views/projects/pages/_destroy.haml +++ b/app/views/projects/pages/_destroy.haml @@ -9,4 +9,4 @@ .form-actions = link_to 'Remove pages', project_pages_path(@project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove" - else - .nothing-here-block Only the project owner can remove pages + .nothing-here-block Only project maintainers can remove pages diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml index ce3ef29c32e..74478ee011c 100644 --- a/app/views/projects/pages/_https_only.html.haml +++ b/app/views/projects/pages/_https_only.html.haml @@ -3,7 +3,7 @@ .form-check = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled? = f.label :pages_https_only, class: pages_https_only_label_class do - %strong Force domains with SSL certificates to use HTTPS + %strong Force HTTPS (requires valid certificates) - unless pages_https_only_disabled? .prepend-top-10 diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index 0d848f7899c..b7b46c56c37 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -6,25 +6,24 @@ .form-group.row = f.label :domain, class: 'col-form-label col-sm-2' do - Domain + = _("Domain") .col-sm-10 = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted? - if Gitlab.config.pages.external_https .form-group.row = f.label :certificate, class: 'col-form-label col-sm-2' do - Certificate (PEM) + = _("Certificate (PEM)") .col-sm-10 = f.text_area :certificate, rows: 5, class: 'form-control' - %span.help-inline Upload a certificate for your domain with all intermediates + %span.help-inline= _("Upload a certificate for your domain with all intermediates") .form-group.row = f.label :key, class: 'col-form-label col-sm-2' do - Key (PEM) + = _("Key (PEM)") .col-sm-10 = f.text_area :key, rows: 5, class: 'form-control' - %span.help-inline Upload a private key for your certificate + %span.help-inline= _("Upload a private key for your certificate") - else .nothing-here-block - Support for custom certificates is disabled. - Ask your system's administrator to enable it. + = _("Support for custom certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml index 342b1482df7..e11387ae742 100644 --- a/app/views/projects/pages_domains/edit.html.haml +++ b/app/views/projects/pages_domains/edit.html.haml @@ -1,4 +1,4 @@ -- add_to_breadcrumbs "Pages", project_pages_path(@project) +- add_to_breadcrumbs _("Pages"), project_pages_path(@project) - breadcrumb_title @domain.domain - page_title @domain.domain %h3.page-title @@ -8,4 +8,4 @@ = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } .form-actions - = f.submit 'Save Changes', class: "btn btn-success" + = f.submit _('Save Changes'), class: "btn btn-success" diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index 94ad1470052..c7cefa87c76 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -1,12 +1,12 @@ -- add_to_breadcrumbs "Pages", project_pages_path(@project) -- page_title 'New Pages Domain' +- add_to_breadcrumbs _("Pages"), project_pages_path(@project) +- page_title _('New Pages Domain') %h3.page-title - New Pages Domain + = _("New Pages Domain") %hr.clearfix %div = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } .form-actions - = f.submit 'Create New Domain', class: "btn btn-success" + = f.submit _('Create New Domain'), class: "btn btn-success" .float-right = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index a8484187493..82147568981 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -1,6 +1,6 @@ -- add_to_breadcrumbs "Pages", project_pages_path(@project) +- add_to_breadcrumbs _("Pages"), project_pages_path(@project) - breadcrumb_title @domain.domain -- page_title "#{@domain.domain}", 'Pages Domains' +- page_title "#{@domain.domain}", _('Pages Domains') - dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}." - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? @@ -9,37 +9,37 @@ = content_for :flash_message do .alert.alert-warning .container-fluid.container-limited - This domain is not verified. You will need to verify ownership before access is enabled. + = _("This domain is not verified. You will need to verify ownership before access is enabled.") %h3.page-title.with-button - = link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success float-right' - Pages Domain + = link_to _('Edit'), edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success float-right' + = _("Pages Domain") .table-holder %table.table %tr %td - Domain + = _("Domain") %td = link_to @domain.url do = @domain.url = icon('external-link') %tr %td - DNS + = _("DNS") %td .input-group = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true .input-group-append = clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block') %p.form-text.text-muted - To access this domain create a new DNS record + = _("To access this domain create a new DNS record") - if verification_enabled - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}" %tr %td - Verification status + = _("Verification status") %td = form_tag verify_project_pages_domain_path(@project, @domain) do .status-badge @@ -53,17 +53,16 @@ .input-group-append = clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') %p.form-text.text-muted - - help_link = help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record') - To #{link_to 'verify ownership', help_link} of your domain, - add the above key to a TXT record within to your DNS configuration. + - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')) + = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help } %tr %td - Certificate + = _("Certificate") %td - if @domain.certificate_text %pre = @domain.certificate_text - else .light - missing + = _("missing") diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 259979417e0..1121cf06b5c 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -34,7 +34,7 @@ = n_('Reveal value', 'Reveal values', @schedule.variables.size) .form-group.row .col-md-9 - = f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold' + = f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold' %div = f.check_box :active, required: false, value: @schedule.active? = _('Active') diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 0f0114d513c..9c2efd6aa35 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -6,25 +6,24 @@ = preserve(markdown(commit.description, pipeline: :single_line)) .info-well - - if commit.status - .well-segment.pipeline-info - .icon-container - = icon('clock-o') - = pluralize @pipeline.total_size, "job" - - if @pipeline.ref - from - - if @pipeline.ref_exists? - = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" - - else - %span.ref-name - = @pipeline.ref - - if @pipeline.duration - in - = time_interval_in_words(@pipeline.duration) - - if @pipeline.queued_duration - = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + .well-segment.pipeline-info + .icon-container + = icon('clock-o') + = pluralize @pipeline.total_size, "job" + - if @pipeline.ref + from + - if @pipeline.ref_exists? + = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" + - else + %span.ref-name + = @pipeline.ref + - if @pipeline.duration + in + = time_interval_in_words(@pipeline.duration) + - if @pipeline.queued_duration + = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" - .well-segment + .well-segment.qa-pipeline-badges .icon-container = sprite_icon('flag') - if @pipeline.latest? diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml index c23fe6ff170..c0ac79ed5f8 100644 --- a/app/views/projects/pipelines/charts/_pipeline_times.haml +++ b/app/views/projects/pipelines/charts/_pipeline_times.haml @@ -1,7 +1,7 @@ -%div - %p.light - = _("Commit duration in minutes for last 30 commits") +%p.light + = _("Commit duration in minutes for last 30 commits") +%div %canvas#build_timesChart{ height: 200 } -# haml-lint:disable InlineJavaScript diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml index 14b3d47a9c2..47f1f074210 100644 --- a/app/views/projects/pipelines/charts/_pipelines.haml +++ b/app/views/projects/pipelines/charts/_pipelines.haml @@ -13,18 +13,21 @@ %p.light = _("Pipelines for last week") (#{date_from_to(Date.today - 7.days, Date.today)}) - %canvas#weekChart{ height: 200 } + %div + %canvas#weekChart{ height: 200 } .prepend-top-default %p.light = _("Pipelines for last month") (#{date_from_to(Date.today - 30.days, Date.today)}) - %canvas#monthChart{ height: 200 } + %div + %canvas#monthChart{ height: 200 } .prepend-top-default %p.light = _("Pipelines for last year") - %canvas#yearChart.padded{ height: 250 } + %div + %canvas#yearChart.padded{ height: 250 } -# haml-lint:disable InlineJavaScript %script#pipelinesChartsData{ type: "application/json" } diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index b92ecf4412f..e0dd386fc5d 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -6,7 +6,7 @@ %span.flex-project-title = _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize_project_name(project.name) } %span.badge.badge-pill= members.total_count - = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do + = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group .position-relative = search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false } diff --git a/app/views/projects/project_templates/_built_in_templates.html.haml b/app/views/projects/project_templates/_built_in_templates.html.haml index 5b4d8927045..6159f1c3542 100644 --- a/app/views/projects/project_templates/_built_in_templates.html.haml +++ b/app/views/projects/project_templates/_built_in_templates.html.haml @@ -1,7 +1,7 @@ - Gitlab::ProjectTemplate.all.each do |template| .template-option.d-flex.align-items-center - .logo.append-right-10 - = custom_icon(template.logo, size: 40) + .logo.append-right-10.px-1 + = image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}" .description %strong = template.title @@ -12,6 +12,6 @@ %a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } } = _("Preview") %label.btn.btn-success.template-button.choose-template.append-bottom-0{ for: template.name } - %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } } + %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } } %span = _("Use template") diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml index f650fa0f38f..635580eac5c 100644 --- a/app/views/projects/serverless/functions/index.html.haml +++ b/app/views/projects/serverless/functions/index.html.haml @@ -5,7 +5,7 @@ - status_path = project_serverless_functions_path(@project, format: :json) - clusters_path = project_clusters_path(@project) -.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } } +.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } } %div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } .js-serverless-functions-notice diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml index 9d4574c4590..6aafa85e99a 100644 --- a/app/views/projects/services/prometheus/_show.html.haml +++ b/app/views/projects/services/prometheus/_show.html.haml @@ -8,3 +8,5 @@ .col-lg-9 = render 'projects/services/prometheus/metrics', project: @project + += render_if_exists 'projects/services/prometheus/external_alerts', project: @project diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 5ec5a06396e..8c4d1c32ebe 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -4,10 +4,6 @@ = form_errors(@project) %fieldset.builds-feature.js-auto-devops-settings .form-group - - message = auto_devops_warning_message(@project) - - if message - %p.auto-devops-warning-message.settings-message.text-center - = message.html_safe = f.fields_for :auto_devops_attributes, @auto_devops do |form| .card.auto-devops-card .card-body @@ -21,19 +17,12 @@ = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' .card-footer.js-extra-settings{ class: @project.auto_devops_enabled? || 'hidden' } - = form.label :domain do - %strong= _('Domain') - = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' - .form-text.text-muted - = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.') - - if cluster_ingress_ip = cluster_ingress_ip(@project) - = s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe } - = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank' - + %p.settings-message.text-center + - kubernetes_cluster_link = help_page_path('user/project/clusters/index') + - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link } + = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe } %label.prepend-top-10 %strong= s_('CICD|Deployment strategy') - %p.settings-message.text-center - = s_('CICD|Deployment strategy needs a domain name to work correctly.') .form-check = form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input' = form.label :deploy_strategy_continuous, class: 'form-check-label' do diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index bb328f5344c..bfb275b9ef5 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -110,6 +110,9 @@ %li go test -cover (Go) %code coverage: \d+.\d+% of statements + %li + nyc npm test (NodeJS) - + %code All files[^|]*\|[^|]*\s+([\d\.]+) = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index b36fa9a5f51..2822debe426 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -1,5 +1,6 @@ - @content_class = 'limit-container-width' unless fluid_layout -- page_title _('Operations') +- page_title _('Operations Settings') +- breadcrumb_title _('Operations Settings') = render 'projects/settings/operations/error_tracking', expanded: true = render_if_exists 'projects/settings/operations/tracing' diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml index a4974d89c1a..7682d01a5a1 100644 --- a/app/views/projects/snippets/index.html.haml +++ b/app/views/projects/snippets/index.html.haml @@ -1,12 +1,16 @@ - page_title _("Snippets") -- if current_user - .top-area - - include_private = @project.team.member?(current_user) || current_user.admin? - = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private } +- if @snippets.exists? + - if current_user + .top-area + - include_private = @project.team.member?(current_user) || current_user.admin? + = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private } - .nav-controls - if can?(current_user, :create_project_snippet, @project) - = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet") + .nav-controls + - if can?(current_user, :create_project_snippet, @project) + = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet") -= render 'snippets/snippets' + = render 'shared/snippets/list' +- else + = render 'shared/empty_states/snippets', button_path: new_namespace_project_snippet_path(@project.namespace, @project) diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml index 26b333d4ecf..d64e3a49a81 100644 --- a/app/views/projects/snippets/new.html.haml +++ b/app/views/projects/snippets/new.html.haml @@ -1,8 +1,8 @@ - add_to_breadcrumbs _("Snippets"), project_snippets_path(@project) - breadcrumb_title _("New") -- page_title _("New Snippets") +- page_title _("New Snippet") %h3.page-title - = _('New Snippet') + = _("New Snippet") %hr = render "shared/snippets/form", url: project_snippets_path(@project, @snippet) diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index f55202c2c5f..cc203cfad86 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -28,7 +28,7 @@ = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] - if can?(current_user, :push_code, @project) - = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do + = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do = icon("pencil") - if can?(current_user, :admin_project, @project) diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml index 52c6c7ec424..e4efeed04f0 100644 --- a/app/views/projects/tags/releases/edit.html.haml +++ b/app/views/projects/tags/releases/edit.html.haml @@ -12,8 +12,7 @@ = form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name), - html: { class: 'common-note-form release-form js-quick-submit' }, - data: { markdown_version: @release.cached_markdown_version }) do |f| + html: { class: 'common-note-form release-form js-quick-submit' }) do |f| = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…" = render 'shared/notes/hints' diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 15a960f81b8..feeaf799f51 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -20,7 +20,7 @@ .nav-controls.controls-flex - if can?(current_user, :push_code, @project) - = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do + = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-edit controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do = icon("pencil") = link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse files') do = icon('files-o') diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 4e9a119ac66..ec8e5234bd4 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -40,27 +40,24 @@ #{ _('New directory') } - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project) %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, + - continue_params = { to: project_new_blob_path(@project, @id), + notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) = link_to fork_path, method: :post do #{ _('New file') } %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file again.", + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to upload a file again.", notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) = link_to fork_path, method: :post do #{ _('Upload file') } %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory again.", + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to create a new directory again.", notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) = link_to fork_path, method: :post do #{ _('New directory') } diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 7d8826e540c..5bb69563b51 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -1,13 +1,9 @@ - commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}") - commit_message = commit_message % { page_title: @page.title } -- if params[:legacy_render] || !commonmark_for_repositories_enabled? - - markdown_version = CacheMarkdownField::CACHE_REDCARPET_VERSION -- else - - markdown_version = 0 = form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' }, - data: { markdown_version: markdown_version, uploads_path: uploads_path } do |f| + data: { uploads_path: uploads_path } do |f| = form_errors(@page) - if @page.persisted? @@ -16,7 +12,7 @@ .form-group.row .col-sm-12= f.label :title, class: 'control-label-full-width' .col-sm-12 - = f.text_field :title, class: 'form-control', value: @page.title + = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title - if @page.persisted? %span.edit-wiki-page-slug-tip = icon('lightbulb-o') @@ -31,7 +27,7 @@ .col-sm-12= f.label :content, class: 'control-label-full-width' .col-sm-12 = render layout: 'projects/md_preview', locals: { url: project_wiki_preview_markdown_path(@project, @page.slug) } do - = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: s_("WikiPage|Write your content or drag files here…") + = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea qa-wiki-content-textarea', placeholder: s_("WikiPage|Write your content or drag files here…") = render 'shared/notes/hints' .clearfix @@ -47,14 +43,14 @@ .form-group.row .col-sm-12= f.label :commit_message, class: 'control-label-full-width' - .col-sm-12= f.text_field :message, class: 'form-control', rows: 18, value: commit_message + .col-sm-12= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: commit_message .form-actions - if @page && @page.persisted? - = f.submit _("Save changes"), class: 'btn-success btn' + = f.submit _("Save changes"), class: 'btn-success btn qa-save-changes-button' .float-right = link_to _("Cancel"), project_wiki_path(@project, @page), class: 'btn btn-cancel btn-grouped' - else - = f.submit s_("Wiki|Create page"), class: 'btn-success btn' + = f.submit s_("Wiki|Create page"), class: 'btn-success btn qa-create-page-button' .float-right = link_to _("Cancel"), project_wiki_path(@project, :home), class: 'btn btn-cancel' diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml index 02c5a6ea55c..28353927135 100644 --- a/app/views/projects/wikis/_sidebar.html.haml +++ b/app/views/projects/wikis/_sidebar.html.haml @@ -12,7 +12,7 @@ .blocks-container .block.block-first - if @sidebar_page - = render_wiki_content(@sidebar_page, legacy_render_context(params)) + = render_wiki_content(@sidebar_page) - else %ul.wiki-pages = render @sidebar_wiki_entries, context: 'sidebar' diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index 26671a7b7d2..1277ea6c743 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -23,9 +23,6 @@ = s_("Wiki|Create Page") .nav-controls - - if can?(current_user, :create_wiki, @project) - = link_to '#modal-new-wiki', class: "add-new-wiki btn btn-success", "data-toggle" => "modal" do - = s_("Wiki|New page") - if @page.persisted? = link_to project_wiki_history_path(@project, @page), class: "btn" do = s_("Wiki|Page history") diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index 8b348bb4e4f..8e1c054b50c 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -27,6 +27,6 @@ .prepend-top-default.append-bottom-default .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) } - = render_wiki_content(@page, legacy_render_context(params)) + = render_wiki_content(@page) = render 'sidebar' diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index e0130f9a4b5..a60a4501557 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -21,7 +21,7 @@ .file-content.wiki - snippet_chunks.each do |chunk| - unless chunk[:data].empty? - = markup(snippet.file_name, chunk[:data], legacy_render_context(params)) + = markup(snippet.file_name, chunk[:data]) - else .file-content.code .nothing-here-block= _("Empty file") diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 422be28737c..755fd3a17d3 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -1,5 +1,5 @@ - if show_auto_devops_implicitly_enabled_banner?(project, current_user) - .auto-devops-implicitly-enabled-banner.alert.alert-warning + .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.alert.alert-warning - more_information_link = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link' - auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link } = auto_devops_message.html_safe diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml index 1ff956649ed..c9ff63f8c45 100644 --- a/app/views/shared/boards/components/_sidebar.html.haml +++ b/app/views/shared/boards/components/_sidebar.html.haml @@ -1,4 +1,4 @@ -%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json } +%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json } %transition{ name: "boards-sidebar-slide" } %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" } .issuable-sidebar diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml index 555cb4f4af9..bba3475d244 100644 --- a/app/views/shared/empty_states/_priority_labels.html.haml +++ b/app/views/shared/empty_states/_priority_labels.html.haml @@ -1,4 +1,4 @@ .text-center - .svg-content + .svg-content.qa-label-svg = image_tag 'illustrations/priority_labels.svg' %p Star labels to start sorting by priority diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml new file mode 100644 index 00000000000..6da40e1b059 --- /dev/null +++ b/app/views/shared/empty_states/_profile_tabs.html.haml @@ -0,0 +1,19 @@ +- current_user_empty_message_description = local_assigns.fetch(:current_user_empty_message_description, nil) +- secondary_button_link = local_assigns.fetch(:secondary_button_link, nil) + +.nothing-here-block + .svg-content + = image_tag illustration_path, size: '75' + .text-content + - if user_profile? and current_user.present? and current_user.username == params[:username] + %h5= current_user_empty_message_header + + - if current_user_empty_message_description.present? + %p= current_user_empty_message_description + + - if secondary_button_link.present? + = link_to secondary_button_label, secondary_button_link, class: 'btn btn-create btn-inverted' + + = link_to primary_button_label, primary_button_link, class: 'btn btn-success' + - else + %h5= visitor_empty_message diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml new file mode 100644 index 00000000000..a1a16b9d067 --- /dev/null +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -0,0 +1,20 @@ +- button_path = local_assigns.fetch(:button_path, false) + +.row.empty-state + .col-12 + .svg-content + = image_tag 'illustrations/snippets_empty.svg' + .text-content + - if current_user + %h4 + = s_('SnippetsEmptyState|Snippets are small pieces of code or notes that you want to keep.') + %p + = s_('SnippetsEmptyState|They can be either public or private.') + .text-center + = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link' + - unless current_page?(dashboard_snippets_path) + = link_to s_('SnippetsEmptyState|Explore public snippets'), explore_snippets_path, class: 'btn btn-default', title: s_('SnippetsEmptyState|Explore public snippets') + - else + %h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.') + + diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml index df3308abe0d..73eedcc1dc9 100644 --- a/app/views/shared/empty_states/_wikis.html.haml +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -2,7 +2,7 @@ - if can?(current_user, :create_wiki, @project) - create_path = project_wiki_path(@project, params[:id], { view: 'create' }) - - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success', title: s_('WikiEmpty|Create your first page') + - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success qa-create-first-page-link', title: s_('WikiEmpty|Create your first page') = render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do %h4.text-left diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml index a5f100e3469..d44017299b8 100644 --- a/app/views/shared/empty_states/_wikis_layout.html.haml +++ b/app/views/shared/empty_states/_wikis_layout.html.haml @@ -1,6 +1,6 @@ .row.empty-state .col-12 - .svg-content + .svg-content.qa-svg-content = image_tag image_path .col-12 .text-content.text-center diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index a1b901aaffa..609b8dce21a 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -14,7 +14,7 @@ %span.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) } = visibility_level_icon(group.visibility_level, fw: false) - .avatar-container.s40 + .avatar-container.rect-avatar.s40 = link_to group do = group_icon(group, class: "avatar s40 d-none d-sm-block") .title diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml index f50a6bd4d6a..c5b39c7db08 100644 --- a/app/views/shared/groups/_list.html.haml +++ b/app/views/shared/groups/_list.html.haml @@ -1,3 +1,10 @@ +- illustration_path = 'illustrations/profile-page/groups.svg' +- current_user_empty_message_header = s_('UserProfile|You can create a group for several dependent projects.') +- current_user_empty_message_description = s_('UserProfile|Groups are the best way to manage projects and members.') +- primary_button_label = _('New group') +- primary_button_link = new_group_path +- visitor_empty_message = s_('GroupsEmptyState|No groups found') + - if groups.any? - user = local_assigns[:user] @@ -5,4 +12,9 @@ - groups.each_with_index do |group, i| = render "shared/groups/group", group: group, user: user - else - .nothing-here-block= s_("GroupsEmptyState|No groups found") + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path, + current_user_empty_message_header: current_user_empty_message_header, + current_user_empty_message_description: current_user_empty_message_description, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + visitor_empty_message: visitor_empty_message } diff --git a/app/views/shared/icons/_express.svg b/app/views/shared/icons/_express.svg deleted file mode 100644 index a51e81e5568..00000000000 --- a/app/views/shared/icons/_express.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="27" height="32" viewBox="0 0 27 32" class="btn-template-icon icon-node-express"><g fill="none" fill-rule="evenodd"><path d="M-3 0h32v32H-3z"/><path fill="#353535" d="M1.192 16.267c.04 2.065.288 3.982.745 5.75.456 1.767 1.16 3.307 2.115 4.618.953 1.31 2.185 2.343 3.694 3.098 1.51.755 3.357 1.132 5.54 1.132 3.22 0 5.89-.844 8.016-2.532 2.125-1.69 3.446-4.22 3.962-7.597h1.192c-.437 3.575-1.847 6.345-4.23 8.312-2.384 1.966-5.324 2.95-8.82 2.95-2.383.04-4.42-.338-6.107-1.133-1.69-.794-3.07-1.917-4.142-3.367-1.073-1.45-1.867-3.158-2.383-5.124C.258 20.408 0 18.294 0 16.028c0-2.542.377-4.806 1.132-6.792C1.887 7.25 2.88 5.57 4.112 4.2 5.34 2.83 6.77 1.79 8.4 1.074 10.03.358 11.698 0 13.406 0c2.383 0 4.44.457 6.167 1.37 1.728.914 3.138 2.126 4.23 3.635 1.093 1.51 1.887 3.238 2.384 5.184.496 1.945.705 3.97.625 6.077H1.193zm24.43-1.192c0-1.867-.26-3.645-.775-5.333-.516-1.688-1.28-3.168-2.294-4.44-1.013-1.27-2.274-2.273-3.784-3.008-1.51-.735-3.258-1.102-5.244-1.102-1.67 0-3.228.317-4.678.953-1.45.636-2.72 1.56-3.813 2.77-1.092 1.212-1.976 2.672-2.652 4.38-.675 1.708-1.072 3.635-1.19 5.78h24.43z"/></g></svg> diff --git a/app/views/shared/icons/_rails.svg b/app/views/shared/icons/_rails.svg deleted file mode 100644 index 852bd183cc7..00000000000 --- a/app/views/shared/icons/_rails.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="32" height="20" viewBox="0 0 32 20" class="btn-template-icon icon-rails"><g fill="none" fill-rule="evenodd"><path d="M0-6h32v32H0z"/><path fill="#c00" fill-rule="nonzero" d="M.985 19.636s.422-4.163 3.375-9.087c2.954-4.924 7.99-8.65 12.083-9.017 8.144-.816 15.46 6.485 15.46 6.485s-.24.168-.494.38C23.42 2.49 18.54 5.274 17.005 6.02c-7.033 3.925-4.91 13.616-4.91 13.616H.987zM24.137 2.32c-.45-.182-.9-.35-1.364-.505l.056-.93c.885.254 1.237.423 1.363.493l-.056.943zM22.8 5.304c.45.028.915.084 1.393.183l-.056.872c-.464-.1-.928-.155-1.392-.17l.056-.885zM17.597.913c-.407 0-.815.015-1.223.058l-.268-.83c.465-.056.915-.084 1.35-.084l.282.858h-.14zm.676 5.178c.35-.154.76-.31 1.237-.45l.31.93c-.41.125-.817.294-1.225.49l-.323-.97zm-6.386-3.7c-.366.184-.718.395-1.083.62l-.647-.985c.38-.225.745-.42 1.097-.604l.633.97zm2.883 6.33c.252-.323.548-.646.87-.942l.634.957c-.31.323-.59.647-.83 1L14.77 8.72zm-2.04 4.53c.112-.506.24-1.027.422-1.547l1.012.802c-.14.548-.24 1.097-.295 1.645l-1.14-.9zM6.57 6.57c-.34.35-.662.73-.958 1.11L4.53 6.752c.323-.352.674-.704 1.04-1.055l1 .872zm-4.25 6.286c-.224.52-.52 1.21-.702 1.688L0 13.954c.14-.38.436-1.084.703-1.69l1.618.592zm10.2 3.967l1.518.548c.084.663.21 1.28.337 1.83l-1.688-.605c-.07-.422-.14-1.027-.168-1.772z"/></g></svg> diff --git a/app/views/shared/icons/_spring.svg b/app/views/shared/icons/_spring.svg deleted file mode 100644 index ccf18749029..00000000000 --- a/app/views/shared/icons/_spring.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" class="btn-template-icon icon-java-spring"><g fill="none" fill-rule="evenodd"><path d="M0 0h32v32H0z"/><path fill="#70AD51" d="M5.466 27.993c.586.473 1.446.385 1.918-.202.475-.585.386-1.445-.2-1.92-.585-.474-1.444-.383-1.92.202-.45.555-.392 1.356.115 1.844l-.266-.234C1.972 24.762 0 20.597 0 15.978 0 7.168 7.168 0 15.98 0c4.48 0 8.53 1.857 11.435 4.836.66-.898 1.232-1.902 1.7-3.015 2.036 6.118 3.233 11.26 2.795 15.31-.592 8.274-7.508 14.83-15.93 14.83-3.912 0-7.496-1.416-10.276-3.757l-.238-.21zm23.58-4.982c4.01-5.336 1.775-13.965-.085-19.48-1.657 3.453-5.738 6.094-9.262 6.93-3.303.788-6.226.142-9.283 1.318-6.97 2.68-6.86 10.992-3.02 12.86.002 0 .23.124.227.12 0-.002 5.644-1.122 8.764-2.274 4.56-1.684 9.566-5.835 11.213-10.657-.877 5.015-5.182 9.84-9.507 12.056-2.302 1.182-4.092 1.445-7.88 2.756-.464.158-.828.314-.828.314.96-.16 1.917-.212 1.917-.212 5.393-.255 13.807 1.516 17.745-3.73z"/></g></svg> diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index ca02424215c..909eb738f95 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -2,7 +2,7 @@ %aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } } .issuable-sidebar.hidden - = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do + = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do .block.issuable-sidebar-header .filter-item.inline.update-issues-btn.float-left = button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true @@ -50,4 +50,3 @@ = hidden_field_tag "update[issuable_ids]", [] = hidden_field_tag :state_event, params[:state_event] - diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 20847378495..bdba47ed14d 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -57,10 +57,10 @@ avatar: { lazy: true, url: '{{avatar_url}}' } #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } + %li.filter-dropdown-item{ data: { value: 'None' } } %button.btn.btn-link{ type: 'button' } = _('None') - %li.filter-dropdown-item{ data: { value: 'any' } } + %li.filter-dropdown-item{ data: { value: 'Any' } } %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore @@ -73,16 +73,16 @@ avatar: { lazy: true, url: '{{avatar_url}}' } #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } + %li.filter-dropdown-item{ data: { value: 'None' } } %button.btn.btn-link{ type: 'button' } = _('None') - %li.filter-dropdown-item{ data: { value: 'any' } } + %li.filter-dropdown-item{ data: { value: 'Any' } } %button.btn.btn-link{ type: 'button' } = _('Any') - %li.filter-dropdown-item{ data: { value: 'upcoming' } } + %li.filter-dropdown-item{ data: { value: 'Upcoming' } } %button.btn.btn-link{ type: 'button' } = _('Upcoming') - %li.filter-dropdown-item{ data: { value: 'started' } } + %li.filter-dropdown-item{ data: { value: 'Started' } } %button.btn.btn-link{ type: 'button' } = _('Started') %li.divider.droplab-item-ignore @@ -92,10 +92,10 @@ {{title}} #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } + %li.filter-dropdown-item{ data: { value: 'None' } } %button.btn.btn-link{ type: 'button' } = _('None') - %li.filter-dropdown-item{ data: { value: 'any' } } + %li.filter-dropdown-item{ data: { value: 'Any' } } %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore @@ -107,10 +107,10 @@ {{title}} #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } + %li.filter-dropdown-item{ data: { value: 'None' } } %button.btn.btn-link{ type: 'button' } = _('None') - %li.filter-dropdown-item{ data: { value: 'any' } } + %li.filter-dropdown-item{ data: { value: 'Any' } } %button.btn.btn-link{ type: 'button' } = _('Any') %li.divider.droplab-item-ignore @@ -128,6 +128,14 @@ %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } %button.btn.btn-link{ type: 'button' } = _('No') + #js-dropdown-confidential.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 diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 0520eda37a4..9596c1df20e 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -133,7 +133,7 @@ #js-confidential-entry-point -# haml-lint:disable InlineJavaScript - %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe + %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe #js-lock-entry-point .js-sidebar-participants-entry-point diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index b6ea9185b10..967f31c8325 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -5,7 +5,7 @@ .dropdown.inline.prepend-left-10.issue-sort-dropdown .btn-group{ role: 'group' } .btn-group{ role: 'group' } - %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } = sort_title = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml deleted file mode 100644 index ebae58f28ba..00000000000 --- a/app/views/shared/members/_access_request_buttons.html.haml +++ /dev/null @@ -1,20 +0,0 @@ -- model_name = source.model_name.to_s.downcase - -- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id)) # rubocop: disable CodeReuse/ActiveRecord - .project-action-button.inline - - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') - = link_to link_text, polymorphic_path([:leave, source, :members]), - method: :delete, - data: { confirm: leave_confirmation_message(source) }, - class: 'btn' -- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord - .project-action-button.inline - = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), - method: :delete, - data: { confirm: remove_member_message(requester) }, - class: 'btn' -- elsif source.request_access_enabled && can?(current_user, :request_access, source) - .project-action-button.inline - = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]), - method: :post, - class: 'btn' diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index 922805958a5..4de89d7c7a0 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -1,11 +1,13 @@ .col-md-6 .form-group.row - = f.label :start_date, "Start Date", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :start_date, "Start Date" .col-sm-10 = f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date", autocomplete: 'off' %a.inline.float-right.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date .form-group.row - = f.label :due_date, "Due Date", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :due_date, "Due Date" .col-sm-10 = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off' %a.inline.float-right.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date diff --git a/app/views/shared/milestones/_search_form.html.haml b/app/views/shared/milestones/_search_form.html.haml new file mode 100644 index 00000000000..403a0224a85 --- /dev/null +++ b/app/views/shared/milestones/_search_form.html.haml @@ -0,0 +1,8 @@ += form_tag request.path, method: :get do |f| + = search_field_tag :search_title, params[:search_title], + placeholder: _('Filter by milestone name'), + class: 'form-control input-short', + spellcheck: false + = hidden_field_tag :state, params[:state] + = hidden_field_tag :sort, params[:sort] + diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index cb5c64fb91d..41d6ae79c81 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -54,7 +54,7 @@ .note-text.md = markdown_field(note, :note) = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') - .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore, markdown_version: note.cached_markdown_version } } + .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } #{note.note} - if note_editable = render 'shared/notes/edit', note: note diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index 30860988bbb..2ece7b7f701 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -1,7 +1,7 @@ - btn_class = local_assigns.fetch(:btn_class, nil) - if notification_setting - .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline + .js-notification-dropdown.notification-dropdown.home-panel-action-button.dropdown.inline = form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f| = hidden_setting_source_input(notification_setting) = f.hidden_field :level, class: "notification_setting_level" diff --git a/app/views/projects/buttons/_notifications.html.haml b/app/views/shared/notifications/_new_button.html.haml index a8b728527c8..6d26dbebbc8 100644 --- a/app/views/projects/buttons/_notifications.html.haml +++ b/app/views/shared/notifications/_new_button.html.haml @@ -1,7 +1,7 @@ -- btn_class = local_assigns.fetch(:btn_class, "btn-xs") +- btn_class = local_assigns.fetch(:btn_class, nil) - if notification_setting - .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline + .js-notification-dropdown.notification-dropdown.home-panel-action-button.prepend-top-default.append-right-8.dropdown.inline = form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f| = hidden_setting_source_input(notification_setting) = hidden_field_tag "hide_label", true @@ -9,14 +9,14 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") %span.js-notification-loading.fa.hidden %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" }, class: "#{btn_class}" } = sprite_icon("arrow-down", css_class: "icon mr-0") .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon") %span.js-notification-loading.fa.hidden = sprite_icon("arrow-down", css_class: "icon") diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 7d90d9ca4a5..13847cd9be1 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -14,6 +14,17 @@ - skip_pagination = false unless local_assigns[:skip_pagination] == true - compact_mode = false unless local_assigns[:compact_mode] == true - css_classes = "#{'compact' if compact_mode} #{'explore' if explore_projects_tab?}" +- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg' +- contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.') +- contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects') +- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg' +- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.') +- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.') +- own_projects_visitor_empty_message = s_('UserProfile|This user doesn\'t have any personal projects') +- primary_button_label = _('New project') +- primary_button_link = new_project_path +- secondary_button_label = _('Explore groups') +- secondary_button_link = explore_groups_path .js-projects-list-holder - if any_projects?(projects) @@ -33,9 +44,18 @@ %span you have no access to. = paginate_collection(projects, remote: remote) unless skip_pagination - else - .nothing-here-block - .svg-content.svg-130 - = image_tag 'illustrations/profile-page/personal-project.svg' - %div - %span - = s_('UserProfile|This user doesn\'t have any personal projects') + - if @contributed_projects + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path, + current_user_empty_message_header: contributed_projects_current_user_empty_message_header, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + secondary_button_label: secondary_button_label, + secondary_button_link: secondary_button_link, + visitor_empty_message: contributed_projects_visitor_empty_message } + - else + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path, + current_user_empty_message_header: own_projects_current_user_empty_message_header, + current_user_empty_message_description: own_projects_current_user_empty_message_description, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + visitor_empty_message: own_projects_visitor_empty_message } diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index e1564d57426..f1a87faa7ac 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,21 +12,21 @@ - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = project_list_cache_key(project) - updated_tooltip = time_ago_with_tooltip(project.last_activity_date) -- css_details_class = compact_mode ? "d-flex flex-column flex-sm-row flex-md-row align-items-sm-center" : "align-items-center flex-md-fill flex-lg-column d-sm-flex d-lg-block" -- css_controls_class = compact_mode ? "" : "align-items-md-end align-items-lg-center flex-lg-row" +- css_controls_class = compact_mode ? "" : "flex-lg-row justify-content-lg-between" +- avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar' %li.project-row.d-flex{ class: css_class } = cache(cache_key) do - if avatar - .avatar-container.s64.flex-grow-0.flex-shrink-0 + .avatar-container.s48.flex-grow-0.flex-shrink-0{ class: avatar_container_class } = link_to project_path(project), class: dom_class(project) do - if project.creator && use_creator_avatar - = image_tag avatar_icon_for_user(project.creator, 64), class: "avatar s65", alt:'' + = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:'' - else - = project_icon(project, alt: '', class: 'avatar project-avatar s64', width: 64, height: 64) - .project-details.flex-sm-fill{ class: css_details_class } - .flex-wrapper.flex-fill - .d-flex.align-items-center.flex-wrap + = project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48) + .project-details.d-sm-flex.flex-sm-fill.align-items-center + .flex-wrapper + .d-flex.align-items-center.flex-wrap.project-title %h2.d-flex.prepend-top-8 = link_to project_path(project), class: 'text-plain' do %span.project-full-name.append-right-8>< @@ -52,13 +52,13 @@ %span.user-access-role.d-block= Gitlab::Access.human_access(access) - if show_last_commit_as_description - .description.d-none.d-sm-block.prepend-top-8.append-right-default + .description.d-none.d-sm-block.append-right-default = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message") - elsif project.description.present? - .description.d-none.d-sm-block.prepend-top-8.append-right-default + .description.d-none.d-sm-block.append-right-default = markdown_field(project, :description) - .controls.d-flex.flex-row.flex-sm-column.flex-md-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0{ class: css_controls_class } + .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0{ class: css_controls_class } .icon-container.d-flex.align-items-center - if project.archived %span.d-flex.icon-wrapper.badge.badge-warning archived @@ -74,13 +74,13 @@ = number_with_delimiter(project.forks_count) - if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode) = link_to project_merge_requests_path(project), - class: "d-none d-lg-flex align-items-center icon-wrapper merge-requests has-tooltip", + class: "d-none d-xl-flex align-items-center icon-wrapper merge-requests has-tooltip", title: _('Merge Requests'), data: { container: 'body', placement: 'top' } do = sprite_icon('git-merge', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.open_merge_requests_count) - if show_issue_count?(disabled: !issues, compact_mode: compact_mode) = link_to project_issues_path(project), - class: "d-none d-lg-flex align-items-center icon-wrapper issues has-tooltip", + class: "d-none d-xl-flex align-items-center icon-wrapper issues has-tooltip", title: _('Issues'), data: { container: 'body', placement: 'top' } do = sprite_icon('issues', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.open_issues_count) @@ -89,19 +89,3 @@ = render_project_pipeline_status(project.pipeline_status, tooltip_placement: 'top') .updated-note %span Updated #{updated_tooltip} - - .d-none.d-lg-flex.align-item-stretch - - unless compact_mode - - if current_user - %button.star-button.btn.btn-default.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(project, :json) } } - - if current_user.starred?(project) - = sprite_icon('star', { css_class: 'icon' }) - %span.starred= s_('ProjectOverview|Unstar') - - else - = sprite_icon('star-o', { css_class: 'icon' }) - %span= s_('ProjectOverview|Star') - - - else - = link_to new_user_session_path, class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do - = sprite_icon('star-o', { css_class: 'icon' }) - %span= s_('ProjectOverview|Star') diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index cf9c3055499..3007da0c189 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -3,8 +3,7 @@ .snippet-form-holder = form_for @snippet, url: url, - html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" }, - data: { markdown_version: @snippet.cached_markdown_version } do |f| + html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f| = form_errors(@snippet) .form-group.row diff --git a/app/views/shared/snippets/_list.html.haml b/app/views/shared/snippets/_list.html.haml new file mode 100644 index 00000000000..5d2152eb411 --- /dev/null +++ b/app/views/shared/snippets/_list.html.haml @@ -0,0 +1,12 @@ +- remote = local_assigns.fetch(:remote, false) +- link_project = local_assigns.fetch(:link_project, false) + +- if @snippets.exists? + .snippets-list-holder + %ul.content-list + = render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project } + + = paginate @snippets, theme: 'gitlab', remote: remote + +- else + .nothing-here-block= s_("SnippetsEmptyState|No snippets found") diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index 69d41f8fe5e..dab247da251 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -1,10 +1,21 @@ - link_project = local_assigns.fetch(:link_project, false) +- illustration_path = 'illustrations/profile-page/activity.svg' +- current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.') +- current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.') +- primary_button_label = _('New snippet') +- primary_button_link = new_snippet_path +- visitor_empty_message = s_('UserProfile|No snippets found.') .snippets-list-holder %ul.content-list = render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project } - if @snippets.empty? %li - .nothing-here-block= _("Nothing here.") + = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path, + current_user_empty_message_header: current_user_empty_message_header, + current_user_empty_message_description: current_user_empty_message_description, + primary_button_label: primary_button_label, + primary_button_link: primary_button_link, + visitor_empty_message: visitor_empty_message } = paginate @snippets, theme: 'gitlab' diff --git a/app/views/snippets/index.html.haml b/app/views/snippets/index.html.haml index 4f418e2381f..f5fe75bed5a 100644 --- a/app/views/snippets/index.html.haml +++ b/app/views/snippets/index.html.haml @@ -10,4 +10,4 @@ = link_to user_path(@user) do = _("%{user_name} profile page") % { user_name: @user.name } -= render 'snippets' += render 'shared/snippets/list' diff --git a/app/views/users/_groups.html.haml b/app/views/users/_groups.html.haml index 55799e10a46..6d7a52c7688 100644 --- a/app/views/users/_groups.html.haml +++ b/app/views/users/_groups.html.haml @@ -1,5 +1,5 @@ .clearfix - groups.each do |group| = link_to group, class: 'profile-groups-avatars inline', title: group.name do - .avatar-container.s40 + .avatar-container.rect-avatar.s40 = group_icon(group, class: 'avatar group-avatar s40') diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 8da63a29ca6..211e3eafac6 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -74,11 +74,11 @@ = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow' - unless @user.location.blank? .profile-link-holder.middle-dot-divider - = icon('map-marker') + = sprite_icon('location', size: 16, css_class: 'vertical-align-sub') = @user.location - unless @user.organization.blank? .profile-link-holder.middle-dot-divider - = icon('briefcase') + = sprite_icon('work', size: 16, css_class: 'vertical-align-sub') = @user.organization - if @user.bio.present? diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 223ddc80c88..d0fc130b04f 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -23,6 +23,7 @@ - cronjob:prune_web_hook_logs - gcp_cluster:cluster_install_app +- gcp_cluster:cluster_upgrade_app - gcp_cluster:cluster_provision - gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:wait_for_cluster_creation @@ -45,6 +46,8 @@ - github_importer:github_import_stage_import_pull_requests - github_importer:github_import_stage_import_repository +- hashed_storage:hashed_storage_migrator + - mail_scheduler:mail_scheduler_issue_due - mail_scheduler:mail_scheduler_notification_service @@ -90,13 +93,16 @@ - object_pool:object_pool_join - object_pool:object_pool_destroy +- container_repository:delete_container_repository +- container_repository:cleanup_container_repository + - default - mailers # ActionMailer::DeliveryJob.queue_name - authorized_projects - background_migration +- chat_notification - create_gpg_signature -- delete_container_repository - delete_merged_branches - delete_user - email_receiver @@ -129,7 +135,6 @@ - repository_fork - repository_import - repository_remove_remote -- storage_migrator - system_hook_push - update_merge_requests - upload_checksum diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index ae853ec9316..adc38226405 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -30,5 +30,6 @@ class BuildFinishedWorker # We execute these async as these are independent operations. BuildHooksWorker.perform_async(build.id) ArchiveTraceWorker.perform_async(build.id) + ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? end end diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb new file mode 100644 index 00000000000..25a306e94d8 --- /dev/null +++ b/app/workers/chat_notification_worker.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class ChatNotificationWorker + include ApplicationWorker + + RESCHEDULE_INTERVAL = 2.seconds + + # rubocop: disable CodeReuse/ActiveRecord + def perform(build_id) + Ci::Build.find_by(id: build_id).try do |build| + send_response(build) + end + rescue Gitlab::Chat::Output::MissingBuildSectionError + # The creation of traces and sections appears to be eventually consistent. + # As a result it's possible for us to run the above code before the trace + # sections are present. To better handle such cases we'll just reschedule + # the job instead of producing an error. + self.class.perform_in(RESCHEDULE_INTERVAL, build_id) + end + # rubocop: enable CodeReuse/ActiveRecord + + def send_response(build) + Gitlab::Chat::Responder.responder_for(build).try do |responder| + if build.success? + output = Gitlab::Chat::Output.new(build) + + responder.success(output.to_s) + else + responder.failure + end + end + end +end diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb new file mode 100644 index 00000000000..974ee8c8146 --- /dev/null +++ b/app/workers/cleanup_container_repository_worker.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class CleanupContainerRepositoryWorker + include ApplicationWorker + include ExclusiveLeaseGuard + + queue_namespace :container_repository + + LEASE_TIMEOUT = 1.hour + + attr_reader :container_repository, :current_user + + def perform(current_user_id, container_repository_id, params) + @current_user = User.find_by_id(current_user_id) + @container_repository = ContainerRepository.find_by_id(container_repository_id) + + return unless valid? + + try_obtain_lease do + Projects::ContainerRepository::CleanupTagsService + .new(project, current_user, params) + .execute(container_repository) + end + end + + private + + def valid? + current_user && container_repository && project + end + + def project + container_repository&.project + end + + # For ExclusiveLeaseGuard concern + def lease_key + @lease_key ||= "container_repository:cleanup_tags:#{container_repository.id}" + end + + # For ExclusiveLeaseGuard concern + def lease_timeout + LEASE_TIMEOUT + end + + # For ExclusiveLeaseGuard concern + def lease_release? + # we don't allow to execute this worker + # more often than LEASE_TIMEOUT + # for given container repository + false + end +end diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb new file mode 100644 index 00000000000..d1a538859b4 --- /dev/null +++ b/app/workers/cluster_upgrade_app_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ClusterUpgradeAppWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::UpgradeService.new(app).execute + end + end +end diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb index e8fe9d82797..42e66513ff1 100644 --- a/app/workers/delete_container_repository_worker.rb +++ b/app/workers/delete_container_repository_worker.rb @@ -4,6 +4,8 @@ class DeleteContainerRepositoryWorker include ApplicationWorker include ExclusiveLeaseGuard + queue_namespace :container_repository + LEASE_TIMEOUT = 1.hour attr_reader :container_repository diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index dce812d1ae2..251e95c68d5 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -4,8 +4,20 @@ class ExpireBuildArtifactsWorker include ApplicationWorker include CronjobQueue - # rubocop: disable CodeReuse/ActiveRecord def perform + if Feature.enabled?(:ci_new_expire_job_artifacts_service, default_enabled: true) + perform_efficient_artifacts_removal + else + perform_legacy_artifacts_removal + end + end + + def perform_efficient_artifacts_removal + Ci::DestroyExpiredJobArtifactsService.new.execute + end + + # rubocop: disable CodeReuse/ActiveRecord + def perform_legacy_artifacts_removal Rails.logger.info 'Scheduling removal of build artifacts' build_ids = Ci::Build.with_expired_artifacts.pluck(:id) diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 148384600b6..2c070d482a1 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -37,9 +37,9 @@ class ExpirePipelineCacheWorker Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json) end - def each_pipelines_merge_request_path(project, pipeline) + def each_pipelines_merge_request_path(pipeline) pipeline.all_merge_requests.each do |merge_request| - path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(project, merge_request, format: :json) + path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json) yield(path) end @@ -59,7 +59,7 @@ class ExpirePipelineCacheWorker store.touch(project_pipeline_path(project, pipeline)) store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil? store.touch(new_merge_request_pipelines_path(project)) - each_pipelines_merge_request_path(project, pipeline) do |path| + each_pipelines_merge_request_path(pipeline) do |path| store.touch(path) end end diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb new file mode 100644 index 00000000000..49e347d4060 --- /dev/null +++ b/app/workers/hashed_storage/migrator_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module HashedStorage + class MigratorWorker + include ApplicationWorker + + queue_namespace :hashed_storage + + # @param [Integer] start initial ID of the batch + # @param [Integer] finish last ID of the batch + def perform(start, finish) + migrator = Gitlab::HashedStorage::Migrator.new + migrator.bulk_migrate(start: start, finish: finish) + end + end +end diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index c8ccaf0c487..421fbf04e28 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -23,7 +23,7 @@ module MailScheduler end def self.perform_async(*args) - super(*ActiveJob::Arguments.serialize(args)) + super(*Arguments.serialize(args)) end private @@ -38,5 +38,34 @@ module MailScheduler end end end + + # Permit ActionController::Parameters for serializable Hash + # + # Port of + # https://github.com/rails/rails/commit/945fdd76925c9f615bf016717c4c8db2b2955357#diff-fc90ec41ef75be8b2259526fe1a8b663 + module Arguments + include ActiveJob::Arguments + extend self + + private + + def serialize_argument(argument) + case argument + when -> (arg) { arg.respond_to?(:permitted?) } + serialize_hash(argument.to_h).tap do |result| + result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true) + end + else + super + end + end + end + + # Make sure we remove this patch starting with Rails 6.0. + if Rails.version.start_with?('6.0') + raise <<~MSG + Please remove the patch `Arguments` module and use `ActiveJob::Arguments` again. + MSG + end end end diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb index 2965f3b1150..f6e98746055 100644 --- a/app/workers/namespaceless_project_destroy_worker.rb +++ b/app/workers/namespaceless_project_destroy_worker.rb @@ -17,7 +17,7 @@ class NamespacelessProjectDestroyWorker return end - return if project.namespace # Reject doing anything for projects that *do* have a namespace + return if project.namespace # Reject doing anything for projects that *do* have a namespace project.team.truncate diff --git a/app/workers/project_migrate_hashed_storage_worker.rb b/app/workers/project_migrate_hashed_storage_worker.rb index 4c6339f7701..1c8f313e6e9 100644 --- a/app/workers/project_migrate_hashed_storage_worker.rb +++ b/app/workers/project_migrate_hashed_storage_worker.rb @@ -4,21 +4,25 @@ class ProjectMigrateHashedStorageWorker include ApplicationWorker LEASE_TIMEOUT = 30.seconds.to_i + LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker'.freeze # rubocop: disable CodeReuse/ActiveRecord def perform(project_id, old_disk_path = nil) - project = Project.find_by(id: project_id) - return if project.nil? || project.pending_delete? - uuid = lease_for(project_id).try_obtain + if uuid - ::Projects::HashedStorageMigrationService.new(project, old_disk_path || project.full_path, logger: logger).execute + project = Project.find_by(id: project_id) + return if project.nil? || project.pending_delete? + + old_disk_path ||= project.disk_path + + ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute else - false + return false end - rescue => ex + + ensure cancel_lease_for(project_id, uuid) if uuid - raise ex end # rubocop: enable CodeReuse/ActiveRecord @@ -29,7 +33,8 @@ class ProjectMigrateHashedStorageWorker private def lease_key(project_id) - "project_migrate_hashed_storage_worker:#{project_id}" + # we share the same lease key for both migration and rollback so they don't run simultaneously + "#{LEASE_KEY_SEGMENT}:#{project_id}" end def cancel_lease_for(project_id, uuid) diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index 7c66ac046ea..9ec8bcca4f3 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -6,7 +6,7 @@ class ReactiveCachingWorker # rubocop: disable CodeReuse/ActiveRecord def perform(class_name, id, *args) klass = begin - Kernel.const_get(class_name) + class_name.constantize rescue NameError nil end diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 7eae07d3f6b..a9b88a133be 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -15,19 +15,19 @@ class RepositoryForkWorker return target_project.import_state.mark_as_failed(_('Source project cannot be found.')) end - fork_repository(target_project, source_project.repository_storage, source_project.disk_path) + fork_repository(target_project, source_project) end private - def fork_repository(target_project, source_repository_storage_name, source_disk_path) + def fork_repository(target_project, source_project) return unless start_fork(target_project) Gitlab::Metrics.add_event(:fork_repository) - result = gitlab_shell.fork_repository(source_repository_storage_name, source_disk_path, - target_project.repository_storage, target_project.disk_path) - raise "Unable to fork project #{target_project.id} for repository #{source_disk_path} -> #{target_project.disk_path}" unless result + result = gitlab_shell.fork_repository(source_project, target_project) + + raise "Unable to fork project #{target_project.id} for repository #{source_project.disk_path} -> #{target_project.disk_path}" unless result target_project.after_import end diff --git a/app/workers/storage_migrator_worker.rb b/app/workers/storage_migrator_worker.rb deleted file mode 100644 index fa76fbac55c..00000000000 --- a/app/workers/storage_migrator_worker.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class StorageMigratorWorker - include ApplicationWorker - - def perform(start, finish) - migrator = Gitlab::HashedStorage::Migrator.new - migrator.bulk_migrate(start, finish) - end -end |