diff options
Diffstat (limited to 'app')
64 files changed, 923 insertions, 328 deletions
diff --git a/app/assets/javascripts/blob/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js index 3baf81905fe..47c431fb809 100644 --- a/app/assets/javascripts/blob/blob_fork_suggestion.js +++ b/app/assets/javascripts/blob/blob_fork_suggestion.js @@ -16,47 +16,44 @@ const defaults = { class BlobForkSuggestion { constructor(options) { this.elementMap = Object.assign({}, defaults, options); - this.onClickWrapper = this.onClick.bind(this); - - document.addEventListener('click', this.onClickWrapper); + this.onOpenButtonClick = this.onOpenButtonClick.bind(this); + this.onCancelButtonClick = this.onCancelButtonClick.bind(this); } - showSuggestionSection(forkPath, action = 'edit') { - [].forEach.call(this.elementMap.suggestionSections, (suggestionSection) => { - suggestionSection.classList.remove('hidden'); - }); + init() { + this.bindEvents(); - [].forEach.call(this.elementMap.forkButtons, (forkButton) => { - forkButton.setAttribute('href', forkPath); - }); + return this; + } - [].forEach.call(this.elementMap.actionTextPieces, (actionTextPiece) => { - // eslint-disable-next-line no-param-reassign - actionTextPiece.textContent = action; - }); + bindEvents() { + $(this.elementMap.openButtons).on('click', this.onOpenButtonClick); + $(this.elementMap.cancelButtons).on('click', this.onCancelButtonClick); } - hideSuggestionSection() { - [].forEach.call(this.elementMap.suggestionSections, (suggestionSection) => { - suggestionSection.classList.add('hidden'); - }); + showSuggestionSection(forkPath, action = 'edit') { + $(this.elementMap.suggestionSections).removeClass('hidden'); + $(this.elementMap.forkButtons).attr('href', forkPath); + $(this.elementMap.actionTextPieces).text(action); } - onClick(e) { - const el = e.target; + hideSuggestionSection() { + $(this.elementMap.suggestionSections).addClass('hidden'); + } - if ([].includes.call(this.elementMap.openButtons, el)) { - const { forkPath, action } = el.dataset; - this.showSuggestionSection(forkPath, action); - } + onOpenButtonClick(e) { + const forkPath = $(e.currentTarget).attr('data-fork-path'); + const action = $(e.currentTarget).attr('data-action'); + this.showSuggestionSection(forkPath, action); + } - if ([].includes.call(this.elementMap.cancelButtons, el)) { - this.hideSuggestionSection(); - } + onCancelButtonClick() { + this.hideSuggestionSection(); } destroy() { - document.removeEventListener('click', this.onClickWrapper); + $(this.elementMap.openButtons).off('click', this.onOpenButtonClick); + $(this.elementMap.cancelButtons).off('click', this.onCancelButtonClick); } } diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js index 9b8bfbfc8c0..36fe8a7184f 100644 --- a/app/assets/javascripts/blob/notebook/index.js +++ b/app/assets/javascripts/blob/notebook/index.js @@ -1,10 +1,9 @@ /* eslint-disable no-new */ import Vue from 'vue'; import VueResource from 'vue-resource'; -import NotebookLab from 'vendor/notebooklab'; +import notebookLab from '../../notebook/index.vue'; Vue.use(VueResource); -Vue.use(NotebookLab); export default () => { const el = document.getElementById('js-notebook-viewer'); @@ -19,6 +18,9 @@ export default () => { json: {}, }; }, + components: { + notebookLab, + }, template: ` <div class="container-fluid md prepend-top-default append-bottom-default"> <div diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 20db2698ba8..b20673cd03c 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -97,7 +97,8 @@ const ShortcutsBlob = require('./shortcuts_blob'); cancelButtons: document.querySelectorAll('.js-cancel-fork-suggestion-button'), suggestionSections: document.querySelectorAll('.js-file-fork-suggestion-section'), actionTextPieces: document.querySelectorAll('.js-file-fork-suggestion-section-action'), - }); + }) + .init(); } switch (page) { diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index f7f6a773036..6075157ec31 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -5,6 +5,7 @@ import Cookies from 'js-cookie'; import './breakpoints'; import './flash'; +import BlobForkSuggestion from './blob/blob_fork_suggestion'; /* eslint-disable max-len */ // MergeRequestTabs @@ -266,6 +267,16 @@ import './flash'; new gl.Diff(); this.scrollToElement('#diffs'); + + $('.diff-file').each((i, el) => { + new BlobForkSuggestion({ + openButtons: $(el).find('.js-edit-blob-link-fork-toggler'), + forkButtons: $(el).find('.js-fork-suggestion-button'), + cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'), + suggestionSections: $(el).find('.js-file-fork-suggestion-section'), + actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'), + }); + }); }, }); } diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js index aff507abb91..78bb0e6fb47 100644 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -22,6 +22,7 @@ class PrometheusGraph { const hasMetrics = $prometheusContainer.data('has-metrics'); this.docLink = $prometheusContainer.data('doc-link'); this.integrationLink = $prometheusContainer.data('prometheus-integration'); + this.state = ''; $(document).ajaxError(() => {}); @@ -38,8 +39,9 @@ class PrometheusGraph { this.configureGraph(); this.init(); } else { + const prevState = this.state; this.state = '.js-getting-started'; - this.updateState(); + this.updateState(prevState); } } @@ -53,26 +55,26 @@ class PrometheusGraph { } init() { - this.getData().then((metricsResponse) => { + return this.getData().then((metricsResponse) => { let enoughData = true; - Object.keys(metricsResponse.metrics).forEach((key) => { - let currentKey; - if (key === 'cpu_values' || key === 'memory_values') { - currentKey = metricsResponse.metrics[key]; - if (Object.keys(currentKey).length === 0) { - enoughData = false; - } - } - }); - if (!enoughData) { - this.state = '.js-loading'; - this.updateState(); + if (typeof metricsResponse === 'undefined') { + enoughData = false; } else { + Object.keys(metricsResponse.metrics).forEach((key) => { + if (key === 'cpu_values' || key === 'memory_values') { + const currentData = (metricsResponse.metrics[key])[0]; + if (currentData.values.length <= 2) { + enoughData = false; + } + } + }); + } + if (enoughData) { + $(prometheusStatesContainer).hide(); + $(prometheusParentGraphContainer).show(); this.transformData(metricsResponse); this.createGraph(); } - }).catch(() => { - new Flash('An error occurred when trying to load metrics. Please try again.'); }); } @@ -342,6 +344,8 @@ class PrometheusGraph { getData() { const maxNumberOfRequests = 3; + this.state = '.js-loading'; + this.updateState(); return gl.utils.backOff((next, stop) => { $.ajax({ url: metricsEndpoint, @@ -352,12 +356,11 @@ class PrometheusGraph { this.backOffRequestCounter = this.backOffRequestCounter += 1; if (this.backOffRequestCounter < maxNumberOfRequests) { next(); - } else { - stop({ - status: resp.status, - metrics: data, - }); + } else if (this.backOffRequestCounter >= maxNumberOfRequests) { + stop(new Error('loading')); } + } else if (!data.success) { + stop(new Error('loading')); } else { stop({ status: resp.status, @@ -373,8 +376,9 @@ class PrometheusGraph { return resp.metrics; }) .catch(() => { + const prevState = this.state; this.state = '.js-unable-to-connect'; - this.updateState(); + this.updateState(prevState); }); } @@ -382,19 +386,20 @@ class PrometheusGraph { Object.keys(metricsResponse.metrics).forEach((key) => { if (key === 'cpu_values' || key === 'memory_values') { const metricValues = (metricsResponse.metrics[key])[0]; - if (metricValues !== undefined) { - this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({ - time: new Date(metric[0] * 1000), - value: metric[1], - })); - } + this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({ + time: new Date(metric[0] * 1000), + value: metric[1], + })); } }); } - updateState() { + updateState(prevState) { const $statesContainer = $(prometheusStatesContainer); $(prometheusParentGraphContainer).hide(); + if (prevState) { + $(`${prevState}`, $statesContainer).addClass('hidden'); + } $(`${this.state}`, $statesContainer).removeClass('hidden'); $(prometheusStatesContainer).show(); } diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue new file mode 100644 index 00000000000..b8a16356576 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/code.vue @@ -0,0 +1,58 @@ +<template> + <div class="cell"> + <code-cell + type="input" + :raw-code="rawInputCode" + :count="cell.execution_count" + :code-css-class="codeCssClass" /> + <output-cell + v-if="hasOutput" + :count="cell.execution_count" + :output="output" + :code-css-class="codeCssClass" /> + </div> +</template> + +<script> +import CodeCell from './code/index.vue'; +import OutputCell from './output/index.vue'; + +export default { + components: { + 'code-cell': CodeCell, + 'output-cell': OutputCell, + }, + props: { + cell: { + type: Object, + required: true, + }, + codeCssClass: { + type: String, + required: false, + default: '', + }, + }, + computed: { + rawInputCode() { + if (this.cell.source) { + return this.cell.source.join(''); + } + + return ''; + }, + hasOutput() { + return this.cell.outputs.length; + }, + output() { + return this.cell.outputs[0]; + }, + }, +}; +</script> + +<style scoped> +.cell { + flex-direction: column; +} +</style> diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue new file mode 100644 index 00000000000..31b30f601e2 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -0,0 +1,57 @@ +<template> + <div :class="type"> + <prompt + :type="promptType" + :count="count" /> + <pre + class="language-python" + :class="codeCssClass" + ref="code" + v-text="code"> + </pre> + </div> +</template> + +<script> + import Prism from '../../lib/highlight'; + import Prompt from '../prompt.vue'; + + export default { + components: { + prompt: Prompt, + }, + props: { + count: { + type: Number, + required: false, + default: 0, + }, + codeCssClass: { + type: String, + required: false, + default: '', + }, + type: { + type: String, + required: true, + }, + rawCode: { + type: String, + required: true, + }, + }, + computed: { + code() { + return this.rawCode; + }, + promptType() { + const type = this.type.split('put')[0]; + + return type.charAt(0).toUpperCase() + type.slice(1); + }, + }, + mounted() { + Prism.highlightElement(this.$refs.code); + }, + }; +</script> diff --git a/app/assets/javascripts/notebook/cells/index.js b/app/assets/javascripts/notebook/cells/index.js new file mode 100644 index 00000000000..e4c255609fe --- /dev/null +++ b/app/assets/javascripts/notebook/cells/index.js @@ -0,0 +1,2 @@ +export { default as MarkdownCell } from './markdown.vue'; +export { default as CodeCell } from './code.vue'; diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue new file mode 100644 index 00000000000..3e8240d10ec --- /dev/null +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -0,0 +1,98 @@ +<template> + <div class="cell text-cell"> + <prompt /> + <div class="markdown" v-html="markdown"></div> + </div> +</template> + +<script> + /* global katex */ + import marked from 'marked'; + import Prompt from './prompt.vue'; + + const renderer = new marked.Renderer(); + + /* + Regex to match KaTex blocks. + + Supports the following: + + \begin{equation}<math>\end{equation} + $$<math>$$ + inline $<math>$ + + The matched text then goes through the KaTex renderer & then outputs the HTML + */ + const katexRegexString = `( + ^\\\\begin{[a-zA-Z]+}\\s + | + ^\\$\\$ + | + \\s\\$(?!\\$) + ) + (.+?) + ( + \\s\\\\end{[a-zA-Z]+}$ + | + \\$\\$$ + | + \\$ + ) + `.replace(/\s/g, '').trim(); + + renderer.paragraph = (t) => { + let text = t; + let inline = false; + + if (typeof katex !== 'undefined') { + const katexString = text.replace(/\\/g, '\\'); + const matches = new RegExp(katexRegexString, 'gi').exec(katexString); + + if (matches && matches.length > 0) { + if (matches[1].trim() === '$' && matches[3].trim() === '$') { + inline = true; + + text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`; + } else { + text = katex.renderToString(matches[2]); + } + } + } + + return `<p class="${inline ? 'inline-katex' : ''}">${text}</p>`; + }; + + marked.setOptions({ + sanitize: true, + renderer, + }); + + export default { + components: { + prompt: Prompt, + }, + props: { + cell: { + type: Object, + required: true, + }, + }, + computed: { + markdown() { + return marked(this.cell.source.join('')); + }, + }, + }; +</script> + +<style> +.markdown .katex { + display: block; + text-align: center; +} + +.markdown .inline-katex .katex { + display: inline; + text-align: initial; +} +</style> diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue new file mode 100644 index 00000000000..0f39cd138df --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -0,0 +1,22 @@ +<template> + <div class="output"> + <prompt /> + <div v-html="rawCode"></div> + </div> +</template> + +<script> +import Prompt from '../prompt.vue'; + +export default { + props: { + rawCode: { + type: String, + required: true, + }, + }, + components: { + prompt: Prompt, + }, +}; +</script> diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue new file mode 100644 index 00000000000..f3b873bbc0f --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/image.vue @@ -0,0 +1,27 @@ +<template> + <div class="output"> + <prompt /> + <img + :src="'data:' + outputType + ';base64,' + rawCode" /> + </div> +</template> + +<script> +import Prompt from '../prompt.vue'; + +export default { + props: { + outputType: { + type: String, + required: true, + }, + rawCode: { + type: String, + required: true, + }, + }, + components: { + prompt: Prompt, + }, +}; +</script> diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue new file mode 100644 index 00000000000..23c9ea78939 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -0,0 +1,83 @@ +<template> + <component :is="componentName" + type="output" + :outputType="outputType" + :count="count" + :raw-code="rawCode" + :code-css-class="codeCssClass" /> +</template> + +<script> +import CodeCell from '../code/index.vue'; +import Html from './html.vue'; +import Image from './image.vue'; + +export default { + props: { + codeCssClass: { + type: String, + required: false, + default: '', + }, + count: { + type: Number, + required: false, + default: 0, + }, + output: { + type: Object, + requred: true, + }, + }, + components: { + 'code-cell': CodeCell, + 'html-output': Html, + 'image-output': Image, + }, + data() { + return { + outputType: '', + }; + }, + computed: { + componentName() { + if (this.output.text) { + return 'code-cell'; + } else if (this.output.data['image/png']) { + this.outputType = 'image/png'; + + return 'image-output'; + } else if (this.output.data['text/html']) { + this.outputType = 'text/html'; + + return 'html-output'; + } else if (this.output.data['image/svg+xml']) { + this.outputType = 'image/svg+xml'; + + return 'html-output'; + } + + this.outputType = 'text/plain'; + return 'code-cell'; + }, + rawCode() { + if (this.output.text) { + return this.output.text.join(''); + } + + return this.dataForType(this.outputType); + }, + }, + methods: { + dataForType(type) { + let data = this.output.data[type]; + + if (typeof data === 'object') { + data = data.join(''); + } + + return data; + }, + }, +}; +</script> diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue new file mode 100644 index 00000000000..4540e4248d8 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/prompt.vue @@ -0,0 +1,30 @@ +<template> + <div class="prompt"> + <span v-if="type && count"> + {{ type }} [{{ count }}]: + </span> + </div> +</template> + +<script> + export default { + props: { + type: { + type: String, + required: false, + }, + count: { + type: Number, + required: false, + }, + }, + }; +</script> + +<style scoped> +.prompt { + padding: 0 10px; + min-width: 7em; + font-family: monospace; +} +</style> diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue new file mode 100644 index 00000000000..fd62c1231ef --- /dev/null +++ b/app/assets/javascripts/notebook/index.vue @@ -0,0 +1,75 @@ +<template> + <div v-if="hasNotebook"> + <component + v-for="(cell, index) in cells" + :is="cellType(cell.cell_type)" + :cell="cell" + :key="index" + :code-css-class="codeCssClass" /> + </div> +</template> + +<script> + import { + MarkdownCell, + CodeCell, + } from './cells'; + + export default { + components: { + 'code-cell': CodeCell, + 'markdown-cell': MarkdownCell, + }, + props: { + notebook: { + type: Object, + required: true, + }, + codeCssClass: { + type: String, + required: false, + default: '', + }, + }, + methods: { + cellType(type) { + return `${type}-cell`; + }, + }, + computed: { + cells() { + if (this.notebook.worksheets) { + const data = { + cells: [], + }; + + return this.notebook.worksheets.reduce((cellData, sheet) => { + const cellDataCopy = cellData; + cellDataCopy.cells = cellDataCopy.cells.concat(sheet.cells); + return cellDataCopy; + }, data).cells; + } + + return this.notebook.cells; + }, + hasNotebook() { + return Object.keys(this.notebook).length; + }, + }, + }; +</script> + +<style> +.cell, +.input, +.output { + display: flex; + width: 100%; + margin-bottom: 10px; +} + +.cell pre { + margin: 0; + width: 100%; +} +</style> diff --git a/app/assets/javascripts/notebook/lib/highlight.js b/app/assets/javascripts/notebook/lib/highlight.js new file mode 100644 index 00000000000..74ade6d2edf --- /dev/null +++ b/app/assets/javascripts/notebook/lib/highlight.js @@ -0,0 +1,22 @@ +import Prism from 'prismjs'; +import 'prismjs/components/prism-python'; +import 'prismjs/plugins/custom-class/prism-custom-class'; + +Prism.plugins.customClass.map({ + comment: 'c', + error: 'err', + operator: 'o', + constant: 'kc', + namespace: 'kn', + keyword: 'k', + string: 's', + number: 'm', + 'attr-name': 'na', + builtin: 'nb', + entity: 'ni', + function: 'nf', + tag: 'nt', + variable: 'nv', +}); + +export default Prism; diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index 5b6bb2bf3f5..85659d7fa39 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -57,8 +57,11 @@ import findAndFollowLink from './shortcuts_dashboard_navigation'; Shortcuts.prototype.toggleMarkdownPreview = function(e) { // Check if short-cut was triggered while in Write Mode - if ($(e.target).hasClass('js-note-text')) { - $('.js-md-preview-button').focus(); + const $target = $(e.target); + const $form = $target.closest('form'); + + if ($target.hasClass('js-note-text')) { + $('.js-md-preview-button', $form).focus(); } return $(document).triggerHandler('markdown-preview:toggle', [e]); }; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index f614f262316..b2102d2fbc5 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -108,8 +108,7 @@ } .award-control { - margin: 3px 5px 3px 0; - padding: .35em .4em; + margin-right: 5px; outline: 0; &.disabled { diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 52425262925..f3e2a5db0a6 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -230,7 +230,6 @@ float: right; margin-top: 8px; padding-bottom: 8px; - border-bottom: 1px solid $border-color; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 0fd7203e72b..638c1403b25 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -70,7 +70,7 @@ pre { } hr { - margin: $gl-padding 0; + margin: 24px 0; border-top: 1px solid darken($gray-normal, 8%); } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index a5a8522739e..df819ffe4bc 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -73,14 +73,6 @@ &.wiki { padding: 30px $gl-padding; - - .highlight { - margin-bottom: 9px; - - > pre { - margin: 0; - } - } } &.blob-no-preview { diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index e6d808717f3..b6cf5101d60 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -110,7 +110,7 @@ .top-area { @include clearfix; - border-bottom: 1px solid $white-normal; + border-bottom: 1px solid $border-color; .nav-text { padding-top: 16px; diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index cd23deb6d75..d2164a1d333 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -4,7 +4,7 @@ padding: 0; .timeline-entry { - padding: $gl-padding $gl-btn-padding 14px; + padding: $gl-padding $gl-btn-padding 0; border-color: $white-normal; color: $gl-text-color; border-bottom: 1px solid $border-white-light; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 664539e93e1..96d8a812723 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -8,6 +8,13 @@ img { max-width: 100%; + margin: 0 0 8px; + } + + p a:not(.no-attachment-icon) img { + // Remove bottom padding because + // <p> already has $gl-padding bottom + margin-bottom: 0; } *:first-child:not(.katex-display) { @@ -47,44 +54,50 @@ h1 { font-size: 1.75em; font-weight: 600; - margin: 16px 0 10px; - padding: 0 0 0.3em; + margin: 24px 0 16px; + padding-bottom: 0.3em; border-bottom: 1px solid $white-dark; color: $gl-text-color; + + &:first-child { + margin-top: 0; + } } h2 { font-size: 1.5em; font-weight: 600; - margin: 16px 0 10px; + margin: 24px 0 16px; + padding-bottom: 0.3em; + border-bottom: 1px solid $white-dark; color: $gl-text-color; } h3 { - margin: 16px 0 10px; + margin: 24px 0 16px; font-size: 1.3em; } h4 { - margin: 16px 0 10px; + margin: 24px 0 16px; font-size: 1.2em; } h5 { - margin: 16px 0 10px; + margin: 24px 0 16px; font-size: 1em; } h6 { - margin: 16px 0 10px; + margin: 24px 0 16px; font-size: 0.95em; } blockquote { color: $gl-grayish-blue; font-size: inherit; - padding: 8px 21px; - margin: 12px 0; + padding: 8px 24px; + margin: 16px 0; border-left: 3px solid $white-dark; } @@ -95,19 +108,20 @@ blockquote p { color: $gl-grayish-blue !important; + margin: 0; font-size: inherit; line-height: 1.5; } p { color: $gl-text-color; - margin: 6px 0 0; + margin: 0 0 16px; } table { @extend .table; @extend .table-bordered; - margin: 12px 0; + margin: 16px 0; color: $gl-text-color; th { @@ -120,7 +134,7 @@ } pre { - margin: 12px 0; + margin-bottom: 16px; font-size: 13px; line-height: 1.6em; overflow-x: auto; @@ -134,7 +148,7 @@ ul, ol { padding: 0; - margin: 3px 0 !important; + margin: 0 0 16px !important; } ul:dir(rtl), @@ -338,3 +352,32 @@ h4 { .idiff.addition { background: $line-added-dark; } + + +/** + * form text input i.e. search bar, comments, forms, etc. + */ +input, +textarea { + &::-webkit-input-placeholder { + color: $placeholder-text-color; + } + + // support firefox 19+ vendor prefix + &::-moz-placeholder { + color: $placeholder-text-color; + opacity: 1; // FF defaults to 0.54 + } + + // scss-lint:disable PseudoElement + // support Edge vendor prefix + &::-ms-input-placeholder { + color: $placeholder-text-color; + } + + // scss-lint:disable PseudoElement + // support IE vendor prefix + &:-ms-input-placeholder { + color: $placeholder-text-color; + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index ad592a45941..49741c963df 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -111,6 +111,7 @@ $gl-gray: $gl-text-color; $gl-gray-dark: #313236; $gl-header-color: #4c4e54; $gl-header-nav-hover-color: #434343; +$placeholder-text-color: rgba(0, 0, 0, .42); /* * Lists diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index 46fd19c93f9..f3de05aa5f6 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -29,11 +29,5 @@ .description { margin-top: 6px; - - p { - &:last-child { - margin-bottom: 0; - } - } } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 8d3d1a72b9b..97fab513b01 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -52,7 +52,7 @@ .title { padding: 0; - margin: 0; + margin-bottom: 16px; border-bottom: none; } @@ -357,6 +357,8 @@ } .detail-page-description { + padding: 16px 0 0; + small { color: $gray-darkest; } @@ -364,6 +366,8 @@ .edited-text { color: $gray-darkest; + display: block; + margin: 0 0 16px; .author_link { color: $gray-darkest; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index b637994adf8..62f654ed343 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -28,7 +28,7 @@ .note-edit-form { .note-form-actions { position: relative; - margin-top: $gl-padding; + margin: $gl-padding 0; } .note-preview-holder { @@ -387,6 +387,7 @@ @media (max-width: $screen-xs-max) { display: flex; width: 100%; + margin-bottom: 10px; .comment-btn { flex-grow: 1; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 2ea2ff8362b..69a95db6920 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -102,13 +102,12 @@ ul.notes { .note-awards { .js-awards-block { - padding: 2px; - margin-top: 10px; + margin-bottom: 16px; } } .note-header { - padding-bottom: 3px; + padding-bottom: 8px; padding-right: 20px; @media (min-width: $screen-sm-min) { @@ -151,6 +150,10 @@ ul.notes { margin-left: 65px; } + .note-header { + padding-bottom: 0; + } + &.timeline-entry::after { clear: none; } @@ -386,6 +389,10 @@ ul.notes { .note-headline-meta { display: inline-block; white-space: nowrap; + + .system-note-message { + white-space: normal; + } } /** diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index fc8d4d02ddf..5885b3543bb 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -28,7 +28,7 @@ class Admin::GroupsController < Admin::ApplicationController if @group.save @group.add_owner(current_user) - redirect_to [:admin, @group], notice: 'Group was successfully created.' + redirect_to [:admin, @group], notice: "Group '#{@group.name}' was successfully created." else render "new" end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 408c0c60cb0..d0dd524c484 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -23,6 +23,7 @@ class Projects::MilestonesController < Projects::ApplicationController respond_to do |format| format.html do + @project_namespace = @project.namespace.becomes(Namespace) @milestones = @milestones.includes(:project) @milestones = @milestones.page(params[:page]) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 3736e1ffcbb..36b16421e8f 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -29,7 +29,7 @@ module BlobHelper link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" elsif current_user && can?(current_user, :fork_project, project) continue_params = { - to: edit_path, + to: edit_path(project, ref, path, options), notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 2de9e0de310..32b1e7822af 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -1,10 +1,16 @@ +## +# DEPRECATED +# +# These helpers are deprecated in favor of detailed CI/CD statuses. +# +# See 'detailed_status?` method and `Gitlab::Ci::Status` module. +# module CiStatusHelper def ci_status_path(pipeline) project = pipeline.project namespace_project_pipeline_path(project.namespace, project, pipeline) end - # Is used by Commit and Merge Request Widget def ci_label_for_status(status) if detailed_status?(status) return status.label @@ -22,6 +28,23 @@ module CiStatusHelper end end + def ci_text_for_status(status) + if detailed_status?(status) + return status.text + end + + case status + when 'success' + 'passed' + when 'success_with_warnings' + 'passed' + when 'manual' + 'blocked' + else + status + end + end + def ci_status_for_statuseable(subject) status = subject.try(:status) || 'not found' status.humanize diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index ab3ef454e1c..55fa81e95ef 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -7,6 +7,11 @@ module IconsHelper # font-awesome-rails gem, but should we ever use a different icon pack in the # future we won't have to change hundreds of method calls. def icon(names, options = {}) + if (options.keys & %w[aria-hidden aria-label]).empty? + # Add `aria-hidden` if there are no aria's set + options['aria-hidden'] = true + end + options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) end diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index fb95f2b565e..a762b320d56 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -5,7 +5,7 @@ module SubmoduleHelper def submodule_links(submodule_item, ref = nil, repository = @repository) url = repository.submodule_url_for(ref, submodule_item.path) - return url, nil unless url =~ /([^\/:]+)\/([^\/]+\.git)\Z/ + return url, nil unless url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/ namespace = $1 project = $2 @@ -37,14 +37,16 @@ module SubmoduleHelper end def self_url?(url, namespace, project) - return true if url == [Gitlab.config.gitlab.url, '/', namespace, '/', - project, '.git'].join('') - url == gitlab_shell.url_to_repo([namespace, '/', project].join('')) + url_no_dotgit = url.chomp('.git') + return true if url_no_dotgit == [Gitlab.config.gitlab.url, '/', namespace, '/', + project].join('') + url_with_dotgit = url_no_dotgit + '.git' + url_with_dotgit == gitlab_shell.url_to_repo([namespace, '/', project].join('')) end def relative_self_url?(url) # (./)?(../repo.git) || (./)?(../../project/repo.git) ) - url =~ /\A((\.\/)?(\.\.\/))(?!(\.\.)|(.*\/)).*\.git\z/ || url =~ /\A((\.\/)?(\.\.\/){2})(?!(\.\.))([^\/]*)\/(?!(\.\.)|(.*\/)).*\.git\z/ + url =~ /\A((\.\/)?(\.\.\/))(?!(\.\.)|(.*\/)).*(\.git)?\z/ || url =~ /\A((\.\/)?(\.\.\/){2})(?!(\.\.))([^\/]*)\/(?!(\.\.)|(.*\/)).*(\.git)?\z/ end def standard_links(host, namespace, project, commit) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 445247f1b41..4be4aa9ffe2 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -75,29 +75,32 @@ module Ci pipeline.update_duration end + before_transition any => [:manual] do |pipeline| + pipeline.update_duration + end + before_transition canceled: any - [:canceled] do |pipeline| pipeline.auto_canceled_by = nil end after_transition [:created, :pending] => :running do |pipeline| - pipeline.run_after_commit { PipelineMetricsWorker.perform_async(id) } + pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end after_transition any => [:success] do |pipeline| - pipeline.run_after_commit { PipelineMetricsWorker.perform_async(id) } + pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end after_transition [:created, :pending, :running] => :success do |pipeline| - pipeline.run_after_commit { PipelineSuccessWorker.perform_async(id) } + pipeline.run_after_commit { PipelineSuccessWorker.perform_async(pipeline.id) } end after_transition do |pipeline, transition| next if transition.loopback? pipeline.run_after_commit do - PipelineHooksWorker.perform_async(id) - Ci::ExpirePipelineCacheService.new(project, nil) - .execute(pipeline) + PipelineHooksWorker.perform_async(pipeline.id) + ExpirePipelineCacheWorker.perform_async(pipeline.id) end end @@ -385,6 +388,11 @@ module Ci .select { |merge_request| merge_request.head_pipeline.try(:id) == self.id } end + # All the merge requests for which the current pipeline runs/ran against + def all_merge_requests + @all_merge_requests ||= project.merge_requests.where(source_branch: ref) + end + def detailed_status(current_user) Gitlab::Ci::Status::Pipeline::Factory .new(self, current_user) diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 8ea95beed79..2eedc143968 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -8,6 +8,14 @@ # # Corresponding foo_html, bar_html and baz_html fields should exist. module CacheMarkdownField + extend ActiveSupport::Concern + + # Increment this number every time the renderer changes its output + CACHE_VERSION = 1 + + # changes to these attributes cause the cache to be invalidates + INVALIDATED_BY = %w[author project].freeze + # Knows about the relationship between markdown and html field names, and # stores the rendering contexts for the latter class FieldData @@ -30,60 +38,71 @@ module CacheMarkdownField end end - # Dynamic registries don't really work in Rails as it's not guaranteed that - # every class will be loaded, so hardcode the list. - CACHING_CLASSES = %w[ - AbuseReport - Appearance - ApplicationSetting - BroadcastMessage - Issue - Label - MergeRequest - Milestone - Namespace - Note - Project - Release - Snippet - ].freeze - - def self.caching_classes - CACHING_CLASSES.map(&:constantize) - end - def skip_project_check? false end - extend ActiveSupport::Concern + # Returns the default Banzai render context for the cached markdown field. + def banzai_render_context(field) + raise ArgumentError.new("Unknown field: #{field.inspect}") unless + cached_markdown_fields.markdown_fields.include?(field) - included do - cattr_reader :cached_markdown_fields do - FieldData.new - end + # Always include a project key, or Banzai complains + project = self.project if self.respond_to?(:project) + context = cached_markdown_fields[field].merge(project: project) - # Returns the default Banzai render context for the cached markdown field. - def banzai_render_context(field) - raise ArgumentError.new("Unknown field: #{field.inspect}") unless - cached_markdown_fields.markdown_fields.include?(field) + # Banzai is less strict about authors, so don't always have an author key + context[:author] = self.author if self.respond_to?(:author) - # Always include a project key, or Banzai complains - project = self.project if self.respond_to?(:project) - context = cached_markdown_fields[field].merge(project: project) + context + end - # Banzai is less strict about authors, so don't always have an author key - context[:author] = self.author if self.respond_to?(:author) + # Update every column in a row if any one is invalidated, as we only store + # one version per row + def refresh_markdown_cache!(do_update: false) + options = { skip_project_check: skip_project_check? } - context - end + updates = cached_markdown_fields.markdown_fields.map do |markdown_field| + [ + cached_markdown_fields.html_field(markdown_field), + Banzai::Renderer.cacheless_render_field(self, markdown_field, options) + ] + end.to_h + updates['cached_markdown_version'] = CacheMarkdownField::CACHE_VERSION - # Allow callers to look up the cache field name, rather than hardcoding it - def markdown_cache_field_for(field) - raise ArgumentError.new("Unknown field: #{field}") unless - cached_markdown_fields.markdown_fields.include?(field) + updates.each {|html_field, data| write_attribute(html_field, data) } - cached_markdown_fields.html_field(field) + update_columns(updates) if persisted? && do_update + end + + def cached_html_up_to_date?(markdown_field) + html_field = cached_markdown_fields.html_field(markdown_field) + + markdown_changed = attribute_changed?(markdown_field) || false + html_changed = attribute_changed?(html_field) || false + + CacheMarkdownField::CACHE_VERSION == cached_markdown_version && + (html_changed || markdown_changed == html_changed) + end + + def invalidated_markdown_cache? + cached_markdown_fields.html_fields.any? {|html_field| attribute_invalidated?(html_field) } + end + + def attribute_invalidated?(attr) + __send__("#{attr}_invalidated?") + end + + def cached_html_for(markdown_field) + raise ArgumentError.new("Unknown field: #{field}") unless + cached_markdown_fields.markdown_fields.include?(markdown_field) + + __send__(cached_markdown_fields.html_field(markdown_field)) + end + + included do + cattr_reader :cached_markdown_fields do + FieldData.new end # Always exclude _html fields from attributes (including serialization). @@ -92,12 +111,16 @@ module CacheMarkdownField def attributes attrs = attributes_before_markdown_cache + attrs.delete('cached_markdown_version') + cached_markdown_fields.html_fields.each do |field| attrs.delete(field) end attrs end + + before_save :refresh_markdown_cache!, if: :invalidated_markdown_cache? end class_methods do @@ -107,31 +130,18 @@ module CacheMarkdownField # a corresponding _html field. Any custom rendering options may be provided # as a context. def cache_markdown_field(markdown_field, context = {}) - raise "Add #{self} to CacheMarkdownField::CACHING_CLASSES" unless - CacheMarkdownField::CACHING_CLASSES.include?(self.to_s) - cached_markdown_fields[markdown_field] = context html_field = cached_markdown_fields.html_field(markdown_field) - cache_method = "#{markdown_field}_cache_refresh".to_sym invalidation_method = "#{html_field}_invalidated?".to_sym - define_method(cache_method) do - options = { skip_project_check: skip_project_check? } - html = Banzai::Renderer.cacheless_render_field(self, markdown_field, options) - __send__("#{html_field}=", html) - true - end - # The HTML becomes invalid if any dependent fields change. For now, assume # author and project invalidate the cache in all circumstances. define_method(invalidation_method) do changed_fields = changed_attributes.keys - invalidations = changed_fields & [markdown_field.to_s, "author", "project"] - !invalidations.empty? + invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY] + !invalidations.empty? || !cached_html_up_to_date?(markdown_field) end - - before_save cache_method, if: invalidation_method end end end diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index aca99feee53..b28e05d0c28 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -163,7 +163,20 @@ module Routable end end + # Every time `project.namespace.becomes(Namespace)` is called for polymorphic_path, + # a new instance is instantiated, and we end up duplicating the same query to retrieve + # the route. Caching this per request ensures that even if we have multiple instances, + # we will not have to duplicate work, avoiding N+1 queries in some cases. def full_path + return uncached_full_path unless RequestStore.active? + + key = "routable/full_path/#{self.class.name}/#{self.id}" + RequestStore[key] ||= uncached_full_path + end + + private + + def uncached_full_path if route && route.path.present? @full_path ||= route.path else @@ -173,8 +186,6 @@ module Routable end end - private - def full_name_changed? name_changed? || parent_changed? end diff --git a/app/models/group.rb b/app/models/group.rb index 106084175ff..cbc10b00cf5 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -125,7 +125,7 @@ class Group < Namespace end def add_users(users, access_level, current_user: nil, expires_at: nil) - GroupMember.add_users_to_group( + GroupMember.add_users( self, users, access_level, diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb index c3f21c55240..6be8ca45739 100644 --- a/app/models/individual_note_discussion.rb +++ b/app/models/individual_note_discussion.rb @@ -10,4 +10,8 @@ class IndividualNoteDiscussion < Discussion def individual_note? true end + + def reply_attributes + super.tap { |attrs| attrs.delete(:discussion_id) } + end end diff --git a/app/models/member.rb b/app/models/member.rb index 0545bd4eedf..97fba501759 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -151,6 +151,22 @@ class Member < ActiveRecord::Base member end + def add_users(source, users, access_level, current_user: nil, expires_at: nil) + return [] unless users.present? + + self.transaction do + users.map do |user| + add_user( + source, + user, + access_level, + current_user: current_user, + expires_at: expires_at + ) + end + end + end + def access_levels Gitlab::Access.sym_options end @@ -173,18 +189,6 @@ class Member < ActiveRecord::Base # There is no current user for bulk actions, in which case anything is allowed !current_user || current_user.can?(:"update_#{member.type.underscore}", member) end - - def add_users_to_source(source, users, access_level, current_user: nil, expires_at: nil) - users.each do |user| - add_user( - source, - user, - access_level, - current_user: current_user, - expires_at: expires_at - ) - end - end end def real_source_type diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 483425cd30f..28e10bc6172 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -21,18 +21,6 @@ class GroupMember < Member Gitlab::Access.sym_options_with_owner end - def self.add_users_to_group(group, users, access_level, current_user: nil, expires_at: nil) - self.transaction do - add_users_to_source( - group, - users, - access_level, - current_user: current_user, - expires_at: expires_at - ) - end - end - def group source end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 912820b51ac..b3a91feb091 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -16,7 +16,7 @@ class ProjectMember < Member before_destroy :delete_member_todos class << self - # Add users to project teams with passed access option + # Add users to projects with passed access option # # access can be an integer representing a access code # or symbol like :master representing role @@ -39,7 +39,7 @@ class ProjectMember < Member project_ids.each do |project_id| project = Project.find(project_id) - add_users_to_source( + add_users( project, users, access_level, diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 1d4827375d7..9d2288c311e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -191,22 +191,23 @@ class MergeRequest < ActiveRecord::Base merge_request_diff ? merge_request_diff.raw_diffs(*args) : compare.raw_diffs(*args) end - def diffs(diff_options = nil) + def diffs(diff_options = {}) if compare - compare.diffs(diff_options) + # When saving MR diffs, `no_collapse` is implicitly added (because we need + # to save the entire contents to the DB), so add that here for + # consistency. + compare.diffs(diff_options.merge(no_collapse: true)) else merge_request_diff.diffs(diff_options) end end def diff_size - # The `#diffs` method ends up at an instance of a class inheriting from - # `Gitlab::Diff::FileCollection::Base`, so use those options as defaults - # here too, to get the same diff size without performing highlighting. - # - opts = Gitlab::Diff::FileCollection::Base.default_options.merge(diff_options || {}) + # Calling `merge_request_diff.diffs.real_size` will also perform + # highlighting, which we don't need here. + return real_size if merge_request_diff - raw_diffs(opts).size + diffs.real_size end def diff_base_commit diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 6604af2b47e..f0a3c30ea74 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -260,7 +260,7 @@ class MergeRequestDiff < ActiveRecord::Base new_attributes[:state] = :empty else diff_collection = compare.diffs(Commit.max_diff_options) - new_attributes[:real_size] = compare.diffs.real_size + new_attributes[:real_size] = diff_collection.real_size if diff_collection.any? new_diffs = dump_diffs(diff_collection) diff --git a/app/models/out_of_context_discussion.rb b/app/models/out_of_context_discussion.rb index 85794630f70..4227c40b69a 100644 --- a/app/models/out_of_context_discussion.rb +++ b/app/models/out_of_context_discussion.rb @@ -15,8 +15,12 @@ class OutOfContextDiscussion < Discussion def self.override_discussion_id(note) discussion_id(note) end - + def self.note_class Note end + + def reply_attributes + super.tap { |attrs| attrs.delete(:discussion_id) } + end end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 6d6644053f8..543b9b293e0 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -50,8 +50,8 @@ class ProjectTeam end def add_users(users, access_level, current_user: nil, expires_at: nil) - ProjectMember.add_users_to_projects( - [project.id], + ProjectMember.add_users( + project, users, access_level, current_user: current_user, diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb deleted file mode 100644 index 91d9c1d2ba1..00000000000 --- a/app/services/ci/expire_pipeline_cache_service.rb +++ /dev/null @@ -1,51 +0,0 @@ -module Ci - class ExpirePipelineCacheService < BaseService - attr_reader :pipeline - - def execute(pipeline) - @pipeline = pipeline - store = Gitlab::EtagCaching::Store.new - - store.touch(project_pipelines_path) - store.touch(commit_pipelines_path) if pipeline.commit - store.touch(new_merge_request_pipelines_path) - merge_requests_pipelines_paths.each { |path| store.touch(path) } - - Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(@pipeline) - end - - private - - def project_pipelines_path - Gitlab::Routing.url_helpers.namespace_project_pipelines_path( - project.namespace, - project, - format: :json) - end - - def commit_pipelines_path - Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path( - project.namespace, - project, - pipeline.commit.id, - format: :json) - end - - def new_merge_request_pipelines_path - Gitlab::Routing.url_helpers.new_namespace_project_merge_request_path( - project.namespace, - project, - format: :json) - end - - def merge_requests_pipelines_paths - pipeline.merge_requests.collect do |merge_request| - Gitlab::Routing.url_helpers.pipelines_namespace_project_merge_request_path( - project.namespace, - project, - merge_request, - format: :json) - end - end - end -end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 9a0a5a12f91..d2a1c161026 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -6,8 +6,8 @@ module Users @params = params.dup end - def execute - raise Gitlab::Access::AccessDeniedError unless can_create_user? + def execute(skip_authorization: false) + raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_create_user? user = User.new(build_user_params) diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb index a2105d31f71..e22f7225ae2 100644 --- a/app/services/users/create_service.rb +++ b/app/services/users/create_service.rb @@ -6,8 +6,8 @@ module Users @params = params.dup end - def execute - user = Users::BuildService.new(current_user, params).execute + def execute(skip_authorization: false) + user = Users::BuildService.new(current_user, params).execute(skip_authorization: skip_authorization) @reset_token = user.generate_reset_token if user.recently_sent_password_reset? diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb index 1e1ed1791ec..4628c4c6f6e 100644 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -15,27 +15,39 @@ module Users end def execute - # Block the user before moving records to prevent a data race. - # For example, if the user creates an issue after `migrate_issues` - # runs and before the user is destroyed, the destroy will fail with - # an exception. - user.block + transition = user.block_transition user.transaction do + # Block the user before moving records to prevent a data race. + # For example, if the user creates an issue after `migrate_issues` + # runs and before the user is destroyed, the destroy will fail with + # an exception. + user.block + + # Reverse the user block if record migration fails + if !migrate_records && transition + transition.rollback + user.save! + end + end + + user.reload + end + + private + + def migrate_records + user.transaction(requires_new: true) do @ghost_user = User.ghost migrate_issues migrate_merge_requests migrate_notes migrate_abuse_reports - migrate_award_emoji + migrate_award_emojis end - - user.reload end - private - def migrate_issues user.issues.update_all(author_id: ghost_user.id) end @@ -52,7 +64,7 @@ module Users user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) end - def migrate_award_emoji + def migrate_award_emojis user.award_emoji.update_all(user_id: ghost_user.id) end end diff --git a/app/views/admin/cohorts/index.html.haml b/app/views/admin/cohorts/index.html.haml index 46fe12a5a99..be8644c0ca6 100644 --- a/app/views/admin/cohorts/index.html.haml +++ b/app/views/admin/cohorts/index.html.haml @@ -9,7 +9,7 @@ .bs-callout.bs-callout-warning.clearfix %p User cohorts are only shown when the - = link_to 'usage ping', help_page_path('user/admin_area/usage_statistics'), target: '_blank' + = link_to 'usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping'), target: '_blank' is enabled. To enable it and see user cohorts, visit = succeed '.' do diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 37429c7cfc0..8ab9747efc5 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -56,7 +56,7 @@ Snippets - if project_nav_tab? :settings - = nav_link(path: %w[projects#edit members#show integrations#show repository#show ci_cd#show pages#show]) do + = nav_link(path: %w[projects#edit members#show integrations#show services#edit repository#show ci_cd#show pages#show]) do = link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do %span Settings diff --git a/app/views/projects/_fork_suggestion.html.haml b/app/views/projects/_fork_suggestion.html.haml new file mode 100644 index 00000000000..c855bfaf067 --- /dev/null +++ b/app/views/projects/_fork_suggestion.html.haml @@ -0,0 +1,11 @@ +- if current_user + .js-file-fork-suggestion-section.file-fork-suggestion.hidden + %span.file-fork-suggestion-note + You're not allowed to + %span.js-file-fork-suggestion-section-action + edit + files in this project directly. Please fork this project, + make your changes there, and submit a merge request. + = link_to 'Fork', nil, method: :post, class: 'js-fork-suggestion-button btn btn-grouped btn-inverted btn-new' + %button.js-cancel-fork-suggestion-button.btn.btn-grouped{ type: 'button' } + Cancel diff --git a/app/views/projects/_last_commit.html.haml b/app/views/projects/_last_commit.html.haml index e1fea8ccf3d..df3b1c75508 100644 --- a/app/views/projects/_last_commit.html.haml +++ b/app/views/projects/_last_commit.html.haml @@ -1,10 +1,9 @@ - - ref = local_assigns.fetch(:ref) - status = commit.status(ref) - if status = link_to pipelines_namespace_project_commit_path(commit.project.namespace, commit.project, commit), class: "ci-status ci-#{status}" do = ci_icon_for_status(status) - = ci_label_for_status(status) + = ci_text_for_status(status) = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message" diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index d46e4534497..c553db84ee0 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -39,14 +39,4 @@ = replace_blob_link = delete_blob_link -- if current_user - .js-file-fork-suggestion-section.file-fork-suggestion.hidden - %span.file-fork-suggestion-note - You're not allowed to - %span.js-file-fork-suggestion-section-action - edit - files in this project directly. Please fork this project, - make your changes there, and submit a merge request. - = link_to 'Fork', nil, method: :post, class: 'js-fork-suggestion-button btn btn-grouped btn-inverted btn-new' - %button.js-cancel-fork-suggestion-button.btn.btn-grouped{ type: 'button' } - Cancel += render 'projects/fork_suggestion' diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 0232a09b4a8..4622b980754 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -18,4 +18,6 @@ = view_file_button(diff_commit.id, diff_file.new_path, project) = view_on_environment_button(diff_commit.id, diff_file.new_path, environment) if environment + = render 'projects/fork_suggestion' + = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index bf98efbdfdf..3971ea44ef3 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -50,7 +50,7 @@ = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' .issue-details.issuable-details - .detail-page-description.content-block{ class: ('hide-bottom-border' unless @issue.description.present? ) } + .detail-page-description.content-block .issue-title-data.hidden{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), "canDescription" => can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '', } } diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index e8c9d7f8429..33bbbd9a3f8 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -36,7 +36,7 @@ %a.btn.btn-default.btn-grouped.pull-right.visible-xs-block.js-sidebar-toggle{ href: "#" } = icon('angle-double-left') - .detail-page-description.milestone-detail{ class: ('hide-bottom-border' unless @milestone.description.present? ) } + .detail-page-description.milestone-detail %h2.title = markdown_field(@milestone, :title) %div diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 0c7b53e5a9a..9e292729425 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -145,7 +145,8 @@ } }); + $('#project_import_url').disable(); $('.import_git').click(function( event ) { - $projectImportUrl = $('#project_import_url') - $projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled')) + $projectImportUrl = $('#project_import_url'); + $projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled')); }); diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml index 50ed78286d2..0f1a76a104a 100644 --- a/app/views/projects/services/edit.html.haml +++ b/app/views/projects/services/edit.html.haml @@ -1,2 +1,3 @@ - page_title @service.title, "Services" += render "projects/settings/head" = render 'form' diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml index 88bcb541dac..e50a543ffa8 100644 --- a/app/views/projects/settings/_head.html.haml +++ b/app/views/projects/settings/_head.html.haml @@ -14,7 +14,7 @@ %span Members - if can_edit - = nav_link(controller: :integrations) do + = nav_link(controller: [:integrations, :services]) do = link_to project_settings_integrations_path(@project), title: 'Integrations' do %span Integrations diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index 4c7d69d40d5..5247d6a51e6 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -1,11 +1,14 @@ -# @project is present when viewing Project's milestone - project = @project || issuable.project +- namespace = @project_namespace || project.namespace.becomes(Namespace) - assignee = issuable.assignee - issuable_type = issuable.class.table_name -- base_url_args = [project.namespace.becomes(Namespace), project, issuable_type] +- base_url_args = [namespace, project] +- issuable_type_args = base_url_args + [issuable_type] +- issuable_url_args = base_url_args + [issuable] - can_update = can?(current_user, :"update_#{issuable.to_ability_name}", issuable) -%li{ id: dom_id(issuable, 'sortable'), class: "issuable-row #{'is-disabled' unless can_update}", 'data-iid' => issuable.iid, 'data-id' => issuable.id, 'data-url' => polymorphic_path(issuable) } +%li{ id: dom_id(issuable, 'sortable'), class: "issuable-row #{'is-disabled' unless can_update}", 'data-iid' => issuable.iid, 'data-id' => issuable.id, 'data-url' => polymorphic_path(issuable_url_args) } %span - if show_project_name %strong #{project.name} · @@ -13,17 +16,17 @@ %strong #{project.name_with_namespace} · - if issuable.is_a?(Issue) = confidential_icon(issuable) - = link_to_gfm issuable.title, [project.namespace.becomes(Namespace), project, issuable], title: issuable.title + = link_to_gfm issuable.title, issuable_url_args, title: issuable.title .issuable-detail = link_to [project.namespace.becomes(Namespace), project, issuable] do %span.issuable-number= issuable.to_reference - issuable.labels.each do |label| - = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do + = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do - render_colored_label(label) %span.assignee-icon - if assignee - = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }), + = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }), class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do - image_tag(avatar_icon(issuable.assignee, 16), class: "avatar s16", alt: '') diff --git a/app/workers/clear_database_cache_worker.rb b/app/workers/clear_database_cache_worker.rb deleted file mode 100644 index c4cb4733482..00000000000 --- a/app/workers/clear_database_cache_worker.rb +++ /dev/null @@ -1,24 +0,0 @@ -# This worker clears all cache fields in the database, working in batches. -class ClearDatabaseCacheWorker - include Sidekiq::Worker - include DedicatedSidekiqQueue - - BATCH_SIZE = 1000 - - def perform - CacheMarkdownField.caching_classes.each do |kls| - fields = kls.cached_markdown_fields.html_fields - clear_cache_fields = fields.each_with_object({}) do |field, memo| - memo[field] = nil - end - - Rails.logger.debug("Clearing Markdown cache for #{kls}: #{fields.inspect}") - - kls.unscoped.in_batches(of: BATCH_SIZE) do |relation| - relation.update_all(clear_cache_fields) - end - end - - nil - end -end diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb new file mode 100644 index 00000000000..603e2f1aaea --- /dev/null +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -0,0 +1,57 @@ +class ExpirePipelineCacheWorker + include Sidekiq::Worker + include PipelineQueue + + def perform(pipeline_id) + pipeline = Ci::Pipeline.find_by(id: pipeline_id) + return unless pipeline + + project = pipeline.project + store = Gitlab::EtagCaching::Store.new + + store.touch(project_pipelines_path(project)) + store.touch(commit_pipelines_path(project, pipeline.commit)) if pipeline.commit + store.touch(new_merge_request_pipelines_path(project)) + each_pipelines_merge_request_path(project, pipeline) do |path| + store.touch(path) + end + + Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(pipeline) + end + + private + + def project_pipelines_path(project) + Gitlab::Routing.url_helpers.namespace_project_pipelines_path( + project.namespace, + project, + format: :json) + end + + def commit_pipelines_path(project, commit) + Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path( + project.namespace, + project, + commit.id, + format: :json) + end + + def new_merge_request_pipelines_path(project) + Gitlab::Routing.url_helpers.new_namespace_project_merge_request_path( + project.namespace, + project, + format: :json) + end + + def each_pipelines_merge_request_path(project, pipeline) + pipeline.all_merge_requests.each do |merge_request| + path = Gitlab::Routing.url_helpers.pipelines_namespace_project_merge_request_path( + project.namespace, + project, + merge_request, + format: :json) + + yield(path) + end + end +end |