diff options
Diffstat (limited to 'app')
170 files changed, 2511 insertions, 1247 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index fe02096d903..947d019c725 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -1,320 +1,8 @@ -/* 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 { DOMParser } from 'prosemirror-model'; +import { getSelectedFragment } from '~/lib/utils/common_utils'; +import schema from './schema'; +import markdownSerializer from './serializer'; export class CopyAsGFM { constructor() { @@ -347,8 +35,13 @@ 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); } static pasteGFM(e) { @@ -361,7 +54,7 @@ export class CopyAsGFM { e.preventDefault(); - window.gl.utils.insertText(e.target, (textBefore, textAfter) => { + window.gl.utils.insertText(e.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 +136,12 @@ 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(); - } + static nodeToGFM(node) { + const wrapEl = document.createElement('div'); + wrapEl.appendChild(node.cloneNode(true)); + const doc = DOMParser.fromSchema(schema).parse(wrapEl); - return nodeText; + return markdownSerializer.serialize(doc); } } 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/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/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 2918e1486a7..0eb067d4963 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,18 +62,18 @@ export default class ShortcutsIssuable extends Shortcuts { } const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); - const selected = CopyAsGFM.nodeToGFM(el); + const blockquoteEl = document.createElement('blockquote'); + blockquoteEl.appendChild(el); + const text = CopyAsGFM.nodeToGFM(blockquoteEl); - if (selected.trim() === '') { + if (text.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`) + .val((a, current) => `${current}${separator}${text}\n\n`) .trigger('input') .trigger('change'); diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 3770b5c8864..3ef54752436 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -2,10 +2,10 @@ 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'; export default { components: { @@ -13,6 +13,7 @@ export default { Icon, GlLink, GlButton, + SettingsDropdown, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,23 +36,10 @@ export default { }, computed: { ...mapState('diffs', ['commit', 'showTreeList', 'startVersion', 'latestVersionPath']), - ...mapGetters('diffs', ['isInlineView', 'isParallelView', 'hasCollapsedFile']), + ...mapGetters('diffs', ['hasCollapsedFile']), 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 +63,6 @@ export default { 'expandAllFiles', 'toggleShowTreeList', ]), - isWhitespaceVisible() { - return getParameterValues('w')[0] !== '1'; - }, }, }; </script> @@ -118,7 +103,7 @@ 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"> <gl-button v-if="commit || startVersion" :href="latestVersionPath" @@ -129,31 +114,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/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..0b3def3d29d 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, @@ -17,17 +14,12 @@ export default { FileRow, }, data() { - const treeListStored = localStorage.getItem(treeListStorageKey); - const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true; - return { search: '', - renderTreeList, - focusSearch: false, }; }, computed: { - ...mapState('diffs', ['tree', 'addedLines', 'removedLines']), + ...mapState('diffs', ['tree', 'addedLines', 'removedLines', 'renderTreeList']), ...mapGetters('diffs', ['allBlobs', 'diffFilesLength']), filteredTreeList() { const search = this.search.toLowerCase().trim(); @@ -52,19 +44,6 @@ export default { ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), 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, @@ -81,8 +60,6 @@ export default { :placeholder="s__('MergeRequest|Filter files')" type="search" class="form-control" - @focus="toggleFocusSearch(true)" - @blur="blurSearch" /> <button v-show="search" @@ -94,34 +71,6 @@ export default { <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> - </div> </div> <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll"> <template v-if="filteredTreeList.length"> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 0af1ba13d36..bd188d9de9e 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -34,3 +34,5 @@ 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'; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index b130cedc24c..094e5cdea9c 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,6 +1,9 @@ import Vue from 'vue'; -import { mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterValues } from '~/lib/utils/url_utility'; import diffsApp from './components/app.vue'; +import { TREE_LIST_STORAGE_KEY } from './constants'; export default function initDiffsApp(store) { return new Vue({ @@ -26,6 +29,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..2c5019fb652 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -14,6 +14,8 @@ import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME, MR_TREE_SHOW_KEY, + TREE_LIST_STORAGE_KEY, + WHITESPACE_STORAGE_KEY, } from '../constants'; export const setBaseConfig = ({ commit }, options) => { @@ -33,7 +35,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 || []); @@ -278,5 +280,21 @@ 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'); + } +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 98e57d52d77..05b4c552f6e 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -27,4 +27,6 @@ export default () => ({ projectPath: '', commentForms: [], highlightedRow: null, + renderTreeList: true, + showWhitespace: true, }); diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 6ed8c5709a8..e760b4d1079 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -20,3 +20,5 @@ 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'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 00095997ba3..4aeb393b29b 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -238,4 +238,10 @@ 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; + }, }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 09afacc24df..effb6202327 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 { LINE_POSITION_LEFT, LINE_POSITION_RIGHT, @@ -306,7 +307,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 +322,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, }; }; 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..b70da240833 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -65,8 +65,10 @@ 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 ); } 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/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/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index e4e2eab2acd..cd569eb3045 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -10,6 +10,7 @@ import descriptionComponent from './description.vue'; import editedComponent from './edited.vue'; import formComponent from './form.vue'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; +import { __ } from '~/locale'; export default { components: { @@ -201,8 +202,8 @@ 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; }, 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/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_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/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 1a9723de856..695efe3602f 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,7 @@ import autosave from '../mixins/autosave'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; import discussionNavigation from '../mixins/discussion_navigation'; +import jumpToNextDiscussionButton from './discussion_jump_to_next_button.vue'; export default { name: 'NoteableDiscussion', @@ -34,6 +36,8 @@ export default { noteSignedOutWidget, noteEditedText, noteForm, + resolveDiscussionButton, + jumpToNextDiscussionButton, toggleRepliesWidget, placeholderNote, placeholderSystemNote, @@ -451,16 +455,12 @@ Please check your network connection and try again.`; > 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> + <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" @@ -476,16 +476,10 @@ Please check your network connection and try again.`; <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> + <jump-to-next-discussion-button + v-if="shouldShowJumpToNextDiscussion" + @onClick="jumpToNextDiscussion" + /> </div> </div> </template> diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 65f85314fa0..2105a62cecb 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -415,12 +415,13 @@ 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(); }); }; diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js index 0c585e162cb..8f98be79640 100644 --- a/app/assets/javascripts/pages/explore/projects/index.js +++ b/app/assets/javascripts/pages/explore/projects/index.js @@ -1,3 +1,7 @@ import ProjectsList from '~/projects_list'; +import Star from '../../../star'; -document.addEventListener('DOMContentLoaded', () => new ProjectsList()); +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/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/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/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index dcda701f049..c33665c24f6 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -130,6 +130,6 @@ export default { <template> <div> <div class="flash-container js-suggestions-flash"></div> - <div v-show="isRendered" ref="container" v-html="noteHtml"></div> + <div v-show="isRendered" ref="container" class="note-text md" v-html="noteHtml"></div> </div> </template> diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 5d2cbdde8dc..d164cc56e44 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; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index a499a3a9f95..0fb9bde1785 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -381,6 +381,7 @@ 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; } @@ -408,3 +409,14 @@ 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; +} diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 679148ddf7b..f708a26bb32 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -280,6 +280,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/variables.scss b/app/assets/stylesheets/framework/variables.scss index e886a54dc99..9eae9a831fa 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -278,8 +278,8 @@ $performance-bar-height: 35px; $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; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 4f804866886..02aac58a475 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1029,7 +1029,7 @@ 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); + max-height: calc(100vh - #{$top-pos}); padding-right: $gl-padding; .file-row { @@ -1040,7 +1040,7 @@ .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); + max-height: calc(100vh - #{$performance-bar-top-pos}); } } @@ -1095,12 +1095,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/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/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..2342c284a5e 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; @@ -772,9 +708,6 @@ .project-stats, .project-buttons { - font-size: 0; - text-align: center; - .scrolling-tabs-container { .scrolling-tabs { margin-top: $gl-padding-8; diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 789e0dc736e..07d0bf16d93 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -129,13 +129,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/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/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb index 3802aa5f40f..9484e4d30cd 100644 --- a/app/controllers/dashboard/milestones_controller.rb +++ b/app/controllers/dashboard/milestones_controller.rb @@ -27,7 +27,7 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController 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 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/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..15aadf3f74b 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -2,8 +2,7 @@ class GroupsController < Groups::ApplicationController include API::Helpers::RelatedResourcesHelpers - include IssuesAction - include MergeRequestsAction + include IssuableCollectionsAction include ParamsBackwardCompatibility include PreviewMarkdown 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/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/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index e3e60665506..fd5f3eeaa99 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -191,6 +191,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) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index accc37557b0..bc0a3d3526d 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -230,6 +230,10 @@ 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 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/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 67827b1d3bb..e6d029c356b 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -69,7 +69,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 +157,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/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/helpers/import_helper.rb b/app/helpers/import_helper.rb index 49171df1433..d3befd87ccc 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) 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/models/application_record.rb b/app/models/application_record.rb index 71fbba5b328..29696ab276f 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -2,4 +2,8 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + + def self.id_in(ids) + where(id: ids) + end 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..35cf4f8d277 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -3,6 +3,7 @@ module Ci class Build < CommitStatus prepend ArtifactMigratable + include Ci::Processable include TokenAuthenticatable include AfterCommitQueue include ObjectStorage::BackgroundMove @@ -638,10 +639,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 diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 30a957b4117..acef5d2e643 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -25,6 +25,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' 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/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/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/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/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/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/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..182c5d3d4b0 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' diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7206d858dae..84cb8e1c50b 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 diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 8efaf87ca3a..26cfdc5ef30 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -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/project.rb b/app/models/project.rb index 15465d9b356..da77479fe1f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1585,6 +1585,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 @@ -1689,11 +1696,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) 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/user.rb b/app/models/user.rb index 4ef5bdc2d12..f8ac230852f 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 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/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/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/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb index f102e00d150..28879d2d67f 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 @@ -40,6 +45,10 @@ module ExclusiveLeaseGuard "#{self.class.name} does not implement #{__method__}" end + def lease_release? + true + end + def release_lease(uuid) Gitlab::ExclusiveLease.cancel(lease_key, uuid) 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/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/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/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/clusters/clusters/_integration_form.html.haml b/app/views/clusters/clusters/_form.html.haml index 4c47e11927e..4c47e11927e 100644 --- a/app/views/clusters/clusters/_integration_form.html.haml +++ b/app/views/clusters/clusters/_form.html.haml 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/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/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 88e401081f4..3a8d95f44d1 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.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/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/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/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/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/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 65537cf56de..7694217eb28 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.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,7 +19,7 @@ %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 - if @project.has_extra_topics? @@ -29,7 +29,7 @@ .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 +44,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/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/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/issues/show.html.haml b/app/views/projects/issues/show.html.haml index f048fb91304..653b7d4c6f3 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -15,7 +15,10 @@ .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 + - if @issue.moved? + = _("Closed (moved)") + - 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 diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 56c043b2b3d..4779b5c434e 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -5,23 +5,23 @@ .row .col-md-6 .form-group.row - = f.label :title, "Title", class: "col-form-label col-sm-2" + = f.label :title, _('Title'), class: 'col-form-label col-sm-2' .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" + = f.label :description, _('Description'), class: 'col-form-label col-sm-2' .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/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/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/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/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 20847378495..588659c7e9c 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 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/_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/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/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..85c123c2704 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -45,6 +45,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 +92,15 @@ - 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 - create_gpg_signature -- delete_container_repository - delete_merged_branches - delete_user - email_receiver @@ -129,7 +133,6 @@ - repository_fork - repository_import - repository_remove_remote -- storage_migrator - system_hook_push - update_merge_requests - upload_checksum 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/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/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/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/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 |