diff options
Diffstat (limited to 'app')
391 files changed, 4296 insertions, 1511 deletions
diff --git a/app/assets/images/none-scheme-preview.png b/app/assets/images/none-scheme-preview.png Binary files differnew file mode 100644 index 00000000000..2eb6bf96671 --- /dev/null +++ b/app/assets/images/none-scheme-preview.png diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e2740981a4b..d1396b6c4bc 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -25,9 +25,11 @@ const Api = { userStatusPath: '/api/:version/users/:id/status', userPostStatusPath: '/api/:version/user/status', commitPath: '/api/:version/projects/:id/repository/commits', + applySuggestionPath: '/api/:version/suggestions/:id/apply', commitPipelinesPath: '/:project_id/commit/:sha/pipelines', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', createBranchPath: '/api/:version/projects/:id/repository/branches', + releasesPath: '/api/:version/projects/:id/releases', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -185,6 +187,12 @@ const Api = { }); }, + applySuggestion(id) { + const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id)); + + return axios.put(url); + }, + commitPipelines(projectId, sha) { const encodedProjectId = projectId .split('/') @@ -300,6 +308,12 @@ const Api = { }); }, + releases(id) { + const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url); + }, + buildUrl(url) { let urlRoot = ''; if (gon.relative_url_root != null) { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js index fa9b2c9f755..bef1553703b 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js @@ -8,6 +8,7 @@ export default class ShortcutsNavigation extends Shortcuts { Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project')); Mousetrap.bind('g v', () => findAndFollowLink('.shortcuts-project-activity')); + Mousetrap.bind('g r', () => findAndFollowLink('.shortcuts-project-releases')); Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree')); Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits')); Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds')); diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 9f547471170..5f64175362d 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -16,13 +16,25 @@ export default () => { const filePath = editBlobForm.data('blobFilename'); const currentAction = $('.js-file-title').data('currentAction'); const projectId = editBlobForm.data('project-id'); + const isMarkdown = editBlobForm.data('is-markdown'); const commitButton = $('.js-commit-button'); + const cancelLink = $('.btn.btn-cancel'); + + cancelLink.on('click', () => { + window.onbeforeunload = null; + }); commitButton.on('click', () => { window.onbeforeunload = null; }); - new EditBlob(`${urlRoot}${assetsPath}`, filePath, currentAction, projectId); + new EditBlob({ + assetsPath: `${urlRoot}${assetsPath}`, + filePath, + currentAction, + projectId, + isMarkdown, + }); new NewCommitForm(editBlobForm); // returning here blocks page navigation diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 6e19548eed2..011898a5e7a 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -6,22 +6,31 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; import TemplateSelectorMediator from '../blob/file_template_mediator'; import getModeByFileExtension from '~/lib/utils/ace_utils'; +import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; export default class EditBlob { - constructor(assetsPath, aceMode, currentAction, projectId) { - this.configureAceEditor(aceMode, assetsPath); + // The options object has: + // assetsPath, filePath, currentAction, projectId, isMarkdown + constructor(options) { + this.options = options; + this.configureAceEditor(); this.initModePanesAndLinks(); this.initSoftWrap(); - this.initFileSelectors(currentAction, projectId); + this.initFileSelectors(); } - configureAceEditor(filePath, assetsPath) { + configureAceEditor() { + const { filePath, assetsPath, isMarkdown } = this.options; ace.config.set('modePath', `${assetsPath}/ace`); ace.config.loadModule('ace/ext/searchbox'); ace.config.loadModule('ace/ext/modelist'); this.editor = ace.edit('editor'); + if (isMarkdown) { + addEditorMarkdownListeners(this.editor); + } + // This prevents warnings re: automatic scrolling being logged this.editor.$blockScrolling = Infinity; @@ -32,7 +41,8 @@ export default class EditBlob { } } - initFileSelectors(currentAction, projectId) { + initFileSelectors() { + const { currentAction, projectId } = this.options; this.fileTemplateMediator = new TemplateSelectorMediator({ currentAction, editor: this.editor, diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index e038198e6f0..9c4c6632976 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -3,7 +3,12 @@ import dateFormat from 'dateformat'; import { GlTooltip } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; -import { getDayDifference, getTimeago, dateInWords } from '~/lib/utils/datetime_utility'; +import { + getDayDifference, + getTimeago, + dateInWords, + parsePikadayDate, +} from '~/lib/utils/datetime_utility'; export default { components: { @@ -54,7 +59,7 @@ export default { return standardDateFormat; }, issueDueDate() { - return new Date(this.date); + return parsePikadayDate(this.date); }, timeDifference() { const today = new Date(); diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue index 08408eb0b52..defd857b92c 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -45,7 +45,7 @@ export default { <section class="empty-state"> <div class="row"> <div class="col-12 col-md-6 order-md-last"> - <aside class="svg-content"><img :src="emptyStateSvg" /></aside> + <aside class="svg-content d-none d-md-block"><img :src="emptyStateSvg" /></aside> </div> <div class="col-12 col-md-6 order-md-first"> <div class="text-content"> diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js index ee0f7cda189..5b20fa141cd 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -36,7 +36,9 @@ export default class VariableList { }, protected: { selector: '.js-ci-variable-input-protected', - default: 'false', + // use `attr` instead of `data` as we don't want the value to be + // converted. we need the value as a string. + default: $('.js-ci-variable-input-protected').attr('data-default'), }, environment_scope: { // We can't use a `.js-` class here because diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index cf70a48f076..aff32d95db1 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,6 +1,6 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; -import initDismissableCallout from '~/dismissable_callout'; +import PersistentUserCallout from '../persistent_user_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; @@ -67,7 +67,7 @@ export default class Clusters { this.showTokenButton = document.querySelector('.js-show-cluster-token'); this.tokenField = document.querySelector('.js-cluster-token'); - initDismissableCallout('.js-cluster-security-warning'); + Clusters.initDismissableCallout(); initSettingsPanels(); setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area')); this.initApplications(clusterType); @@ -108,6 +108,12 @@ export default class Clusters { }); } + static initDismissableCallout() { + const callout = document.querySelector('.js-cluster-security-warning'); + + if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + } + addListeners() { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index f0e82b1ed27..d4c1b07093d 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -42,6 +42,11 @@ export default { type: Object, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, changesEmptyStateIllustration: { type: String, required: false, @@ -208,6 +213,7 @@ export default { v-for="file in diffFiles" :key="file.newPath" :file="file" + :help-page-path="helpPagePath" :can-current-user-fork="canCurrentUserFork" /> </template> diff --git a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue index 8da02ed0b7c..b9b1ee02697 100644 --- a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue +++ b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue @@ -129,7 +129,7 @@ export default { </strong> </div> <div> - <small class="commit-sha"> {{ version.truncated_commit_sha }} </small> + <small class="commit-sha"> {{ version.short_commit_sha }} </small> </div> <div> <small> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 11cc4c09fed..ba6dcd63880 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -1,6 +1,7 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; +import EmptyFileViewer from '~/vue_shared/components/diff_viewer/viewers/empty_file.vue'; import InlineDiffView from './inline_diff_view.vue'; import ParallelDiffView from './parallel_diff_view.vue'; import NoteForm from '../../notes/components/note_form.vue'; @@ -17,12 +18,18 @@ export default { NoteForm, DiffDiscussions, ImageDiffOverlay, + EmptyFileViewer, }, props: { diffFile: { type: Object, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapState({ @@ -38,6 +45,9 @@ export default { isTextFile() { return this.diffFile.viewer.name === 'text'; }, + errorMessage() { + return this.diffFile.viewer.error; + }, diffFileCommentForm() { return this.getCommentFormForDiffFile(this.diffFile.file_hash); }, @@ -68,17 +78,20 @@ export default { <template> <div class="diff-content"> - <div class="diff-viewer"> + <div v-if="!errorMessage" class="diff-viewer"> <template v-if="isTextFile"> + <empty-file-viewer v-if="diffFile.empty" /> <inline-diff-view - v-if="isInlineView" + v-else-if="isInlineView" :diff-file="diffFile" :diff-lines="diffFile.highlighted_diff_lines || []" + :help-page-path="helpPagePath" /> <parallel-diff-view - v-if="isParallelView" + v-else-if="isParallelView" :diff-file="diffFile" :diff-lines="diffFile.parallel_diff_lines || []" + :help-page-path="helpPagePath" /> </template> <diff-viewer @@ -119,5 +132,8 @@ export default { </div> </diff-viewer> </div> + <div v-else class="diff-viewer"> + <div class="nothing-here-block" v-html="errorMessage"></div> + </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index bee29b04e92..b2021cd6061 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -13,6 +13,11 @@ export default { type: Array, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, shouldCollapseDiscussions: { type: Boolean, required: false, @@ -23,6 +28,11 @@ export default { required: false, default: false, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, methods: { ...mapActions(['toggleDiscussion']), @@ -72,6 +82,8 @@ export default { :render-diff-file="false" :always-expanded="true" :discussions-by-diff-order="true" + :line="line" + :help-page-path="helpPagePath" @noteDeleted="deleteNoteHandler" > <span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill"> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index bed29efb253..449f7007077 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -23,6 +23,11 @@ export default { type: Boolean, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -164,6 +169,7 @@ export default { v-if="!isCollapsed && file.renderIt" :class="{ hidden: isCollapsed || file.too_large }" :diff-file="file" + :help-page-path="helpPagePath" /> <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> <div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed"> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 9fd02acbd6e..e7569ba7b84 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -94,6 +94,7 @@ export default { ref="noteForm" :is-editing="true" :line-code="line.line_code" + :line="line" save-button-title="Comment" class="diff-comment-form" @cancelForm="handleCancelCommentForm" diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue index aa40b24950a..814ee0b7c02 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -16,6 +16,11 @@ export default { type: String, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { className() { @@ -38,7 +43,12 @@ export default { <tr v-if="shouldRender" :class="className" class="notes_holder"> <td class="notes_content" colspan="3"> <div class="content"> - <diff-discussions v-if="line.discussions.length" :discussions="line.discussions" /> + <diff-discussions + v-if="line.discussions.length" + :line="line" + :discussions="line.discussions" + :help-page-path="helpPagePath" + /> <diff-line-note-form v-if="line.hasForm" :diff-file-hash="diffFileHash" diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index 6a0ce760e6d..e781397214d 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -17,6 +17,11 @@ export default { type: Array, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapGetters('diffs', ['commitId']), @@ -44,9 +49,10 @@ export default { :is-bottom="index + 1 === diffLinesLength" /> <inline-diff-comment-row - :key="`icr-${index}`" + :key="`icr-${line.line_code || index}`" :diff-file-hash="diffFile.file_hash" :line="line" + :help-page-path="helpPagePath" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index b98463d3dd3..a65cf025cde 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -20,6 +20,11 @@ export default { type: Number, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { hasExpandedDiscussionOnLeft() { @@ -87,6 +92,8 @@ export default { <diff-discussions v-if="line.left.discussions.length" :discussions="line.left.discussions" + :line="line.left" + :help-page-path="helpPagePath" /> </div> <diff-line-note-form @@ -102,6 +109,8 @@ export default { <diff-discussions v-if="line.right.discussions.length" :discussions="line.right.discussions" + :line="line.right" + :help-page-path="helpPagePath" /> </div> <diff-line-note-form diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 9a6e0e82529..1bf693380db 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -17,6 +17,11 @@ export default { type: Array, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapGetters('diffs', ['commitId']), @@ -38,17 +43,18 @@ export default { <tbody> <template v-for="(line, index) in diffLines"> <parallel-diff-table-row - :key="index" + :key="line.line_code" :file-hash="diffFile.file_hash" :context-lines-path="diffFile.context_lines_path" :line="line" :is-bottom="index + 1 === diffLinesLength" /> <parallel-diff-comment-row - :key="`dcr-${index}`" + :key="`dcr-${line.line_code || index}`" :line="line" :diff-file-hash="diffFile.file_hash" :line-index="index" + :help-page-path="helpPagePath" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 915cacb374f..b130cedc24c 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -16,6 +16,7 @@ export default function initDiffsApp(store) { return { endpoint: dataset.endpoint, projectPath: dataset.projectPath, + helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, changesEmptyStateIllustration: dataset.changesEmptyStateIllustration, }; @@ -31,6 +32,7 @@ export default function initDiffsApp(store) { endpoint: this.endpoint, currentUser: this.currentUser, projectPath: this.projectPath, + helpPagePath: this.helpPagePath, shouldShow: this.activeTab === 'diffs', changesEmptyStateIllustration: this.changesEmptyStateIllustration, }, diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 2ea884d1293..ed4203cf5e0 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -138,7 +138,7 @@ export default { if (file.highlighted_diff_lines) { file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => - mapDiscussions(line), + lineCheck(line) ? mapDiscussions(line) : line, ); } diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index cbaa0e26395..2fe20551642 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -196,6 +196,15 @@ export function trimFirstCharOfLineContent(line = {}) { return parsedLine; } +function getLineCode({ left, right }, index) { + if (left && left.line_code) { + return left.line_code; + } else if (right && right.line_code) { + return right.line_code; + } + return index; +} + // This prepares and optimizes the incoming diff data from the server // by setting up incremental rendering and removing unneeded data export function prepareDiffData(diffData) { @@ -208,6 +217,8 @@ export function prepareDiffData(diffData) { const linesLength = file.parallel_diff_lines.length; for (let u = 0; u < linesLength; u += 1) { const line = file.parallel_diff_lines[u]; + + line.line_code = getLineCode(line, u); if (line.left) { line.left = trimFirstCharOfLineContent(line.left); line.left.hasForm = false; diff --git a/app/assets/javascripts/dismissable_callout.js b/app/assets/javascripts/dismissable_callout.js deleted file mode 100644 index 5185b019376..00000000000 --- a/app/assets/javascripts/dismissable_callout.js +++ /dev/null @@ -1,27 +0,0 @@ -import $ from 'jquery'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import Flash from '~/flash'; - -export default function initDismissableCallout(alertSelector) { - const alertEl = document.querySelector(alertSelector); - if (!alertEl) { - return; - } - - const closeButtonEl = alertEl.getElementsByClassName('close')[0]; - const { dismissEndpoint, featureId } = closeButtonEl.dataset; - - closeButtonEl.addEventListener('click', () => { - axios - .post(dismissEndpoint, { - feature_name: featureId, - }) - .then(() => { - $(alertEl).alert('close'); - }) - .catch(() => { - Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); - }); - }); -} diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index cd2f46fd07a..f44806d82a6 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -14,6 +14,7 @@ import MonitoringButtonComponent from './environment_monitoring.vue'; import CommitComponent from '../../vue_shared/components/commit.vue'; import eventHub from '../event_hub'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { CLUSTER_TYPE } from '~/clusters/constants'; /** * Environment Item Component @@ -85,6 +86,15 @@ export default { }, /** + * Hide group cluster features which are not currently implemented. + * + * @returns {Boolean} + */ + disableGroupClusterFeatures() { + return this.model && this.model.cluster_type === CLUSTER_TYPE.GROUP; + }, + + /** * Returns whether the environment can be stopped. * * @returns {Boolean} @@ -547,6 +557,7 @@ export default { <terminal-button-component v-if="model && model.terminal_path" :terminal-path="model.terminal_path" + :disabled="disableGroupClusterFeatures" /> <rollback-component diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 83727caad16..6d74d136a94 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.vue +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -19,6 +19,11 @@ export default { required: false, default: '', }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, computed: { title() { @@ -33,6 +38,7 @@ export default { :title="title" :aria-label="title" :href="terminalPath" + :class="{ disabled: disabled }" class="btn terminal-button d-none d-sm-none d-md-block" > <icon name="terminal" /> diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index c14eb936930..8178821be3d 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -256,7 +256,7 @@ class GfmAutoComplete { displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; if (value.title != null) { - tmpl = GfmAutoComplete.Milestones.template; + tmpl = GfmAutoComplete.Milestones.templateFunction(value.title); } return tmpl; }, @@ -323,7 +323,7 @@ class GfmAutoComplete { searchKey: 'search', data: GfmAutoComplete.defaultLoadingData, displayTpl(value) { - let tmpl = GfmAutoComplete.Labels.template; + let tmpl = GfmAutoComplete.Labels.templateFunction(value.color, value.title); if (GfmAutoComplete.isLoading(value)) { tmpl = GfmAutoComplete.Loading.template; } @@ -588,9 +588,11 @@ GfmAutoComplete.Members = { }, }; GfmAutoComplete.Labels = { - template: - // eslint-disable-next-line no-template-curly-in-string - '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>', + templateFunction(color, title) { + return `<li><span class="dropdown-label-box" style="background: ${_.escape( + color, + )}"></span> ${_.escape(title)}</li>`; + }, }; // Issues, MergeRequests and Snippets GfmAutoComplete.Issues = { @@ -600,8 +602,9 @@ GfmAutoComplete.Issues = { }; // Milestones GfmAutoComplete.Milestones = { - // eslint-disable-next-line no-template-curly-in-string - template: '<li>${title}</li>', + templateFunction(title) { + return `<li>${_.escape(title)}</li>`; + }, }; GfmAutoComplete.Loading = { template: diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue index 309b7427b9e..0bce860df91 100644 --- a/app/assets/javascripts/jobs/components/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/artifacts_block.vue @@ -28,27 +28,29 @@ export default { </script> <template> <div class="block"> - <div class="title">{{ s__('Job|Job artifacts') }}</div> + <div class="title font-weight-bold">{{ s__('Job|Job artifacts') }}</div> - <p v-if="isExpired" class="js-artifacts-removed build-detail-row"> - {{ s__('Job|The artifacts were removed') }} + <p + v-if="isExpired || willExpire" + :class="{ + 'js-artifacts-removed': isExpired, + 'js-artifacts-will-be-removed': willExpire, + }" + class="build-detail-row" + > + <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span> + <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</span> + <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> </p> - <p v-else-if="willExpire" class="js-artifacts-will-be-removed build-detail-row"> - {{ s__('Job|The artifacts will be removed in') }} - </p> - - <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> - - <div class="btn-group d-flex" role="group"> + <div class="btn-group d-flex prepend-top-10" role="group"> <gl-link v-if="artifact.keep_path" :href="artifact.keep_path" class="js-keep-artifacts btn btn-sm btn-default" data-method="post" + >{{ s__('Job|Keep') }}</gl-link > - {{ s__('Job|Keep') }} - </gl-link> <gl-link v-if="artifact.download_path" @@ -56,17 +58,15 @@ export default { class="js-download-artifacts btn btn-sm btn-default" download rel="nofollow" + >{{ s__('Job|Download') }}</gl-link > - {{ s__('Job|Download') }} - </gl-link> <gl-link v-if="artifact.browse_path" :href="artifact.browse_path" class="js-browse-artifacts btn btn-sm btn-default" + >{{ s__('Job|Browse') }}</gl-link > - {{ s__('Job|Browse') }} - </gl-link> </div> </div> </template> diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index 3b9c61bd48c..e0f55518eef 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -31,12 +31,12 @@ export default { block: !isLastBlock, }" > - <p> - {{ __('Commit') }} + <p class="append-bottom-5"> + <span class="font-weight-bold">{{ __('Commit') }}</span> - <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">{{ - commit.short_id - }}</gl-link> + <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit"> + {{ commit.short_id }} + </gl-link> <clipboard-button :text="commit.short_id" @@ -44,11 +44,14 @@ export default { css-class="btn btn-clipboard btn-transparent" /> - <gl-link v-if="mergeRequest" :href="mergeRequest.path" class="js-link-commit link-commit" - >!{{ mergeRequest.iid }}</gl-link - > + <span v-if="mergeRequest"> + {{ __('in') }} + <gl-link :href="mergeRequest.path" class="js-link-commit link-commit" + >!{{ mergeRequest.iid }}</gl-link + > + </span> </p> - <p class="build-light-text append-bottom-0">{{ commit.title }}</p> + <p class="append-bottom-0">{{ commit.title }}</p> </div> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 934ecd0e3ec..ad3e7dabc79 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -110,22 +110,20 @@ export default { <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> <div class="blocks-container"> - <div class="block"> - <strong class="inline prepend-top-8"> {{ job.name }} </strong> + <div class="block d-flex align-items-center"> + <h4 class="flex-grow-1 prepend-top-8 m-0">{{ job.name }}</h4> <gl-link v-if="job.retry_path" :class="retryButtonClass" :href="job.retry_path" data-method="post" rel="nofollow" + >{{ __('Retry') }}</gl-link > - {{ __('Retry') }} - </gl-link> <gl-link v-if="job.terminal_path" :href="job.terminal_path" - class="js-terminal-link pull-right btn btn-primary - btn-inverted visible-md-block visible-lg-block" + class="js-terminal-link pull-right btn btn-primary btn-inverted visible-md-block visible-lg-block" target="_blank" > {{ __('Debug') }} <icon name="external-link" /> @@ -133,8 +131,7 @@ export default { <gl-button :aria-label="__('Toggle Sidebar')" type="button" - class="btn btn-blank gutter-toggle - float-right d-block d-md-none js-sidebar-build-toggle" + class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle" @click="toggleSidebar" > <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i> @@ -145,25 +142,18 @@ export default { v-if="job.new_issue_path" :href="job.new_issue_path" class="js-new-issue btn btn-success btn-inverted" + >{{ __('New issue') }}</gl-link > - {{ __('New issue') }} - </gl-link> <gl-link v-if="job.retry_path" :href="job.retry_path" class="js-retry-job btn btn-inverted-secondary" data-method="post" rel="nofollow" + >{{ __('Retry') }}</gl-link > - {{ __('Retry') }} - </gl-link> </div> <div :class="{ block: renderBlock }"> - <p v-if="job.merge_request" class="build-detail-row js-job-mr"> - <span class="build-light-text"> {{ __('Merge Request:') }} </span> - <gl-link :href="job.merge_request.path"> !{{ job.merge_request.iid }} </gl-link> - </p> - <detail-row v-if="job.duration" :value="duration" @@ -198,10 +188,10 @@ export default { title="Coverage" /> <p v-if="job.tags.length" class="build-detail-row js-job-tags"> - <span class="build-light-text"> {{ __('Tags:') }} </span> - <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary"> - {{ tag }} - </span> + <span class="font-weight-bold">{{ __('Tags:') }}</span> + <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{ + tag + }}</span> </p> <div v-if="job.cancel_path" class="btn-group prepend-top-5" role="group"> @@ -210,9 +200,8 @@ export default { class="js-cancel-job btn btn-sm btn-default" data-method="post" rel="nofollow" + >{{ __('Cancel') }}</gl-link > - {{ __('Cancel') }} - </gl-link> </div> </div> diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue index 77be295e802..b826007ec2c 100644 --- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue +++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue @@ -34,8 +34,7 @@ export default { </script> <template> <p class="build-detail-row"> - <span v-if="hasTitle" class="build-light-text"> {{ title }}: </span> {{ value }} - + <span v-if="hasTitle" class="font-weight-bold">{{ title }}:</span> {{ value }} <span v-if="hasHelpURL" class="help-button float-right"> <gl-link :href="helpUrl" target="_blank" rel="noopener noreferrer nofollow"> <i class="fa fa-question-circle" aria-hidden="true"></i> diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 90482500bbf..7f79e92067f 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -38,11 +38,11 @@ export default { <div class="block-last dropdown"> <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> - {{ __('Pipeline') }} - <a :href="pipeline.path" class="js-pipeline-path link-commit"> #{{ pipeline.id }} </a> + <span class="font-weight-bold">{{ __('Pipeline') }}</span> + <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a> <template v-if="hasRef"> {{ __('from') }} - <a :href="pipeline.ref.path" class="link-commit ref-name"> {{ pipeline.ref.name }} </a> + <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a> </template> <button diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index 3cd3b743108..997737b3e23 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -43,23 +43,24 @@ export default { <template> <div class="build-widget block"> - <h4 class="title">{{ __('Trigger') }}</h4> - <p v-if="trigger.short_token" class="js-short-token" - :class="{ 'append-bottom-0': !hasVariables }" + :class="{ 'append-bottom-5': hasVariables, 'append-bottom-0': !hasVariables }" > - <span class="build-light-text"> {{ __('Token') }} </span> {{ trigger.short_token }} + <span class="font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }} </p> <template v-if="hasVariables"> <p class="trigger-variables-btn-container"> - <span class="build-light-text"> {{ __('Variables:') }} </span> + <span class="font-weight-bold">{{ __('Trigger variables:') }}</span> - <gl-button v-if="hasValues" class="group js-reveal-variables" @click="toggleValues"> - {{ getToggleButtonText }} - </gl-button> + <gl-button + v-if="hasValues" + class="btn-sm group js-reveal-variables trigger-variables-btn" + @click="toggleValues" + >{{ getToggleButtonText }}</gl-button + > </p> <table class="js-build-variables trigger-build-variables"> diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 3618c6af7e2..84a617acb42 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -8,6 +8,10 @@ function selectedText(text, textarea) { return text.substring(textarea.selectionStart, textarea.selectionEnd); } +function addBlockTags(blockTag, selected) { + return `${blockTag}\n${selected}\n${blockTag}`; +} + function lineBefore(text, textarea) { var split; split = text @@ -24,59 +28,130 @@ function lineAfter(text, textarea) { .split('\n')[0]; } +function editorBlockTagText(text, blockTag, selected, editor) { + const lines = text.split('\n'); + const selectionRange = editor.getSelectionRange(); + const shouldRemoveBlock = + lines[selectionRange.start.row - 1] === blockTag && + lines[selectionRange.end.row + 1] === blockTag; + + if (shouldRemoveBlock) { + if (blockTag !== null) { + // ace is globally defined + // eslint-disable-next-line no-undef + const { Range } = ace.require('ace/range'); + const lastLine = lines[selectionRange.end.row + 1]; + const rangeWithBlockTags = new Range( + lines[selectionRange.start.row - 1], + 0, + selectionRange.end.row + 1, + lastLine.length, + ); + editor.getSelection().setSelectionRange(rangeWithBlockTags); + } + return selected; + } + return addBlockTags(blockTag, selected); +} + function blockTagText(text, textArea, blockTag, selected) { - const before = lineBefore(text, textArea); - const after = lineAfter(text, textArea); - if (before === blockTag && after === blockTag) { + const shouldRemoveBlock = + lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag; + + if (shouldRemoveBlock) { // To remove the block tag we have to select the line before & after if (blockTag != null) { textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); } return selected; - } else { - return blockTag + '\n' + selected + '\n' + blockTag; } + return addBlockTags(blockTag, selected); } -function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, select }) { +function moveCursor({ + textArea, + tag, + cursorOffset, + positionBetweenTags, + removedLastNewLine, + select, + editor, + editorSelectionStart, + editorSelectionEnd, +}) { var pos; - if (!textArea.setSelectionRange) { + if (textArea && !textArea.setSelectionRange) { return; } if (select && select.length > 0) { - // calculate the part of the text to be selected - const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select)); - const endPosition = startPosition + select.length; - return textArea.setSelectionRange(startPosition, endPosition); - } - if (textArea.selectionStart === textArea.selectionEnd) { - if (positionBetweenTags) { - pos = textArea.selectionStart - tag.length; - } else { - pos = textArea.selectionStart; + if (textArea) { + // calculate the part of the text to be selected + const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select)); + const endPosition = startPosition + select.length; + return textArea.setSelectionRange(startPosition, endPosition); + } else if (editor) { + editor.navigateLeft(tag.length - tag.indexOf(select)); + editor.getSelection().selectAWord(); + return; } + } + if (textArea) { + if (textArea.selectionStart === textArea.selectionEnd) { + if (positionBetweenTags) { + pos = textArea.selectionStart - tag.length; + } else { + pos = textArea.selectionStart; + } - if (removedLastNewLine) { - pos -= 1; - } + if (removedLastNewLine) { + pos -= 1; + } + + if (cursorOffset) { + pos -= cursorOffset; + } - return textArea.setSelectionRange(pos, pos); + return textArea.setSelectionRange(pos, pos); + } + } else if (editor && editorSelectionStart.row === editorSelectionEnd.row) { + if (positionBetweenTags) { + editor.navigateLeft(tag.length); + } } } -export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) { +export function insertMarkdownText({ + textArea, + text, + tag, + cursorOffset, + blockTag, + selected = '', + wrap, + select, + editor, +}) { var textToInsert, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, - lastNewLine; + lastNewLine, + editorSelectionStart, + editorSelectionEnd; removedLastNewLine = false; removedFirstNewLine = false; currentLineEmpty = false; + if (editor) { + const selectionRange = editor.getSelectionRange(); + + editorSelectionStart = selectionRange.start; + editorSelectionEnd = selectionRange.end; + } + // check for link pattern and selected text is an URL // if so fill in the url part instead of the text part of the pattern. if (tag === LINK_TAG_PATTERN) { @@ -99,14 +174,27 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr } // Remove the last newline - if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { - removedLastNewLine = true; - selected = selected.replace(/\n$/, ''); + if (textArea) { + if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { + removedLastNewLine = true; + selected = selected.replace(/\n$/, ''); + } + } else if (editor) { + if (editorSelectionStart.row !== editorSelectionEnd.row) { + removedLastNewLine = true; + selected = selected.replace(/\n$/, ''); + } } selectedSplit = selected.split('\n'); - if (!wrap) { + if (editor && !wrap) { + lastNewLine = editor.getValue().split('\n')[editorSelectionStart.row]; + + if (/^\s*$/.test(lastNewLine)) { + currentLineEmpty = true; + } + } else if (textArea && !wrap) { lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); // Check whether the current line is empty or consists only of spaces(=handle as empty) @@ -115,13 +203,19 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr } } - startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; + const isBeginning = + (textArea && textArea.selectionStart === 0) || + (editor && editorSelectionStart.column === 0 && editorSelectionStart.row === 0); + + startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : ''; const textPlaceholder = '{text}'; if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { if (blockTag != null && blockTag !== '') { - textToInsert = blockTagText(text, textArea, blockTag, selected); + textToInsert = editor + ? editorBlockTagText(text, blockTag, selected, editor) + : blockTagText(text, textArea, blockTag, selected); } else { textToInsert = selectedSplit .map(function(val) { @@ -150,24 +244,41 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr textToInsert += '\n'; } - insertText(textArea, textToInsert); + if (editor) { + editor.insert(textToInsert); + } else { + insertText(textArea, textToInsert); + } return moveCursor({ textArea, tag: tag.replace(textPlaceholder, selected), + cursorOffset, positionBetweenTags: wrap && selected.length === 0, removedLastNewLine, select, + editor, + editorSelectionStart, + editorSelectionEnd, }); } -function updateText({ textArea, tag, blockTag, wrap, select }) { +function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { var $textArea, selected, text; $textArea = $(textArea); textArea = $textArea.get(0); text = $textArea.val(); - selected = selectedText(text, textArea); + selected = selectedText(text, textArea) || tagContent; $textArea.focus(); - return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }); + return insertMarkdownText({ + textArea, + text, + tag, + cursorOffset, + blockTag, + selected, + wrap, + select, + }); } export function addMarkdownListeners(form) { @@ -178,10 +289,31 @@ export function addMarkdownListeners(form) { return updateText({ textArea: $this.closest('.md-area').find('textarea'), tag: $this.data('mdTag'), + cursorOffset: $this.data('mdCursorOffset'), blockTag: $this.data('mdBlock'), wrap: !$this.data('mdPrepend'), select: $this.data('mdSelect'), + tagContent: $this.data('mdTagContent'), + }); + }); +} + +export function addEditorMarkdownListeners(editor) { + $('.js-md') + .off('click') + .on('click', function(e) { + const { mdTag, mdBlock, mdPrepend, mdSelect } = $(e.currentTarget).data(); + + insertMarkdownText({ + tag: mdTag, + blockTag: mdBlock, + wrap: !mdPrepend, + select: mdSelect, + selected: editor.getSelectedText(), + text: editor.getValue(), + editor, }); + editor.focus(); }); } diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index bb24a1acdb3..50ba14dfb2e 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -92,7 +92,11 @@ function queryTimeSeries(query, graphDrawData, lineStyle) { if (seriesCustomizationData) { metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; [lineColor, areaColor] = pickColor(seriesCustomizationData.color); - shouldRenderLegend = false; + if (timeSeriesParsed.length > 0) { + shouldRenderLegend = false; + } else { + shouldRenderLegend = true; + } } else { metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; [lineColor, areaColor] = pickColor(); @@ -101,19 +105,6 @@ function queryTimeSeries(query, graphDrawData, lineStyle) { } } - if (!shouldRenderLegend) { - if (!timeSeriesParsed[0].tracksLegend) { - timeSeriesParsed[0].tracksLegend = []; - } - timeSeriesParsed[0].tracksLegend.push({ - max: maximumValue, - average: accum / timeSeries.values.length, - lineStyle, - lineColor, - metricTag, - }); - } - const values = datesWithoutGaps.map(time => ({ time, value: findByDate(timeSeries.values, time), @@ -135,6 +126,19 @@ function queryTimeSeries(query, graphDrawData, lineStyle) { shouldRenderLegend, renderCanary, }); + + if (!shouldRenderLegend) { + if (!timeSeriesParsed[0].tracksLegend) { + timeSeriesParsed[0].tracksLegend = []; + } + timeSeriesParsed[0].tracksLegend.push({ + max: maximumValue, + average: accum / timeSeries.values.length, + lineStyle, + lineColor, + metricTag, + }); + } }); return timeSeriesParsed; diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 1c98683c597..e4d72eb8318 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -33,6 +33,7 @@ export default function initMrNotes() { noteableData, currentUserData: JSON.parse(notesDataset.currentUserData), notesData: JSON.parse(notesDataset.notesData), + helpPagePath: notesDataset.helpPagePath, }; }, computed: { @@ -71,6 +72,7 @@ export default function initMrNotes() { notesData: this.notesData, userData: this.currentUserData, shouldShow: this.activeTab === 'show', + helpPagePath: this.helpPagePath, }, }); }, diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 86c114a761a..f5c410211b6 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -2,7 +2,11 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; -import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE } from '../constants'; +import { + DISCUSSION_FILTERS_DEFAULT_VALUE, + HISTORY_ONLY_FILTER_VALUE, + DISCUSSION_TAB_LABEL, +} from '../constants'; export default { components: { @@ -23,6 +27,7 @@ export default { return { currentValue: this.selectedValue, defaultValue: DISCUSSION_FILTERS_DEFAULT_VALUE, + displayFilters: true, }; }, computed: { @@ -32,6 +37,14 @@ export default { return this.filters.find(filter => filter.value === this.currentValue); }, }, + created() { + if (window.mrTabs) { + const { eventHub, currentTab } = window.mrTabs; + + eventHub.$on('MergeRequestTabChange', this.toggleFilters); + this.toggleFilters(currentTab); + } + }, mounted() { this.toggleCommentsForm(); }, @@ -51,12 +64,15 @@ export default { toggleCommentsForm() { this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE); }, + toggleFilters(tab) { + this.displayFilters = tab === DISCUSSION_TAB_LABEL; + }, }, }; </script> <template> - <div class="discussion-filter-container d-inline-block align-bottom"> + <div v-if="displayFilters" class="discussion-filter-container d-inline-block align-bottom"> <button id="discussion-filter-dropdown" ref="dropdownToggle" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index c0bee600181..bcf5d334da4 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,10 +1,12 @@ <script> +import { mapActions } from 'vuex'; import $ from 'jquery'; import noteEditedText from './note_edited_text.vue'; import noteAwardsList from './note_awards_list.vue'; import noteAttachment from './note_attachment.vue'; import noteForm from './note_form.vue'; import autosave from '../mixins/autosave'; +import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; export default { components: { @@ -12,6 +14,7 @@ export default { noteAwardsList, noteAttachment, noteForm, + Suggestions, }, mixins: [autosave], props: { @@ -19,6 +22,11 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, canEdit: { type: Boolean, required: true, @@ -28,11 +36,22 @@ export default { required: false, default: false, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { noteBody() { return this.note.note; }, + hasSuggestion() { + return this.note.suggestions && this.note.suggestions.length; + }, + lineType() { + return this.line ? this.line.type : null; + }, }, mounted() { this.renderGFM(); @@ -53,6 +72,7 @@ export default { } }, methods: { + ...mapActions(['submitSuggestion']), renderGFM() { $(this.$refs['note-body']).renderGFM(); }, @@ -62,19 +82,35 @@ export default { formCancelHandler(shouldConfirm, isDirty) { this.$emit('cancelForm', shouldConfirm, isDirty); }, + applySuggestion({ suggestionId, flashContainer, callback }) { + const { discussion_id: discussionId, id: noteId } = this.note; + + this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback }); + }, }, }; </script> <template> <div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body"> - <div class="note-text md" v-html="note.note_html"></div> + <suggestions + v-if="hasSuggestion && !isEditing" + :suggestions="note.suggestions" + :note-html="note.note_html" + :line-type="lineType" + :help-page-path="helpPagePath" + @apply="applySuggestion" + /> + <div v-else class="note-text md" v-html="note.note_html"></div> <note-form v-if="isEditing" ref="noteForm" :is-editing="isEditing" :note-body="noteBody" :note-id="note.id" + :line="line" + :note="note" + :help-page-path="helpPagePath" :markdown-version="note.cached_markdown_version" @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 95164183ccb..e78596f8b52 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,4 +1,5 @@ <script> +import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mapGetters, mapActions } from 'vuex'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -53,6 +54,21 @@ export default { required: false, default: false, }, + line: { + type: Object, + required: false, + default: null, + }, + note: { + type: Object, + required: false, + default: null, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -79,7 +95,8 @@ export default { return '#'; }, markdownPreviewPath() { - return this.getNoteableDataByProp('preview_note_path'); + const notable = this.getNoteableDataByProp('preview_note_path'); + return mergeUrlParams({ preview_suggestions: true }, notable); }, markdownDocsPath() { return this.getNotesDataByProp('markdownDocsPath'); @@ -93,6 +110,18 @@ export default { isDisabled() { return !this.updatedNoteBody.length || this.isSubmitting; }, + discussionNote() { + const discussionNote = this.discussion.id + ? this.getDiscussionLastNote(this.discussion) + : this.note; + return discussionNote || {}; + }, + canSuggest() { + return ( + this.getNoteableData.can_receive_suggestion && + (this.line && this.line.can_receive_suggestion) + ); + }, }, watch: { noteBody() { @@ -171,7 +200,11 @@ export default { :markdown-docs-path="markdownDocsPath" :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" + :line="line" + :note="discussionNote" + :can-suggest="canSuggest" :add-spacing-classes="false" + :help-page-path="helpPagePath" > <textarea id="note_note" @@ -193,7 +226,7 @@ export default { <button :disabled="isDisabled" type="button" - class="js-vue-issue-save btn btn-success js-comment-button" + class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button" @click="handleUpdate();" > {{ saveButtonTitle }} diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 5c9a28b8512..7c3f5d00308 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -49,6 +49,11 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, renderDiffFile: { type: Boolean, required: false, @@ -64,6 +69,11 @@ export default { required: false, default: false, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { const { diff_discussion: isDiffDiscussion, resolved } = this.discussion; @@ -168,31 +178,39 @@ export default { commitId = `<span class="commit-sha">${truncateSha(commitId)}</span>`; } - let text = s__('MergeRequests|started a discussion'); + const { + for_commit: isForCommit, + diff_discussion: isDiffDiscussion, + active: isActive, + } = this.discussion; - if (this.discussion.for_commit) { + let text = s__('MergeRequests|started a discussion'); + if (isForCommit) { text = s__( 'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}', ); - } else if (this.discussion.diff_discussion) { - if (this.discussion.active) { - text = s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}'); - } else { - text = s__( - 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}', - ); - } + } else if (isDiffDiscussion && commitId) { + text = isActive + ? s__('MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}') + : s__( + 'MergeRequests|started a discussion on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}', + ); + } else if (isDiffDiscussion) { + text = isActive + ? s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}') + : s__( + 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}', + ); } - return sprintf( - text, - { - commitId, - linkStart, - linkEnd, - }, - false, - ); + return sprintf(text, { commitId, linkStart, linkEnd }, false); + }, + diffLine() { + if (this.discussion.diff_discussion && this.discussion.truncated_diff_lines) { + return this.discussion.truncated_diff_lines.slice(-1)[0]; + } + + return this.line; }, }, watch: { @@ -357,8 +375,18 @@ Please check your network connection and try again.`; <component :is="componentName(initialDiscussion)" :note="componentData(initialDiscussion)" + :line="line" + :help-page-path="helpPagePath" @handleDeleteNote="deleteNoteHandler" > + <note-edited-text + v-if="discussion.resolved" + slot="discussion-resolved-text" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" + /> <slot slot="avatar-badge" name="avatar-badge"></slot> </component> <toggle-replies-widget @@ -373,6 +401,8 @@ Please check your network connection and try again.`; v-for="note in replies" :key="note.id" :note="componentData(note)" + :help-page-path="helpPagePath" + :line="line" @handleDeleteNote="deleteNoteHandler" /> </template> @@ -383,6 +413,8 @@ Please check your network connection and try again.`; v-for="(note, index) in discussion.notes" :key="note.id" :note="componentData(note)" + :help-page-path="helpPagePath" + :line="diffLine" @handleDeleteNote="deleteNoteHandler" > <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> @@ -390,7 +422,7 @@ Please check your network connection and try again.`; </template> </ul> <div - v-if="!isRepliesCollapsed" + v-if="!isRepliesCollapsed || !hasReplies" :class="{ 'is-replying': isReplying }" class="discussion-reply-holder" > @@ -447,6 +479,7 @@ Please check your network connection and try again.`; ref="noteForm" :discussion="discussion" :is-editing="false" + :line="diffLine" save-button-title="Comment" @handleFormUpdate="saveReply" @cancelForm="cancelReplyForm" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index a17be51353e..4c02588127e 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -27,6 +27,16 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -185,7 +195,7 @@ export default { :img-alt="author.name" :img-size="40" > - <slot slot="avatar-badge" name="avatar-badge"> </slot> + <slot slot="avatar-badge" name="avatar-badge"></slot> </user-avatar-link> </div> <div class="timeline-content"> @@ -217,14 +227,19 @@ export default { @handleResolve="resolveHandler" /> </div> - <note-body - ref="noteBody" - :note="note" - :can-edit="note.current_user.can_edit" - :is-editing="isEditing" - @handleFormUpdate="formUpdateHandler" - @cancelForm="formCancelHandler" - /> + <div class="timeline-discussion-body"> + <slot name="discussion-resolved-text"></slot> + <note-body + ref="noteBody" + :note="note" + :line="line" + :can-edit="note.current_user.can_edit" + :is-editing="isEditing" + :help-page-path="helpPagePath" + @handleFormUpdate="formUpdateHandler" + @cancelForm="formCancelHandler" + /> + </div> </div> </timeline-entry-item> </template> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 27f896cee35..f3fcfdfda05 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -49,6 +49,11 @@ export default { required: false, default: 0, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -206,6 +211,7 @@ export default { :key="discussion.id" :discussion="discussion" :render-diff-file="true" + :help-page-path="helpPagePath" /> </template> </ul> diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index 72a8ff28466..f1b0b12bdce 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -57,7 +57,7 @@ export default { tooltip-placement="bottom" /> </div> - <button class="btn btn-link js-replies-text" type="button" @click="toggle"> + <button class="btn btn-link js-replies-text qa-expand-replies" type="button" @click="toggle"> {{ replies.length }} {{ n__('reply', 'replies', replies.length) }} </button> {{ __('Last reply by') }} @@ -66,7 +66,11 @@ export default { </a> <time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" /> </template> - <span v-else class="collapse-replies-btn js-collapse-replies" @click="toggle"> + <span + v-else + class="collapse-replies-btn js-collapse-replies qa-collapse-replies" + @click="toggle" + > <icon name="chevron-down" /> {{ s__('Notes|Collapse replies') }} </span> </li> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index 3147dc64c27..78d365fe94b 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -17,6 +17,7 @@ export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const DESCRIPTION_TYPE = 'changed the description'; export const HISTORY_ONLY_FILTER_VALUE = 2; export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0; +export const DISCUSSION_TAB_LABEL = 'show'; export const NOTEABLE_TYPE_MAPPING = { Issue: ISSUE_NOTEABLE_TYPE, diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index 47a6f07cce2..237e70c0a4c 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Api from '~/api'; import VueResource from 'vue-resource'; import * as constants from '../constants'; @@ -44,4 +45,7 @@ export default { toggleIssueState(endpoint, data) { return Vue.http.put(endpoint, data); }, + applySuggestion(id) { + return Api.applySuggestion(id); + }, }; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 4716ab52333..65f85314fa0 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -405,5 +405,25 @@ export const startTaskList = ({ dispatch }) => export const updateResolvableDiscussonsCounts = ({ commit }) => commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); +export const submitSuggestion = ( + { commit }, + { discussionId, noteId, suggestionId, flashContainer, callback }, +) => { + service + .applySuggestion(suggestionId) + .then(() => { + commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }); + callback(); + }) + .catch(() => { + Flash( + __('Something went wrong while applying the suggestion. Please try again.'), + 'alert', + flashContainer, + ); + callback(); + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index b5fe8bdb1d3..887e6d22b06 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -20,6 +20,7 @@ export default () => ({ userData: {}, noteableData: { current_user: {}, + preview_note_path: 'path/to/preview', }, commentsDisabled: false, resolvableDiscussionsCount: 0, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 9c68ab67a8c..df943c155f4 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -16,6 +16,7 @@ export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; +export const APPLY_SUGGESTION = 'APPLY_SUGGESTION'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 39ff0ff73d7..8992454be2e 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -197,6 +197,17 @@ export default { } }, + [types.APPLY_SUGGESTION](state, { noteId, discussionId, suggestionId }) { + const noteObj = utils.findNoteObjectById(state.discussions, discussionId); + const comment = utils.findNoteObjectById(noteObj.notes, noteId); + + comment.suggestions = comment.suggestions.map(suggestion => ({ + ...suggestion, + applied: suggestion.applied || suggestion.id === suggestionId, + appliable: false, + })); + }, + [types.UPDATE_DISCUSSION](state, noteData) { const note = noteData; const selectedDiscussion = state.discussions.find(disc => disc.id === note.id); diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js index 845a5f7042c..21efc4f6d00 100644 --- a/app/assets/javascripts/pages/groups/clusters/index/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index/index.js @@ -1,5 +1,7 @@ -import initDismissableCallout from '~/dismissable_callout'; +import PersistentUserCallout from '~/persistent_user_callout'; document.addEventListener('DOMContentLoaded', () => { - initDismissableCallout('.gcp-signup-offer'); + const callout = document.querySelector('.gcp-signup-offer'); + + if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index bf80d8b8193..a63a0dbc6b1 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -1,6 +1,12 @@ -import initDismissableCallout from '~/dismissable_callout'; +import PersistentUserCallout from '~/persistent_user_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; +function initGcpSignupCallout() { + const callout = document.querySelector('.gcp-signup-offer'); + + if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new +} + document.addEventListener('DOMContentLoaded', () => { const { page } = document.body.dataset; const newClusterViews = [ @@ -10,7 +16,7 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - initDismissableCallout('.gcp-signup-offer'); + initGcpSignupCallout(); initGkeDropdowns(); } }); diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js index 845a5f7042c..21efc4f6d00 100644 --- a/app/assets/javascripts/pages/projects/clusters/index/index.js +++ b/app/assets/javascripts/pages/projects/clusters/index/index.js @@ -1,5 +1,7 @@ -import initDismissableCallout from '~/dismissable_callout'; +import PersistentUserCallout from '~/persistent_user_callout'; document.addEventListener('DOMContentLoaded', () => { - initDismissableCallout('.gcp-signup-offer'); + const callout = document.querySelector('.gcp-signup-offer'); + + if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 5659e13981a..b0345b4e50d 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,5 +1,5 @@ -import initDismissableCallout from '~/dismissable_callout'; import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; +import PersistentUserCallout from '../../persistent_user_callout'; import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; @@ -12,7 +12,9 @@ document.addEventListener('DOMContentLoaded', () => { ]; if (newClusterViews.indexOf(page) > -1) { - initDismissableCallout('.gcp-signup-offer'); + const callout = document.querySelector('.gcp-signup-offer'); + if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new + initGkeDropdowns(); } diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index 02a56685a35..f99023ad8e7 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -17,7 +17,7 @@ export default () => { new MilestoneSelect(); new IssuableTemplateSelectors(); - if (gon.features.issueSuggestions && gon.features.graphql) { + if (gon.features.graphql) { initSuggestions(); } }; diff --git a/app/assets/javascripts/pages/projects/releases/index/index.js b/app/assets/javascripts/pages/projects/releases/index/index.js new file mode 100644 index 00000000000..c183fbb9610 --- /dev/null +++ b/app/assets/javascripts/pages/projects/releases/index/index.js @@ -0,0 +1,3 @@ +import initReleases from '~/releases'; + +document.addEventListener('DOMContentLoaded', initReleases); diff --git a/app/assets/javascripts/pages/users/user_overview_block.js b/app/assets/javascripts/pages/users/user_overview_block.js index eec2b5ca8e5..e9ecec717d6 100644 --- a/app/assets/javascripts/pages/users/user_overview_block.js +++ b/app/assets/javascripts/pages/users/user_overview_block.js @@ -29,18 +29,21 @@ export default class UserOverviewBlock { render(data) { const { html, count } = data; - const contentList = document.querySelector(`${this.container} .overview-content-list`); + const containerEl = document.querySelector(this.container); + const contentList = containerEl.querySelector('.overview-content-list'); contentList.innerHTML += html; - const loadingEl = document.querySelector(`${this.container} .loading`); + const loadingEl = containerEl.querySelector('.loading'); if (count && count > 0) { - document.querySelector(`${this.container} .js-view-all`).classList.remove('hide'); + containerEl.querySelector('.js-view-all').classList.remove('hide'); } else { - document - .querySelector(`${this.container} .nothing-here-block`) - .classList.add('text-left', 'p-0'); + const nothingHereBlock = containerEl.querySelector('.nothing-here-block'); + + if (nothingHereBlock) { + nothingHereBlock.classList.add('text-left', 'p-0'); + } } loadingEl.classList.add('hide'); diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js new file mode 100644 index 00000000000..1e34e74a152 --- /dev/null +++ b/app/assets/javascripts/persistent_user_callout.js @@ -0,0 +1,34 @@ +import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; +import Flash from './flash'; + +export default class PersistentUserCallout { + constructor(container) { + const { dismissEndpoint, featureId } = container.dataset; + this.container = container; + this.dismissEndpoint = dismissEndpoint; + this.featureId = featureId; + + this.init(); + } + + init() { + const closeButton = this.container.querySelector('.js-close'); + closeButton.addEventListener('click', event => this.dismiss(event)); + } + + dismiss(event) { + event.preventDefault(); + + axios + .post(this.dismissEndpoint, { + feature_name: this.featureId, + }) + .then(() => { + this.container.remove(); + }) + .catch(() => { + Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.')); + }); + } +} diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 30a5bbf92ce..7d8863dff29 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -65,7 +65,7 @@ export default { v-if="pipeline.flags.latest" v-gl-tooltip class="js-pipeline-url-latest badge badge-success" - title="__('Latest pipeline for this branch')" + :title="__('Latest pipeline for this branch')" > latest </span> @@ -100,7 +100,7 @@ export default { <span v-if="pipeline.flags.merge_request" v-gl-tooltip - title="__('This pipeline is run in a merge request context')" + :title="__('This pipeline is run in a merge request context')" class="js-pipeline-url-mergerequest badge badge-info" > merge request diff --git a/app/assets/javascripts/releases/components/app.vue b/app/assets/javascripts/releases/components/app.vue new file mode 100644 index 00000000000..0ad5ee2915c --- /dev/null +++ b/app/assets/javascripts/releases/components/app.vue @@ -0,0 +1,82 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import ReleaseBlock from './release_block.vue'; + +export default { + name: 'ReleasesApp', + components: { + GlLoadingIcon, + GlEmptyState, + ReleaseBlock, + }, + props: { + projectId: { + type: String, + required: true, + }, + documentationLink: { + type: String, + required: true, + }, + illustrationPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['isLoading', 'releases', 'hasError']), + shouldRenderEmptyState() { + return !this.releases.length && !this.hasError && !this.isLoading; + }, + shouldRenderSuccessState() { + return this.releases.length && !this.isLoading && !this.hasError; + }, + }, + created() { + this.fetchReleases(this.projectId); + }, + methods: { + ...mapActions(['fetchReleases']), + }, +}; +</script> +<template> + <div class="prepend-top-default"> + <gl-loading-icon v-if="isLoading" :size="2" class="js-loading prepend-top-20" /> + + <gl-empty-state + v-else-if="shouldRenderEmptyState" + class="js-empty-state" + :title="__('Getting started with releases')" + :svg-path="illustrationPath" + :description=" + __( + 'Releases mark specific points in a project\'s development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API.', + ) + " + :primary-button-link="documentationLink" + :primary-button-text="__('Open Documentation')" + /> + + <div v-else-if="shouldRenderSuccessState" class="js-success-state"> + <release-block + v-for="(release, index) in releases" + :key="release.tag_name" + :release="release" + :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" + /> + </div> + </div> +</template> +<style> +.linked-card::after { + width: 1px; + content: ' '; + border: 1px solid #e5e5e5; + height: 17px; + top: 100%; + position: absolute; + left: 32px; +} +</style> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue new file mode 100644 index 00000000000..34b97826cdb --- /dev/null +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -0,0 +1,129 @@ +<script> +import _ from 'underscore'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { sprintf } from '../../locale'; + +export default { + name: 'ReleaseBlock', + components: { + GlLink, + Icon, + UserAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + release: { + type: Object, + required: true, + default: () => ({}), + }, + }, + computed: { + releasedTimeAgo() { + return sprintf('released %{time}', { + time: this.timeFormated(this.release.created_at), + }); + }, + userImageAltDescription() { + return this.author && this.author.username + ? sprintf("%{username}'s avatar", { username: this.author.username }) + : null; + }, + commit() { + return this.release.commit || {}; + }, + assets() { + return this.release.assets || {}; + }, + author() { + return this.release.author || {}; + }, + hasAuthor() { + return _.isEmpty(this.author); + }, + }, +}; +</script> +<template> + <div class="card"> + <div class="card-body"> + <h2 class="card-title mt-0">{{ release.name }}</h2> + + <div class="card-subtitle d-flex flex-wrap text-secondary"> + <div class="append-right-8"> + <icon name="commit" class="align-middle" /> + <span v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span> + </div> + + <div class="append-right-8"> + <icon name="tag" class="align-middle" /> + <span v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span> + </div> + + <div class="append-right-4"> + • + <span v-gl-tooltip.bottom :title="tooltipTitle(release.created_at)">{{ + releasedTimeAgo + }}</span> + </div> + + <div v-if="hasAuthor" class="d-flex"> + by + <user-avatar-link + class="prepend-left-4" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="author.username" + /> + </div> + </div> + + <div + v-if="assets.links.length || assets.sources.length" + Sclass="card-text prepend-top-default" + > + <b> + {{ __('Assets') }} + <span class="js-assets-count badge badge-pill">{{ assets.count }}</span> + </b> + + <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"> + <li v-for="link in assets.links" :key="link.name" class="append-bottom-8"> + <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url"> + <icon name="package" class="align-middle append-right-4 align-text-bottom" /> + {{ link.name }} + </gl-link> + </li> + </ul> + + <div v-if="assets.sources.length" class="dropdown"> + <button + type="button" + class="btn btn-link" + data-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false" + > + <icon name="doc-code" class="align-top append-right-4" /> {{ __('Source code') }} + <icon name="arrow-down" /> + </button> + + <div class="js-sources-dropdown dropdown-menu"> + <li v-for="asset in assets.sources" :key="asset.url"> + <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link> + </li> + </div> + </div> + </div> + + <div class="card-text prepend-top-default"><div v-html="release.description_html"></div></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/index.js b/app/assets/javascripts/releases/index.js new file mode 100644 index 00000000000..adbed3cb8e2 --- /dev/null +++ b/app/assets/javascripts/releases/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import App from './components/app.vue'; +import createStore from './store'; + +export default () => { + const element = document.getElementById('js-releases-page'); + + return new Vue({ + el: element, + store: createStore(), + components: { + App, + }, + render(createElement) { + return createElement('app', { + props: { + projectId: element.dataset.projectId, + documentationLink: element.dataset.documentationPath, + illustrationPath: element.dataset.illustrationPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/store/actions.js new file mode 100644 index 00000000000..baa2251403e --- /dev/null +++ b/app/assets/javascripts/releases/store/actions.js @@ -0,0 +1,37 @@ +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import api from '~/api'; + +/** + * Commits a mutation to update the state while the main endpoint is being requested. + */ +export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES); + +/** + * Fetches the main endpoint. + * Will dispatch requestNamespace action before starting the request. + * Will dispatch receiveNamespaceSuccess if the request is successfull + * Will dispatch receiveNamesapceError if the request returns an error + * + * @param {String} projectId + */ +export const fetchReleases = ({ dispatch }, projectId) => { + dispatch('requestReleases'); + + api + .releases(projectId) + .then(({ data }) => dispatch('receiveReleasesSuccess', data)) + .catch(() => dispatch('receiveReleasesError')); +}; + +export const receiveReleasesSuccess = ({ commit }, data) => + commit(types.RECEIVE_RELEASES_SUCCESS, data); + +export const receiveReleasesError = ({ commit }) => { + commit(types.RECEIVE_RELEASES_ERROR); + createFlash(__('An error occured while fetching the releases. Please try again.')); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/releases/store/index.js b/app/assets/javascripts/releases/store/index.js new file mode 100644 index 00000000000..968b94f0e0d --- /dev/null +++ b/app/assets/javascripts/releases/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + mutations, + state: state(), + }); diff --git a/app/assets/javascripts/releases/store/mutation_types.js b/app/assets/javascripts/releases/store/mutation_types.js new file mode 100644 index 00000000000..a74bf15c515 --- /dev/null +++ b/app/assets/javascripts/releases/store/mutation_types.js @@ -0,0 +1,3 @@ +export const REQUEST_RELEASES = 'REQUEST_RELEASES'; +export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS'; +export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR'; diff --git a/app/assets/javascripts/releases/store/mutations.js b/app/assets/javascripts/releases/store/mutations.js new file mode 100644 index 00000000000..b97dc6cb0ab --- /dev/null +++ b/app/assets/javascripts/releases/store/mutations.js @@ -0,0 +1,37 @@ +import * as types from './mutation_types'; + +export default { + /** + * Sets isLoading to true while the request is being made. + * @param {Object} state + */ + [types.REQUEST_RELEASES](state) { + state.isLoading = true; + }, + + /** + * Sets isLoading to false. + * Sets hasError to false. + * Sets the received data + * @param {Object} state + * @param {Object} data + */ + [types.RECEIVE_RELEASES_SUCCESS](state, data) { + state.hasError = false; + state.isLoading = false; + state.releases = data; + }, + + /** + * Sets isLoading to false. + * Sets hasError to true. + * Resets the data + * @param {Object} state + * @param {Object} data + */ + [types.RECEIVE_RELEASES_ERROR](state) { + state.isLoading = false; + state.releases = []; + state.hasError = true; + }, +}; diff --git a/app/assets/javascripts/releases/store/state.js b/app/assets/javascripts/releases/store/state.js new file mode 100644 index 00000000000..bf25e651c99 --- /dev/null +++ b/app/assets/javascripts/releases/store/state.js @@ -0,0 +1,5 @@ +export default () => ({ + isLoading: false, + hasError: false, + releases: [], +}); diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 225e21ad322..9a0cdc02952 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -79,11 +79,12 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { Sidebar.prototype.toggleTodo = function(e) { var $btnText, $this, $todoLoading, ajaxType, url; $this = $(e.currentTarget); - ajaxType = $this.attr('data-delete-path') ? 'delete' : 'post'; - if ($this.attr('data-delete-path')) { - url = '' + $this.attr('data-delete-path'); + ajaxType = $this.data('deletePath') ? 'delete' : 'post'; + + if ($this.data('deletePath')) { + url = '' + $this.data('deletePath'); } else { - url = '' + $this.data('url'); + url = '' + $this.data('createPath'); } $this.tooltip('hide'); @@ -119,14 +120,14 @@ Sidebar.prototype.todoUpdateDone = function(data) { .removeClass('is-loading') .enable() .attr('aria-label', $el.data(`${attrPrefix}Text`)) - .attr('data-delete-path', deletePath) - .attr('title', $el.data(`${attrPrefix}Text`)); + .attr('title', $el.data(`${attrPrefix}Text`)) + .data('deletePath', deletePath); if ($el.hasClass('has-tooltip')) { $el.tooltip('_fixTitle'); } - if ($el.data(`${attrPrefix}Icon`)) { + if (typeof $el.data('isCollapsed') !== 'undefined') { $elText.html($el.data(`${attrPrefix}Icon`)); } else { $elText.text($el.data(`${attrPrefix}Text`)); diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 7874a7b6b6a..349e14670b1 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -81,7 +81,7 @@ export default { </p> <ul> <li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li> - <li>Your <code>gitlab-ci.yml</code> file is not properly configured.</li> + <li>Your <code>.gitlab-ci.yml</code> file is not properly configured.</li> <li> The functions listed in the <code>serverless.yml</code> file don't match the namespace of your cluster. diff --git a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js b/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js index 14a89ef9293..3a8631a196f 100644 --- a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js +++ b/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js @@ -12,9 +12,8 @@ class EmojiMenuInModal extends AwardsHandler { this.bindEvents(); } - postEmoji($emojiButton, awardUrl, selectedEmoji, callback) { + postEmoji($emojiButton, awardUrl, selectedEmoji) { this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji)); - callback(); } } diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index f20cc6d8cca..7b8b4c5d856 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -71,7 +71,7 @@ export default class SidebarStore { } findAssignee(findAssignee) { - return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + return this.assignees.find(assignee => assignee.id === findAssignee.id); } removeAssignee(removeAssignee) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index adfbcd18588..0bcccc50eb2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -72,7 +72,7 @@ export default { Flash('Something went wrong. Please try again.'); } - eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('MRWidgetRebaseSuccess'); stopPolling(); } }) diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 3c3e3efcc36..b7f12076958 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -106,6 +106,9 @@ export default { (!this.mr.isNothingToMergeState && !this.mr.isMergedState) ); }, + shouldRenderCollaborationStatus() { + return this.mr.allowCollaboration && this.mr.isOpen; + }, shouldRenderMergedPipeline() { return this.mr.state === 'merged' && !_.isEmpty(this.mr.mergePipeline); }, @@ -155,13 +158,13 @@ export default { }; return new MRWidgetService(endpoints); }, - checkStatus(cb) { + checkStatus(cb, isRebased) { return this.service .checkStatus() .then(res => res.data) .then(data => { this.handleNotification(data); - this.mr.setData(data); + this.mr.setData(data, isRebased); this.setFaviconHelper(); if (cb) { @@ -263,6 +266,10 @@ export default { this.checkStatus(cb); }); + eventHub.$on('MRWidgetRebaseSuccess', cb => { + this.checkStatus(cb, true); + }); + // `params` should be an Array contains a Boolean, like `[true]` // Passing parameter as Boolean didn't work. eventHub.$on('SetBranchRemoveFlag', params => { @@ -311,7 +318,7 @@ export default { <div class="mr-widget-section"> <component :is="componentName" :mr="mr" :service="service" /> - <section v-if="mr.allowCollaboration" class="mr-info-list mr-links"> + <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links"> {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }} </section> diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index f7f0c1b6cb7..066a3b833d7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -19,7 +19,7 @@ export default function deviseState(data) { return stateKey.unresolvedDiscussions; } else if (this.isPipelineBlocked) { return stateKey.pipelineBlocked; - } else if (this.hasSHAChanged) { + } else if (this.isSHAMismatch) { return stateKey.shaMismatch; } else if (this.mergeWhenPipelineSucceeds) { return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 5c9a7133a6e..c777bcca0fa 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -11,7 +11,11 @@ export default class MergeRequestStore { this.setData(data); } - setData(data) { + setData(data, isRebased) { + if (isRebased) { + this.sha = data.diff_head_sha; + } + const currentUser = data.current_user; const pipelineStatus = data.pipeline ? data.pipeline.details.status : null; @@ -84,7 +88,7 @@ export default class MergeRequestStore { this.canMerge = !!data.merge_path; this.canCreateIssue = currentUser.can_create_issue || false; this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; - this.hasSHAChanged = this.sha !== data.diff_head_sha; + this.isSHAMismatch = this.sha !== data.diff_head_sha; this.canBeMerged = data.can_be_merged || false; this.isMergeAllowed = data.mergeable || false; this.mergeOngoing = data.merge_ongoing; diff --git a/app/assets/javascripts/vue_shared/components/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue index ddbb14ae812..56bafebf4ce 100644 --- a/app/assets/javascripts/vue_shared/components/callout.vue +++ b/app/assets/javascripts/vue_shared/components/callout.vue @@ -11,13 +11,14 @@ export default { }, message: { type: String, - required: true, + required: false, + default: '', }, }, }; </script> <template> <div :class="`bs-callout bs-callout-${category}`" role="alert" aria-live="assertive"> - {{ message }} + {{ message }} <slot></slot> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue new file mode 100644 index 00000000000..53210cbcc93 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue @@ -0,0 +1,3 @@ +<template> + <div class="nothing-here-block">{{ __('Empty file') }}</div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue new file mode 100644 index 00000000000..df6fadf10cd --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue @@ -0,0 +1,69 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlModal } from '@gitlab/ui'; + +/** + * This component keeps the GlModal's visibility in sync with the given vuex module. + */ +export default { + components: { + GlModal, + }, + props: { + modalId: { + type: String, + required: true, + }, + modalModule: { + type: String, + required: true, + }, + }, + computed: { + ...mapState({ + isVisible(state) { + return state[this.modalModule].isVisible; + }, + }), + attrs() { + const { modalId, modalModule, ...attrs } = this.$attrs; + + return attrs; + }, + }, + watch: { + isVisible(val) { + return val ? this.bsShow() : this.bsHide(); + }, + }, + methods: { + ...mapActions({ + syncShow(dispatch) { + return dispatch(`${this.modalModule}/show`); + }, + syncHide(dispatch) { + return dispatch(`${this.modalModule}/hide`); + }, + }), + bsShow() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + bsHide() { + // $root.$emit is a workaround because other b-modal approaches don't work yet with gl-modal + this.$root.$emit('bv::hide::modal', this.modalId); + }, + }, +}; +</script> + +<template> + <gl-modal + v-bind="attrs" + :modal-id="modalId" + v-on="$listeners" + @shown="syncShow" + @hidden="syncHide" + > + <slot></slot> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 43def2673eb..2f7ed4a982c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,17 +1,21 @@ <script> import $ from 'jquery'; +import _ from 'underscore'; import { __ } from '~/locale'; +import { stripHtml } from '~/lib/utils/text_utility'; import Flash from '../../../flash'; import GLForm from '../../../gl_form'; import markdownHeader from './header.vue'; import markdownToolbar from './toolbar.vue'; import icon from '../icon.vue'; +import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; export default { components: { markdownHeader, markdownToolbar, icon, + Suggestions, }, props: { markdownPreviewPath: { @@ -48,12 +52,33 @@ export default { required: false, default: true, }, + line: { + type: Object, + required: false, + default: null, + }, + note: { + type: Object, + required: false, + default: () => ({}), + }, + canSuggest: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { markdownPreview: '', referencedCommands: '', referencedUsers: '', + hasSuggestion: false, markdownPreviewLoading: false, previewMarkdown: false, }; @@ -63,6 +88,39 @@ export default { const referencedUsersThreshold = 10; return this.referencedUsers.length >= referencedUsersThreshold; }, + lineContent() { + const FIRST_CHAR_REGEX = /^(\+|-)/; + const [firstSuggestion] = this.suggestions; + if (firstSuggestion) { + return firstSuggestion.from_content; + } + + if (this.line) { + const { rich_text: richText, text } = this.line; + + if (text) { + return text.replace(FIRST_CHAR_REGEX, ''); + } + + return _.unescape(stripHtml(richText).replace(/\n/g, '')); + } + + return ''; + }, + lineNumber() { + let lineNumber; + if (this.line) { + const { new_line: newLine, old_line: oldLine } = this.line; + lineNumber = newLine || oldLine; + } + return lineNumber; + }, + suggestions() { + return this.note.suggestions || []; + }, + lineType() { + return this.line ? this.line.type : ''; + }, }, mounted() { /* @@ -122,6 +180,7 @@ export default { if (data.references) { this.referencedCommands = data.references.commands; this.referencedUsers = data.references.users; + this.hasSuggestion = data.references.suggestions && data.references.suggestions.length; } this.$nextTick(() => { @@ -147,6 +206,8 @@ export default { > <markdown-header :preview-markdown="previewMarkdown" + :line-content="lineContent" + :can-suggest="canSuggest" @preview-markdown="showPreviewTab" @write-markdown="showWriteTab" /> @@ -163,19 +224,39 @@ export default { /> </div> </div> - <div - v-show="previewMarkdown" - ref="markdown-preview" - class="md-preview js-vue-md-preview md md-preview-holder" - v-html="markdownPreview" - ></div> + <template v-if="hasSuggestion"> + <div + v-show="previewMarkdown" + ref="markdown-preview" + class="md-preview js-vue-md-preview md md-preview-holder" + > + <suggestions + v-if="hasSuggestion" + :note-html="markdownPreview" + :from-line="lineNumber" + :from-content="lineContent" + :line-type="lineType" + :disabled="true" + :suggestions="suggestions" + :help-page-path="helpPagePath" + /> + </div> + </template> + <template v-else> + <div + v-show="previewMarkdown" + ref="markdown-preview" + class="md-preview js-vue-md-preview md md-preview-holder" + v-html="markdownPreview" + ></div> + </template> <template v-if="previewMarkdown && !markdownPreviewLoading"> <div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div> <div v-if="shouldShowReferencedUsers" class="referenced-users"> <span> - <i class="fa fa-exclamation-triangle" aria-hidden="true"> </i> You are about to add + <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> You are about to add <strong> - <span class="js-referenced-users-count"> {{ referencedUsers.length }} </span> + <span class="js-referenced-users-count">{{ referencedUsers.length }}</span> </strong> people to the discussion. Proceed with caution. </span> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 4c4ba537065..bf4d42670ee 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -17,6 +17,16 @@ export default { type: Boolean, required: true, }, + lineContent: { + type: String, + required: false, + default: '', + }, + canSuggest: { + type: Boolean, + required: false, + default: true, + }, }, computed: { mdTable() { @@ -27,6 +37,9 @@ export default { '| cell | cell |', ].join('\n'); }, + mdSuggestion() { + return ['```suggestion', `{text}`, '```'].join('\n'); + }, }, mounted() { $(document).on('markdown-preview:show.vue', this.previewMarkdownTab); @@ -119,6 +132,16 @@ export default { :button-title="__('Add a table')" icon="table" /> + <toolbar-button + v-if="canSuggest" + :tag="mdSuggestion" + :prepend="true" + :button-title="__('Insert suggestion')" + :cursor-offset="4" + :tag-content="lineContent" + icon="doc-code" + class="qa-suggestion-btn" + /> <button v-gl-tooltip aria-label="Go full screen" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue new file mode 100644 index 00000000000..f98560f7336 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -0,0 +1,74 @@ +<script> +import SuggestionDiffHeader from './suggestion_diff_header.vue'; + +export default { + components: { + SuggestionDiffHeader, + }, + props: { + newLines: { + type: Array, + required: true, + }, + fromContent: { + type: String, + required: false, + default: '', + }, + fromLine: { + type: Number, + required: true, + }, + suggestion: { + type: Object, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + methods: { + applySuggestion(callback) { + this.$emit('apply', { suggestionId: this.suggestion.id, callback }); + }, + }, +}; +</script> + +<template> + <div> + <suggestion-diff-header + class="qa-suggestion-diff-header" + :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" + :is-applied="suggestion.applied" + :help-page-path="helpPagePath" + @apply="applySuggestion" + /> + <table class="mb-3 md-suggestion-diff"> + <tbody> + <!-- Old Line --> + <tr class="line_holder old"> + <td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td> + <td class="diff-line-num new_line old"></td> + <td class="line_content old"> + <span>{{ fromContent }}</span> + </td> + </tr> + <!-- New Line(s) --> + <tr v-for="(line, key) of newLines" :key="key" class="line_holder new"> + <td class="diff-line-num old_line new"></td> + <td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td> + <td class="line_content new"> + <span>{{ line.content }}</span> + </td> + </tr> + </tbody> + </table> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue new file mode 100644 index 00000000000..563e2f94fcc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -0,0 +1,60 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { Icon }, + props: { + canApply: { + type: Boolean, + required: false, + default: false, + }, + isApplied: { + type: Boolean, + required: true, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + isAppliedSuccessfully: false, + isApplying: false, + }; + }, + methods: { + applySuggestion() { + if (!this.canApply) return; + this.isApplying = true; + this.$emit('apply', this.applySuggestionCallback); + }, + applySuggestionCallback() { + this.isApplying = false; + }, + }, +}; +</script> + +<template> + <div class="md-suggestion-header border-bottom-0 mt-2"> + <div class="qa-suggestion-diff-header font-weight-bold"> + {{ __('Suggested change') }} + <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')"> + <icon name="question-o" css-classes="link-highlight" /> + </a> + </div> + <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span> + <button + v-if="canApply" + type="button" + class="btn qa-apply-btn" + :disabled="isApplying" + @click="applySuggestion" + > + {{ __('Apply suggestion') }} + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue new file mode 100644 index 00000000000..7c6dbee3e19 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -0,0 +1,136 @@ +<script> +import Vue from 'vue'; +import SuggestionDiff from './suggestion_diff.vue'; +import Flash from '~/flash'; + +export default { + components: { SuggestionDiff }, + props: { + fromLine: { + type: Number, + required: false, + default: 0, + }, + fromContent: { + type: String, + required: false, + default: '', + }, + lineType: { + type: String, + required: false, + default: '', + }, + suggestions: { + type: Array, + required: false, + default: () => [], + }, + noteHtml: { + type: String, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + isRendered: false, + }; + }, + watch: { + suggestions() { + this.reset(); + }, + noteHtml() { + this.reset(); + }, + }, + mounted() { + this.renderSuggestions(); + }, + methods: { + renderSuggestions() { + // swaps out suggestion(s) markdown with rich diff components + // (while still keeping non-suggestion markdown in place) + + if (!this.noteHtml) return; + const { container } = this.$refs; + const suggestionElements = container.querySelectorAll('.js-render-suggestion'); + + if (this.lineType === 'old') { + Flash('Unable to apply suggestions to a deleted line.', 'alert', this.$el); + } + + suggestionElements.forEach((suggestionEl, i) => { + const suggestionParentEl = suggestionEl.parentElement; + const newLines = this.extractNewLines(suggestionParentEl); + const diffComponent = this.generateDiff(newLines, i); + diffComponent.$mount(suggestionParentEl); + }); + + this.isRendered = true; + }, + extractNewLines(suggestionEl) { + // extracts the suggested lines from the markdown + // calculates a line number for each line + + const FIRST_CHAR_REGEX = /^(\+|-)/; + const newLines = suggestionEl.querySelectorAll('.line'); + const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine; + const lines = []; + + newLines.forEach((line, i) => { + const content = `${line.innerText.replace(FIRST_CHAR_REGEX, '')}\n`; + const lineNumber = fromLine + i; + lines.push({ content, lineNumber }); + }); + + return lines; + }, + generateDiff(newLines, suggestionIndex) { + // generates the diff <suggestion-diff /> component + // all `suggestion` markdown will be swapped out by this component + + const { suggestions, disabled, helpPagePath } = this; + const suggestion = + suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; + const fromContent = suggestion.from_content || this.fromContent; + const fromLine = suggestion.from_line || this.fromLine; + const SuggestionDiffComponent = Vue.extend(SuggestionDiff); + const suggestionDiff = new SuggestionDiffComponent({ + propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath }, + }); + + suggestionDiff.$on('apply', ({ suggestionId, callback }) => { + this.$emit('apply', { suggestionId, callback, flashContainer: this.$el }); + }); + + return suggestionDiff; + }, + reset() { + // resets the container HTML (replaces it with the updated noteHTML) + // calls `renderSuggestions` once the updated noteHTML is added to the DOM + + this.$refs.container.innerHTML = this.noteHtml; + this.isRendered = false; + this.renderSuggestions(); + this.$nextTick(() => this.renderSuggestions()); + }, + }, +}; +</script> + +<template> + <div> + <div class="flash-container mt-3"></div> + <div v-show="isRendered" ref="container" v-html="noteHtml"></div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index a6d2cecdf7e..4572caa907b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -37,6 +37,16 @@ export default { required: false, default: false, }, + tagContent: { + type: String, + required: false, + default: '', + }, + cursorOffset: { + type: Number, + required: false, + default: 0, + }, }, }; </script> @@ -45,8 +55,10 @@ export default { <button v-gl-tooltip :data-md-tag="tag" + :data-md-cursor-offset="cursorOffset" :data-md-select="tagSelect" :data-md-block="tagBlock" + :data-md-tag-content="tagContent" :data-md-prepend="prepend" :title="buttonTitle" :aria-label="buttonTitle" diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index e833a8e0483..95f4395ac13 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -67,6 +67,7 @@ export default { // In both cases we should render the defaultAvatarUrl sanitizedSource() { let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + // Only adds the width to the URL if its not a base64 data image if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`; return baseSrc; }, diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 7fbadcc0111..d24fe1b547e 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -1,6 +1,5 @@ <script> import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; @@ -28,19 +27,6 @@ export default { }, }, computed: { - jobLine() { - if (this.user.bio && this.user.organization) { - return sprintf(__('%{bio} at %{organization}'), { - bio: this.user.bio, - organization: this.user.organization, - }); - } else if (this.user.bio) { - return this.user.bio; - } else if (this.user.organization) { - return this.user.organization; - } - return null; - }, statusHtml() { if (this.user.status.emoji && this.user.status.message) { return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`; @@ -82,7 +68,8 @@ export default { <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> </div> <div class="text-secondary"> - {{ jobLine }} + <div v-if="user.bio" class="js-bio">{{ user.bio }}</div> + <div v-if="user.organization" class="js-organization">{{ user.organization }}</div> <gl-skeleton-loading v-if="jobInfoIsLoading" :lines="1" diff --git a/app/assets/javascripts/vuex_shared/modules/modal/actions.js b/app/assets/javascripts/vuex_shared/modules/modal/actions.js new file mode 100644 index 00000000000..552237e05c5 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/actions.js @@ -0,0 +1,17 @@ +import * as types from './mutation_types'; + +export const open = ({ commit }, data) => { + commit(types.OPEN, data); +}; + +export const close = ({ commit }) => { + commit(types.CLOSE); +}; + +export const show = ({ commit }) => { + commit(types.SHOW); +}; + +export const hide = ({ commit }) => { + commit(types.HIDE); +}; diff --git a/app/assets/javascripts/vuex_shared/modules/modal/index.js b/app/assets/javascripts/vuex_shared/modules/modal/index.js new file mode 100644 index 00000000000..c349d875c24 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; + +export default () => ({ + namespaced: true, + state: state(), + mutations, + actions, +}); diff --git a/app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js b/app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js new file mode 100644 index 00000000000..f8259736009 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js @@ -0,0 +1,4 @@ +export const HIDE = 'HIDE'; +export const SHOW = 'SHOW'; +export const OPEN = 'OPEN'; +export const CLOSE = 'CLOSE'; diff --git a/app/assets/javascripts/vuex_shared/modules/modal/mutations.js b/app/assets/javascripts/vuex_shared/modules/modal/mutations.js new file mode 100644 index 00000000000..9e96ae8b5a9 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/mutations.js @@ -0,0 +1,18 @@ +import * as types from './mutation_types'; + +export default { + [types.SHOW](state) { + state.isVisible = true; + }, + [types.HIDE](state) { + state.isVisible = false; + }, + [types.OPEN](state, data) { + state.data = data; + state.isVisible = true; + }, + [types.CLOSE](state) { + state.data = null; + state.isVisible = false; + }, +}; diff --git a/app/assets/javascripts/vuex_shared/modules/modal/state.js b/app/assets/javascripts/vuex_shared/modules/modal/state.js new file mode 100644 index 00000000000..5d0955aa9b0 --- /dev/null +++ b/app/assets/javascripts/vuex_shared/modules/modal/state.js @@ -0,0 +1,4 @@ +export default () => ({ + isVisible: false, + data: null, +}); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 985fac11c87..bdf20866197 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -47,6 +47,7 @@ @import "highlight/solarized_dark"; @import "highlight/solarized_light"; @import "highlight/white"; +@import "highlight/none"; /* * Styles for JS behaviors. diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index f0671e36130..587127bb059 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -70,6 +70,17 @@ h6, margin-bottom: 10px; } +/* Our adjustments to hx & .hx above add unnecessary margins to modal-title + and page-title in modals, so we set them to 0 in order to have properly + formatted modal headers. */ +.modal-header { + .modal-title, + .page-title { + margin-top: 0; + margin-bottom: 0; + } +} + h5, .h5 { font-size: $gl-font-size; @@ -134,7 +145,8 @@ table { pointer-events: none; } -.popover { +.popover, +.popover-header { font-size: 14px; } @@ -142,7 +154,9 @@ table { @include media-breakpoint-up($breakpoint) { $infix: breakpoint-infix($breakpoint, $grid-breakpoints); - .d#{$infix}-table-header-group { display: table-header-group !important; } + .d#{$infix}-table-header-group { + display: table-header-group !important; + } } } diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 834e7ffce81..62d471bc30c 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -31,7 +31,6 @@ @import 'framework/logo'; @import 'framework/markdown_area'; @import 'framework/media_object'; -@import 'framework/mobile'; @import 'framework/modal'; @import 'framework/pagination'; @import 'framework/panels'; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 549a8730301..43d4044033f 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -260,3 +260,25 @@ $skeleton-line-widths: ( .slide-down-leave-to { transform: translateY(-30%); } + +@keyframes spin { + 0% { transform: rotate(0deg);} + 100% { transform: rotate(360deg);} +} + +/** COMMON ANIMATION CLASSES **/ +.transform-origin-center { @include webkit-prefix(transform-origin, 50% 50%); } +.animate-n-spin { @include webkit-prefix(animation-name, spin); } +.animate-c-infinite { @include webkit-prefix(animation-iteration-count, infinite); } +.animate-t-linear { @include webkit-prefix(animation-timing-function, linear); } +.animate-d-1 { @include webkit-prefix(animation-duration, 1s); } +.animate-d-2 { @include webkit-prefix(animation-duration, 2s); } + +/** COMPOSITE ANIMATION CLASSES **/ +.gl-spinner { + @include webkit-prefix(animation-name, spin); + @include webkit-prefix(animation-iteration-count, infinite); + @include webkit-prefix(animation-timing-function, linear); + @include webkit-prefix(animation-duration, 1s); + transform-origin: 50% 50%; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index b47b1cb76dc..afcb230797a 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -534,8 +534,9 @@ .dropdown-title { position: relative; - padding: 2px 25px 10px; - margin: 0 10px 10px; + padding: $dropdown-item-padding-y $dropdown-item-padding-x; + padding-bottom: #{2 * $dropdown-item-padding-y}; + margin-bottom: $dropdown-item-padding-y; font-weight: $gl-font-weight-bold; line-height: 1; text-align: center; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 3ac7b6b704b..037a5adfb7e 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -24,7 +24,7 @@ } } - &:not(.use-csslab) table { + table { @extend .table; } diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index afd888af672..4da2243981e 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -256,7 +256,12 @@ label { } } +.input-md { + max-width: $input-md-width; + width: 100%; +} + .input-lg { - max-width: 320px; + max-width: $input-lg-width; width: 100%; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 7d283dcfb71..5574873fa22 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -597,3 +597,11 @@ @include emoji-menu-toggle-button; } } + +.nav-links > li > a { + .badge.badge-pill { + @include media-breakpoint-down(xs) { display: none; } + } + + @include media-breakpoint-down(xs) { margin-right: 3px; } +} diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 73533571a2f..946f575ac13 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -42,7 +42,6 @@ padding: 10px; text-align: right; float: left; - line-height: 1; a { font-family: $monospace-font; @@ -69,3 +68,9 @@ } } } + +// Vertically aligns <table> line numbers (eg. blame view) +// see https://gitlab.com/gitlab-org/gitlab-ce/issues/54048 +td.line-numbers { + line-height: 1; +} diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index a66604e56ff..e51f230a680 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -45,9 +45,4 @@ &.status-box-upcoming { background: $gl-text-color-secondary; } - - &.status-box-milestone { - color: $gl-text-color; - background: $gray-darker; - } } diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 9218df9b40f..97cb9d90ff0 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -40,6 +40,14 @@ body { .content { margin: 0; + + @include media-breakpoint-down(xs) { margin-top: 20px; } + } + + @include media-breakpoint-down(xs) { + .container .title { + padding-left: 15px !important; + } } } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 2b110e23fb8..ce46d760d7b 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -173,7 +173,7 @@ svg { width: 14px; height: 14px; - margin-top: 3px; + vertical-align: middle; fill: $gl-text-color-secondary; } @@ -277,6 +277,27 @@ } } +.md-suggestion-diff { + display: table !important; + border: 1px solid $border-color !important; +} + +.md-suggestion-header { + height: $suggestion-header-height; + display: flex; + align-items: center; + justify-content: space-between; + background-color: $gray-light; + border: 1px solid $border-color; + padding: $gl-padding; + border-radius: $border-radius-default $border-radius-default 0 0; + + svg { + vertical-align: middle; + margin-bottom: 3px; + } +} + @include media-breakpoint-down(xs) { .atwho-view-ul { width: 350px; @@ -286,4 +307,8 @@ overflow: hidden; text-overflow: ellipsis; } + + .referenced-users { + margin-right: 0; + } } diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss deleted file mode 100644 index 3bb046d0e51..00000000000 --- a/app/assets/stylesheets/framework/mobile.scss +++ /dev/null @@ -1,88 +0,0 @@ -/** Common mobile (screen XS, SM) styles **/ -@include media-breakpoint-down(xs) { - .container .content { - margin-top: 20px; - } - - .nav-links > li > a { - padding: 10px; - font-size: 12px; - margin-right: 3px; - - .badge.badge-pill { - display: none; - } - } - - .referenced-users { - margin-right: 0; - } - - .issues-details-filters:not(.filtered-search-block), - .dash-projects-filters, - .check-all-holder { - display: none; - } - - .rss-btn { - display: none; - } - - .project-home-links { - display: none; - } - - .project-home-panel { - padding-left: 0 !important; - - .project-repo-buttons, - .git-clone-holder { - display: none; - } - } - - .group-buttons { - display: none; - } - - .container .title { - padding-left: 15px !important; - } - - .nav-links, - .nav-links { - li a { - font-size: 14px; - padding: 19px 10px; - } - } - - .activity-filter-block { - display: none; - } - - .projects-search-form { - .btn { - display: none; - } - } -} - -@include media-breakpoint-down(sm) { - .issues-filters { - .milestone-filter { - display: none; - } - } - - .page-title { - .note-created-ago, - .new-issue-link { - display: none; - } - } - - aside:not(.right-sidebar) { - display: none; - } -} diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 7e30747963a..46d40ea7aa5 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -25,14 +25,10 @@ &.w-100 { // after upgrading to Bootstrap 4.2 we can use $modal-header-padding-x here // https://github.com/twbs/bootstrap/pull/26976 - margin-right: -2rem; - padding-right: 2rem; + margin-right: -28px; + padding-right: 28px; } } - - .page-title { - margin-top: 0; - } } .modal-body { diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 7f0edd88dfb..a68f1e4e570 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -1,6 +1,11 @@ /** Select2 selectbox style override **/ .select2-container { width: 100% !important; + + &.input-md, + &.input-lg { + display: block; + } } .select2-container, diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index a92481b3ebb..d92d81b2cb5 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -32,6 +32,15 @@ $gray-dark: darken($gray-light, $darken-dark-factor); $gray-darker: #eee; $gray-darkest: #c4c4c4; +$black: #000; +$black-transparent: rgba(0, 0, 0, 0.3); +$almost-black: #242424; + +$t-gray-a-02: rgba($black, 0.02); +$t-gray-a-04: rgba($black, 0.04); +$t-gray-a-06: rgba($black, 0.06); +$t-gray-a-08: rgba($black, 0.08); + $gl-gray-100: #dddddd; $gl-gray-200: #cccccc; $gl-gray-350: #aaaaaa; @@ -170,11 +179,6 @@ $theme-light-red-500: #c24b38; $theme-light-red-600: #b03927; $theme-light-red-700: #a62e21; -$black: #000; -$black-transparent: rgba(0, 0, 0, 0.3); -$shadow-color: rgba($black, 0.1); -$almost-black: #242424; - $border-white-light: darken($white-light, $darken-border-factor); $border-white-normal: darken($white-normal, $darken-border-factor); @@ -187,6 +191,7 @@ $border-gray-dark: darken($white-normal, $darken-border-factor); * UI elements */ $border-color: #e5e5e5; +$shadow-color: $t-gray-a-08; $well-expand-item: #e8f2f7; $well-inner-border: #eef0f2; $well-light-border: #f1f1f1; @@ -198,7 +203,6 @@ $well-light-text-color: #5b6169; $gl-font-size: 14px; $gl-font-size-xs: 11px; $gl-font-size-small: 12px; -$gl-font-size-medium: 1.43rem; $gl-font-size-large: 16px; $gl-font-weight-normal: 400; $gl-font-weight-bold: 600; @@ -252,6 +256,7 @@ $browserScrollbarSize: 10px; * Misc */ $header-height: 40px; +$suggestion-header-height: 46px; $ide-statusbar-height: 25px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; @@ -508,6 +513,8 @@ $gl-field-focus-shadow: rgba(0, 0, 0, 0.075); $gl-field-focus-shadow-error: rgba($red-500, 0.6); $input-short-width: 200px; $input-short-md-width: 280px; +$input-md-width: 240px; +$input-lg-width: 320px; /* * Help diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 5ca76bb6c5a..069f45bff49 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -28,3 +28,9 @@ $popover-border-width: 1px; $popover-border-color: $border-color; $popover-box-shadow: 0 $border-radius-small $border-radius-default 0 $shadow-color; $popover-arrow-outer-color: $shadow-color; +$h1-font-size: 14px * 2.5; +$h2-font-size: 14px * 2; +$h3-font-size: 14px * 1.75; +$h4-font-size: 14px * 1.5; +$h5-font-size: 14px * 1.25; +$h6-font-size: 14px; diff --git a/app/assets/stylesheets/highlight/none.scss b/app/assets/stylesheets/highlight/none.scss new file mode 100644 index 00000000000..7d692a87e33 --- /dev/null +++ b/app/assets/stylesheets/highlight/none.scss @@ -0,0 +1,242 @@ +/* +* None Syntax Colors +*/ + + + +@mixin matchLine { + color: $black-transparent; + background-color: $white-normal; +} + +.code.none { + // Line numbers + .line-numbers, + .diff-line-num { + background-color: $gray-light; + } + + .diff-line-num, + .diff-line-num a { + color: $black-transparent; + } + + // Code itself + pre.code, + .diff-line-num { + border-color: $white-normal; + } + + &, + pre.code, + .line_holder .line_content { + background-color: $white-light; + color: $gl-text-color; + } + +// Diff line + + $none-over-bg: #ded7fc; + $none-expanded-border: #e0e0e0; + $none-expanded-bg: #f7f7f7; + + .line_holder { + + &.match .line_content, + .new-nonewline.line_content, + .old-nonewline.line_content { + @include matchLine; + } + + .diff-line-num { + &.old { + background-color: $line-number-old; + border-color: $line-removed-dark; + + a { + color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); + } + } + + &.new { + background-color: $line-number-new; + border-color: $line-added-dark; + + a { + color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); + } + } + + &.is-over, + &.hll:not(.empty-cell).is-over { + background-color: $none-over-bg; + border-color: darken($none-over-bg, 5%); + + a { + color: darken($none-over-bg, 15%); + } + } + + &.hll:not(.empty-cell) { + background-color: $line-number-select; + border-color: $line-select-yellow-dark; + } + } + + &:not(.diff-expanded) + .diff-expanded, + &.diff-expanded + .line_holder:not(.diff-expanded) { + > .diff-line-num, + > .line_content { + border-top: 1px solid $none-expanded-border; + } + } + + &.diff-expanded { + > .diff-line-num, + > .line_content { + background: $none-expanded-bg; + border-color: $none-expanded-bg; + } + } + + .line_content { + &.old { + background-color: $line-removed; + + &::before { + color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%); + } + + span.idiff { + background-color: $line-removed-dark; + } + } + + &.new { + background-color: $line-added; + + &::before { + color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%); + } + + span.idiff { + background-color: $line-added-dark; + } + } + + &.match { + @include matchLine; + } + + &.hll:not(.empty-cell) { + background-color: $line-select-yellow; + } + } + } + + // highlight line via anchor + pre .hll { + background-color: $white-normal; + } + + // Search result highlight + span.highlight_word { + background-color: $white-normal; + } + + // Links to URLs, emails, or dependencies + .line a { + color: $gl-text-color; + text-decoration: underline; + } + + .hll { background-color: $white-light; } + + .gd { + color: $gl-text-color; + background-color: $white-light; + + .x { + color: $gl-text-color; + background-color: $white-light; + } + } + + .gi { + color: $gl-text-color; + background-color: $white-light; + + .x { + color: $gl-text-color; + background-color: $white-light; + } + } + + .c { color: $gl-text-color; } /* Comment */ + .err { color: $gl-text-color; } /* Error */ + .g { color: $gl-text-color; } /* Generic */ + .k { color: $gl-text-color; } /* Keyword */ + .l { color: $gl-text-color; } /* Literal */ + .n { color: $gl-text-color; } /* Name */ + .o { color: $gl-text-color; } /* Operator */ + .x { color: $gl-text-color; } /* Other */ + .p { color: $gl-text-color; } /* Punctuation */ + .cm { color: $gl-text-color; } /* Comment.Multiline */ + .cp { color: $gl-text-color; } /* Comment.Preproc */ + .c1 { color: $gl-text-color; } /* Comment.Single */ + .cs { color: $gl-text-color; } /* Comment.Special */ + .ge { color: $gl-text-color; } /* Generic.Emph */ + .gr { color: $gl-text-color; } /* Generic.Error */ + .gh { color: $gl-text-color; } /* Generic.Heading */ + .go { color: $gl-text-color; } /* Generic.Output */ + .gp { color: $gl-text-color; } /* Generic.Prompt */ + .gs { color: $gl-text-color; } /* Generic.Strong */ + .gu { color: $gl-text-color; } /* Generic.Subheading */ + .gt { color: $gl-text-color; } /* Generic.Traceback */ + .kc { color: $gl-text-color; } /* Keyword.Constant */ + .kd { color: $gl-text-color; } /* Keyword.Declaration */ + .kn { color: $gl-text-color; } /* Keyword.Namespace */ + .kp { color: $gl-text-color; } /* Keyword.Pseudo */ + .kr { color: $gl-text-color; } /* Keyword.Reserved */ + .kt { color: $gl-text-color; } /* Keyword.Type */ + .ld { color: $gl-text-color; } /* Literal.Date */ + .m { color: $gl-text-color; } /* Literal.Number */ + .s { color: $gl-text-color; } /* Literal.String */ + .na { color: $gl-text-color; } /* Name.Attribute */ + .nb { color: $gl-text-color; } /* Name.Builtin */ + .nc { color: $gl-text-color; } /* Name.Class */ + .no { color: $gl-text-color; } /* Name.Constant */ + .nd { color: $gl-text-color; } /* Name.Decorator */ + .ni { color: $gl-text-color; } /* Name.Entity */ + .ne { color: $gl-text-color; } /* Name.Exception */ + .nf { color: $gl-text-color; } /* Name.Function */ + .nl { color: $gl-text-color; } /* Name.Label */ + .nn { color: $gl-text-color; } /* Name.Namespace */ + .nx { color: $gl-text-color; } /* Name.Other */ + .py { color: $gl-text-color; } /* Name.Property */ + .nt { color: $gl-text-color; } /* Name.Tag */ + .nv { color: $gl-text-color; } /* Name.Variable */ + .ow { color: $gl-text-color; } /* Operator.Word */ + .w { color: $gl-text-color; } /* Text.Whitespace */ + .mf { color: $gl-text-color; } /* Literal.Number.Float */ + .mh { color: $gl-text-color; } /* Literal.Number.Hex */ + .mi { color: $gl-text-color; } /* Literal.Number.Integer */ + .mo { color: $gl-text-color; } /* Literal.Number.Oct */ + .sb { color: $gl-text-color; } /* Literal.String.Backtick */ + .sc { color: $gl-text-color; } /* Literal.String.Char */ + .sd { color: $gl-text-color; } /* Literal.String.Doc */ + .s2 { color: $gl-text-color; } /* Literal.String.Double */ + .se { color: $gl-text-color; } /* Literal.String.Escape */ + .sh { color: $gl-text-color; } /* Literal.String.Heredoc */ + .si { color: $gl-text-color; } /* Literal.String.Interpol */ + .sx { color: $gl-text-color; } /* Literal.String.Other */ + .sr { color: $gl-text-color; } /* Literal.String.Regex */ + .s1 { color: $gl-text-color; } /* Literal.String.Single */ + .ss { color: $gl-text-color; } /* Literal.String.Symbol */ + .bp { color: $gl-text-color; } /* Name.Builtin.Pseudo */ + .vc { color: $gl-text-color; } /* Name.Variable.Class */ + .vg { color: $gl-text-color; } /* Name.Variable.Global */ + .vi { color: $gl-text-color; } /* Name.Variable.Instance */ + .il { color: $gl-text-color; } /* Literal.Number.Integer.Long */ + +} diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 57918eafd6f..09235661cea 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -135,6 +135,7 @@ .build-loader-animation { @include build-loader-animation; float: left; + padding-left: $gl-padding-8; } } @@ -232,6 +233,11 @@ @extend .d-flex; justify-content: space-between; align-items: center; + + .trigger-variables-btn { + margin-top: -5px; + margin-bottom: -5px; + } } .trigger-build-variables { diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index f46ff360496..5a988b184b6 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -128,6 +128,10 @@ width: 100%; } } + + @media(max-width: map-get($grid-breakpoints, md)-1) { + clear: both; + } } .editor-ref { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 5b5f486ea63..a1069aa9783 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -60,6 +60,10 @@ padding: 0; margin-bottom: $gl-padding; border-bottom: 0; + word-wrap: break-word; + overflow-wrap: break-word; + min-width: 0; + width: 100%; } .btn-edit { diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss index d26659701e1..e0f7d075fc7 100644 --- a/app/assets/stylesheets/pages/merge_conflicts.scss +++ b/app/assets/stylesheets/pages/merge_conflicts.scss @@ -93,8 +93,28 @@ $colors: ( solarized-dark-line-origin-chosen : rgba(#2878c9, .35), solarized-dark-button-origin-chosen : #0082cc, - solarized-dark-header-not-chosen : rgba(#839496, .25), - solarized-dark-line-not-chosen : rgba(#839496, .15) + solarized_dark_header_not_chosen : rgba(#839496, .25), + solarized_dark_line_not_chosen : rgba(#839496, .15), + + none_header_head_neutral : $gray-normal, + none_line_head_neutral : $gray-normal, + none_button_head_neutral : $gray-normal, + + none_header_head_chosen : $gray-darker, + none_line_head_chosen : $gray-darker, + none_button_head_chosen : $gray-darker, + + none_header_origin_neutral : $gray-normal, + none_line_origin_neutral : $gray-normal, + none_button_origin_neutral : $gray-normal, + + none_header_origin_chosen : $gray-darker, + none_line_origin_chosen : $gray-darker, + none_button_origin_chosen : $gray-darker, + + none_header_not_chosen : $gray-light, + none_line_not_chosen : $gray-light + ); // scss-lint:enable ColorVariable diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index 1e92582d6d9..94bf32945fc 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -1,3 +1,5 @@ +$status-box-line-height: 26px; + .issues-sortable-list .str-truncated { max-width: 90%; } @@ -38,6 +40,7 @@ font-size: $tooltip-font-size; margin-top: 0; margin-right: $gl-padding-4; + line-height: $status-box-line-height; @include media-breakpoint-down(xs) { line-height: unset; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 2adfa0d312e..a5b1eff3e1d 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -152,6 +152,16 @@ $note-form-margin-left: 72px; display: block; position: relative; + .timeline-discussion-body { + margin-top: -8px; + overflow-x: auto; + overflow-y: hidden; + + .discussion-resolved-text { + margin-bottom: 8px; + } + } + .diff-content { overflow: visible; padding: 0; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index fdd17af35fb..7a47e0a2836 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -978,7 +978,6 @@ button.mini-pipeline-graph-dropdown-toggle { * Top arrow in the dropdown in the mini pipeline graph */ .mini-pipeline-graph-dropdown-menu { - z-index: 200; &::before, &::after { diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index a4831b64344..b813eb16dad 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -456,4 +456,15 @@ table.u2f-registrations { } } } + + @include media-breakpoint-down(sm) { + .input-md, + .input-lg { + max-width: 100%; + } + } +} + +.help-block { + color: $gl-text-color-secondary; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 0ce0db038a7..004c49dd226 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -973,7 +973,7 @@ pre.light-well { padding: $gl-padding 0; @include media-breakpoint-up(lg) { - padding: $gl-padding-24 0; + padding: $gl-padding 0; } &.no-description { @@ -990,7 +990,7 @@ pre.light-well { } h2 { - font-size: $gl-font-size-medium; + font-size: $gl-font-size-large; font-weight: $gl-font-weight-bold; margin-bottom: 0; @@ -1049,7 +1049,7 @@ pre.light-well { } .controls { - margin-top: $gl-padding; + margin-top: $gl-padding-8; @include media-breakpoint-down(md) { margin-top: 0; diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 8f683ca06ad..8f267eccc8a 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -77,7 +77,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController def reset_health_check_token @application_setting.reset_health_check_access_token! flash[:notice] = 'New health check access token has been generated!' - redirect_to :back + redirect_back_or_default end def clear_repository_check_states diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb index 25cc241e5b0..7cd80e8b5e1 100644 --- a/app/controllers/admin/health_check_controller.rb +++ b/app/controllers/admin/health_check_controller.rb @@ -2,6 +2,12 @@ class Admin::HealthCheckController < Admin::ApplicationController def show - @errors = HealthCheck::Utils.process_checks(['standard']) + @errors = HealthCheck::Utils.process_checks(checks) + end + + private + + def checks + ['standard'] end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7c8c1392c1c..a8fc848c879 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,9 +12,6 @@ class ApplicationController < ActionController::Base include EnforcesTwoFactorAuthentication include WithPerformanceBar include SessionlessAuthentication - # this can be removed after switching to rails 5 - # https://gitlab.com/gitlab-org/gitlab-ce/issues/51908 - include InvalidUTF8ErrorHandler unless Gitlab.rails5? before_action :authenticate_user! before_action :enforce_terms!, if: :should_enforce_terms? @@ -79,7 +76,7 @@ class ApplicationController < ActionController::Base end def redirect_back_or_default(default: root_path, options: {}) - redirect_to request.referer.present? ? :back : default, options + redirect_back(fallback_location: default, **options) end def not_found @@ -157,7 +154,7 @@ class ApplicationController < ActionController::Base def log_exception(exception) Gitlab::Sentry.track_acceptable_exception(exception) - backtrace_cleaner = Gitlab.rails5? ? request.env["action_dispatch.backtrace_cleaner"] : env + backtrace_cleaner = request.env["action_dispatch.backtrace_cleaner"] application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace application_trace.map! { |t| " #{t}\n" } logger.error "\n#{exception.class.name} (#{exception.message}):\n#{application_trace.join}" @@ -406,7 +403,7 @@ class ApplicationController < ActionController::Base end def manifest_import_enabled? - Group.supports_nested_groups? && Gitlab::CurrentSettings.import_sources.include?('manifest') + Group.supports_nested_objects? && Gitlab::CurrentSettings.import_sources.include?('manifest') end # U2F (universal 2nd factor) devices need a unique identifier for the application diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 9aa8b758539..b9717b97640 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -18,8 +18,20 @@ class Clusters::ClustersController < Clusters::BaseController STATUS_POLLING_INTERVAL = 10_000 def index - clusters = ClustersFinder.new(clusterable, current_user, :all).execute - @clusters = clusters.page(params[:page]).per(20) + finder = ClusterAncestorsFinder.new(clusterable.subject, current_user) + clusters = finder.execute + + # Note: We are paginating through an array here but this should OK as: + # + # In CE, we can have a maximum group nesting depth of 21, so including + # project cluster, we can have max 22 clusters for a group hierachy. + # In EE (Premium) we can have any number, as multiple clusters are + # supported, but the number of clusters are fairly low currently. + # + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/55260 also. + @clusters = Kaminari.paginate_array(clusters).page(params[:page]).per(20) + + @has_ancestor_clusters = finder.has_ancestor_clusters? end def new diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb index 4f56346832c..e9a7d6a3152 100644 --- a/app/controllers/concerns/group_tree.rb +++ b/app/controllers/concerns/group_tree.rb @@ -32,14 +32,14 @@ module GroupTree def filtered_groups_with_ancestors(groups) filtered_groups = groups.search(params[:filter]).page(params[:page]) - if Group.supports_nested_groups? + if Group.supports_nested_objects? # We find the ancestors by ID of the search results here. # Otherwise the ancestors would also have filters applied, # which would cause them not to be preloaded. # # Pagination needs to be applied before loading the ancestors to # make sure ancestors are not cut off by pagination. - Gitlab::GroupHierarchy.new(Group.where(id: filtered_groups.select(:id))) + Gitlab::ObjectHierarchy.new(Group.where(id: filtered_groups.select(:id))) .base_and_ancestors else filtered_groups diff --git a/app/controllers/concerns/invalid_utf8_error_handler.rb b/app/controllers/concerns/invalid_utf8_error_handler.rb deleted file mode 100644 index 44c6d6b0da0..00000000000 --- a/app/controllers/concerns/invalid_utf8_error_handler.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module InvalidUTF8ErrorHandler - extend ActiveSupport::Concern - - included do - rescue_from ArgumentError, with: :handle_invalid_utf8 - end - - private - - def handle_invalid_utf8(error) - if error.message == "invalid byte sequence in UTF-8" - render_412 - else - raise(error) - end - end - - def render_412 - respond_to do |format| - format.html { render "errors/precondition_failed", layout: "errors", status: 412 } - format.js { render json: { error: 'Invalid UTF-8' }, status: :precondition_failed, content_type: 'application/json' } - format.any { head :precondition_failed } - end - end -end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index ad9cc0925b7..3d64ae8b775 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -5,7 +5,6 @@ module IssuableActions include Gitlab::Utils::StrongMemoize included do - before_action :labels, only: [:show, :new, :edit] before_action :authorize_destroy_issuable!, only: :destroy before_action :authorize_admin_issuable!, only: :bulk_update end @@ -25,7 +24,10 @@ module IssuableActions def show respond_to do |format| - format.html + format.html do + @issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + format.json do render json: serializer.represent(issuable, serializer: params[:serializer]) end @@ -168,10 +170,6 @@ module IssuableActions end end - def labels - @labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute # rubocop:disable Gitlab/ModuleWithInstanceVariables - end - def authorize_destroy_issuable! unless can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable) return access_denied! diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 9576eb14fdd..5572c3cee2d 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -94,6 +94,7 @@ module LfsRequest def lfs_upload_access? return false unless project.lfs_enabled? return false unless has_authentication_ability?(:push_code) + return false if limit_exceeded? lfs_deploy_token? || can?(user, :push_code, project) end @@ -121,4 +122,9 @@ module LfsRequest def has_authentication_ability?(capability) (authentication_abilities || []).include?(capability) end + + # Overriden in EE + def limit_exceeded? + false + end end diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index c61b9fabe9e..4b0f0b8255c 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -12,7 +12,7 @@ module PreviewMarkdown when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } when 'snippets' then { skip_project_check: true } when 'groups' then { group: group } - when 'projects' then { issuable_state_filter_enabled: true } + when 'projects' then projects_filter_params else {} end @@ -22,9 +22,17 @@ module PreviewMarkdown body: view_context.markdown(result[:text], markdown_params), references: { users: result[:users], + suggestions: result[:suggestions], commands: view_context.markdown(result[:commands]) } } end + + def projects_filter_params + { + issuable_state_filter_enabled: true, + suggestions_filter_enabled: params[:preview_suggestions].present? + } + end # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 8bd93a349ef..c6ae4fe15bf 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -70,7 +70,7 @@ module ServiceParams def service_params dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables - service_params = params.permit(:id, service: ALLOWED_PARAMS_CE + dynamic_params) + service_params = params.permit(:id, service: allowed_service_params + dynamic_params) if service_params[:service].is_a?(Hash) FILTER_BLANK_PARAMS.each do |param| @@ -80,4 +80,8 @@ module ServiceParams service_params end + + def allowed_service_params + ALLOWED_PARAMS_CE + end end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 6ea4758ec32..3ef03bc9622 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -43,6 +43,6 @@ class GraphqlController < ApplicationController end def check_graphql_feature_flag! - render_404 unless Feature.enabled?(:graphql) + render_404 unless Gitlab::Graphql.enabled? end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index b42116b0f36..868deea3f01 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -43,14 +43,7 @@ class Groups::MilestonesController < Groups::ApplicationController def update # Keep this compatible with legacy group milestones where we have to update # all projects milestones states at once. - if @milestone.legacy_group_milestone? - update_params = milestone_params.select { |key| key == "state_event" } - milestones = @milestone.milestones - else - update_params = milestone_params - milestones = [@milestone] - end - + milestones, update_params = get_milestones_for_update milestones.each do |milestone| Milestones::UpdateService.new(milestone.parent, current_user, update_params).execute(milestone) end @@ -71,6 +64,14 @@ class Groups::MilestonesController < Groups::ApplicationController private + def get_milestones_for_update + if @milestone.legacy_group_milestone? + [@milestone.milestones, legacy_milestone_params] + else + [[@milestone], milestone_params] + end + end + def authorize_admin_milestones! return render_404 unless can?(current_user, :admin_milestone, group) end @@ -79,6 +80,10 @@ class Groups::MilestonesController < Groups::ApplicationController params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event) end + def legacy_milestone_params + params.require(:milestone).permit(:state_event) + end + def milestone_path if @milestone.legacy_group_milestone? group_milestone_path(group, @milestone.safe_title, title: @milestone.title) diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index c1dcc463de7..f476f428fdb 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -4,7 +4,7 @@ module Groups module Settings class CiCdController < Groups::ApplicationController skip_cross_project_access_check :show - before_action :authorize_admin_pipeline! + before_action :authorize_admin_group! def show define_ci_variables @@ -26,8 +26,8 @@ module Groups .map { |variable| variable.present(current_user: current_user) } end - def authorize_admin_pipeline! - return render_404 unless can?(current_user, :admin_pipeline, group) + def authorize_admin_group! + return render_404 unless can?(current_user, :admin_group, group) end end end diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index dcee8eb7e6e..055d900eece 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -40,7 +40,6 @@ class Profiles::KeysController < Profiles::ApplicationController begin user = UserFinder.new(params[:username]).find_by_username if user.present? - headers['Content-Disposition'] = 'attachment' render plain: user.all_ssh_keys.join("\n") else return render_404 diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 60fabd15333..ff286c0ccf0 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -260,7 +260,7 @@ class Projects::BlobController < Projects::ApplicationController extension: blob.extension, size: blob.raw_size, mime_type: blob.mime_type, - binary: blob.raw_binary?, + binary: blob.binary?, simple_viewer: blob.simple_viewer&.class&.partial_name, rich_viewer: blob.rich_viewer&.class&.partial_name, show_viewer_switcher: !!blob.show_viewer_switcher?, diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 0a593bd35b6..6824a07dc76 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -24,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController end def create - @key = DeployKeys::CreateService.new(current_user, create_params).execute + @key = DeployKeys::CreateService.new(current_user, create_params).execute(project: @project) unless @key.valid? flash[:alert] = @key.errors.full_messages.join(', ').html_safe diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index c0aa39d87c6..30e436365de 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -80,9 +80,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController end def access_check - # Use the magic string '_any' to indicate we do not know what the - # changes are. This is also what gitlab-shell does. - access.check(git_command, '_any') + access.check(git_command, Gitlab::GitAccess::ANY) @project ||= access.project end diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index a10e159ea1e..8b33fa85c1e 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -13,7 +13,7 @@ class Projects::ImportsController < Projects::ApplicationController end def create - if @project.update(safe_import_params) + if @project.update(import_params) @project.import_state.reload.schedule end @@ -66,11 +66,11 @@ class Projects::ImportsController < Projects::ApplicationController end end - def import_params - params.require(:project).permit(:import_url) + def import_params_attributes + [:import_url] end - def safe_import_params - import_params + def import_params + params.require(:project).permit(import_params_attributes) end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index c6ab6b4642e..5ed46fc0545 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -268,7 +268,6 @@ class Projects::IssuesController < Projects::ApplicationController end def set_suggested_issues_feature_flags - push_frontend_feature_flag(:graphql) - push_frontend_feature_flag(:issue_suggestions) + push_frontend_feature_flag(:graphql, default_enabled: true) end end diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index c58b30eace7..bfbbcba883f 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -9,7 +9,7 @@ class Projects::JobsController < Projects::ApplicationController before_action :authorize_update_build!, except: [:index, :show, :status, :raw, :trace, :cancel_all, :erase] before_action :authorize_erase_build!, only: [:erase] - before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_workhorse_authorize] + before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize] before_action :verify_api_request!, only: :terminal_websocket_authorize layout 'project' diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb index ac1969adc6e..045a4e974fe 100644 --- a/app/controllers/projects/merge_requests/conflicts_controller.rb +++ b/app/controllers/projects/merge_requests/conflicts_controller.rb @@ -8,7 +8,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap def show respond_to do |format| format.html do - labels + @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') end format.json do @@ -60,9 +60,15 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap end end + private + def authorize_can_resolve_conflicts! @conflicts_list = ::MergeRequests::Conflicts::ListService.new(@merge_request) return render_404 unless @conflicts_list.can_be_resolved_by?(current_user) end + + def serializer + MergeRequestSerializer.new(current_user: current_user, project: project) + end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index da9316d5f22..162c2636641 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -22,8 +22,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo format.html format.json do render json: { - html: view_to_html_string("projects/merge_requests/_merge_requests"), - labels: @labels.as_json(methods: :text_color) + html: view_to_html_string("projects/merge_requests/_merge_requests") } end end @@ -43,8 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @noteable = @merge_request @commits_count = @merge_request.commits_count - - labels + @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') set_pipeline_variables @@ -220,6 +218,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo head :ok end + def discussions + merge_request.preload_discussions_diff_highlight + + super + end + protected alias_method :subscribable_resource, :merge_request diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index a860be83e95..c5454883060 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -15,6 +15,10 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController @protected_ref = @project.protected_branches.find(params[:id]) end + def access_levels + [:merge_access_levels, :push_access_levels] + end + def protected_ref_params params.require(:protected_branch).permit(:name, merge_access_levels_attributes: access_level_attributes, diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb index 3a3a29ddd0d..4e2a9df5576 100644 --- a/app/controllers/projects/protected_refs_controller.rb +++ b/app/controllers/projects/protected_refs_controller.rb @@ -32,7 +32,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController @protected_ref = update_service_class.new(@project, current_user, protected_ref_params).execute(@protected_ref) if @protected_ref.valid? - render json: @protected_ref, status: :ok + render json: @protected_ref, status: :ok, include: access_levels else render json: @protected_ref.errors, status: :unprocessable_entity end @@ -62,6 +62,6 @@ class Projects::ProtectedRefsController < Projects::ApplicationController end def access_level_attributes - %i(access_level id) + %i[access_level id] end end diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb index 01cedba95ac..41191639c2b 100644 --- a/app/controllers/projects/protected_tags_controller.rb +++ b/app/controllers/projects/protected_tags_controller.rb @@ -15,6 +15,10 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController @protected_ref = @project.protected_tags.find(params[:id]) end + def access_levels + [:create_access_levels] + end + def protected_ref_params params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes) end diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 55827075896..62bdc84b41a 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -3,40 +3,17 @@ class Projects::ReleasesController < Projects::ApplicationController # Authorize before_action :require_non_empty_project - before_action :authorize_download_code! - before_action :authorize_push_code! - before_action :tag - before_action :release + before_action :authorize_read_release! + before_action :check_releases_page_feature_flag - def edit - end - - def update - # Release belongs to Tag which is not active record object, - # it exists only to save a description to each Tag. - # If description is empty we should destroy the existing record. - if release_params[:description].present? - release.update(release_params) - else - release.destroy - end - - redirect_to project_tag_path(@project, @tag.name) + def index end private - def tag - @tag ||= @repository.find_tag(params[:tag_id]) - end - - # rubocop: disable CodeReuse/ActiveRecord - def release - @release ||= @project.releases.find_or_initialize_by(tag: @tag.name) - end - # rubocop: enable CodeReuse/ActiveRecord + def check_releases_page_feature_flag + return render_404 unless Feature.enabled?(:releases_page, @project) - def release_params - params.require(:release).permit(:description) + push_frontend_feature_flag(:releases_page, @project) end end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index 30724de7f6a..ac3004d069f 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -5,7 +5,6 @@ module Projects class RepositoryController < Projects::ApplicationController before_action :authorize_admin_project! before_action :remote_mirror, only: [:show] - before_action :check_cleanup_feature_flag!, only: :cleanup def show render_show @@ -37,10 +36,6 @@ module Projects private - def check_cleanup_feature_flag! - render_404 unless ::Feature.enabled?(:project_cleanup, project) - end - def render_show @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user) @deploy_tokens = @project.deploy_tokens.active diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index a44acb12bdf..255f1f3569a 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -75,7 +75,14 @@ class Projects::SnippetsController < Projects::ApplicationController format.json do render_blob_json(blob) end - format.js { render 'shared/snippets/show'} + + format.js do + if @snippet.embeddable? + render 'shared/snippets/show' + else + head :not_found + end + end end end diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb new file mode 100644 index 00000000000..334e1847cc8 --- /dev/null +++ b/app/controllers/projects/tags/releases_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class Projects::Tags::ReleasesController < Projects::ApplicationController + # Authorize + before_action :require_non_empty_project + before_action :authorize_download_code! + before_action :authorize_push_code! + before_action :tag + before_action :release + + def edit + end + + def update + # Release belongs to Tag which is not active record object, + # it exists only to save a description to each Tag. + # If description is empty we should destroy the existing record. + if release_params[:description].present? + release.update(release_params) + else + release.destroy + end + + redirect_to project_tag_path(@project, @tag.name) + end + + private + + def tag + @tag ||= @repository.find_tag(params[:tag_id]) + end + + # rubocop: disable CodeReuse/ActiveRecord + def release + @release ||= @project.releases.find_or_initialize_by(tag: @tag.name) + end + # rubocop: enable CodeReuse/ActiveRecord + + def release_params + params.require(:release).permit(:description) + end +end diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 686d66b10a3..a17c050b696 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -42,10 +42,23 @@ class Projects::TagsController < Projects::ApplicationController # rubocop: enable CodeReuse/ActiveRecord def create - result = Tags::CreateService.new(@project, current_user) - .execute(params[:tag_name], params[:ref], params[:message], params[:release_description]) + result = ::Tags::CreateService.new(@project, current_user) + .execute(params[:tag_name], params[:ref], params[:message]) if result[:status] == :success + # Release creation with Tags was deprecated in GitLab 11.7 + if params[:release_description].present? + release_params = { + tag: params[:tag_name], + name: params[:tag_name], + description: params[:release_description] + } + + Releases::CreateService + .new(@project, current_user, release_params) + .execute + end + @tag = result[:tag] redirect_to project_tag_path(@project, @tag.name) @@ -58,7 +71,7 @@ class Projects::TagsController < Projects::ApplicationController end def destroy - result = Tags::DestroyService.new(project, current_user).execute(params[:id]) + result = ::Tags::DestroyService.new(project, current_user).execute(params[:id]) respond_to do |format| if result[:status] == :success diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 8bf93bfd68d..878816475b2 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -19,6 +19,7 @@ class ProjectsController < Projects::ApplicationController before_action :lfs_blob_ids, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :present_project, only: [:edit] + before_action :authorize_download_code!, only: [:refs] # Authorize before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] diff --git a/app/controllers/sherlock/transactions_controller.rb b/app/controllers/sherlock/transactions_controller.rb index 46e382e594e..8d1847507cc 100644 --- a/app/controllers/sherlock/transactions_controller.rb +++ b/app/controllers/sherlock/transactions_controller.rb @@ -15,7 +15,7 @@ module Sherlock def destroy_all Gitlab::Sherlock.collection.clear - redirect_to :back, status: :found + redirect_back_or_default(options: { status: :found }) end end end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index dd9bf17cf0c..8ea5450b4e8 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -80,7 +80,13 @@ class SnippetsController < ApplicationController render_blob_json(blob) end - format.js { render 'shared/snippets/show' } + format.js do + if @snippet.embeddable? + render 'shared/snippets/show' + else + head :not_found + end + end end end diff --git a/app/finders/cluster_ancestors_finder.rb b/app/finders/cluster_ancestors_finder.rb new file mode 100644 index 00000000000..2f9709ee057 --- /dev/null +++ b/app/finders/cluster_ancestors_finder.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class ClusterAncestorsFinder + include Gitlab::Utils::StrongMemoize + + def initialize(clusterable, current_user) + @clusterable = clusterable + @current_user = current_user + end + + def execute + return [] unless can_read_clusters? + + clusterable.clusters + ancestor_clusters + end + + def has_ancestor_clusters? + ancestor_clusters.any? + end + + private + + attr_reader :clusterable, :current_user + + def can_read_clusters? + Ability.allowed?(current_user, :read_cluster, clusterable) + end + + # This unfortunately returns an Array, not a Relation! + def ancestor_clusters + strong_memoize(:ancestor_clusters) do + Clusters::Cluster.ancestor_clusters_for_clusterable(clusterable) + end + end +end diff --git a/app/finders/concerns/finder_with_cross_project_access.rb b/app/finders/concerns/finder_with_cross_project_access.rb index 220f62bcc7f..06ebb286086 100644 --- a/app/finders/concerns/finder_with_cross_project_access.rb +++ b/app/finders/concerns/finder_with_cross_project_access.rb @@ -5,7 +5,8 @@ # # This module depends on the finder implementing the following methods: # -# - `#execute` should return an `ActiveRecord::Relation` +# - `#execute` should return an `ActiveRecord::Relation` or the `model` needs to +# be defined in the call to `requires_cross_project_access`. # - `#current_user` the user that requires access (or nil) module FinderWithCrossProjectAccess extend ActiveSupport::Concern @@ -13,20 +14,35 @@ module FinderWithCrossProjectAccess prepended do extend Gitlab::CrossProjectAccess::ClassMethods + + cattr_accessor :finder_model + + def self.requires_cross_project_access(*args) + super + + self.finder_model = extract_model_from_arguments(args) + end + + private + + def self.extract_model_from_arguments(args) + args.detect { |argument| argument.is_a?(Hash) && argument[:model] } + &.fetch(:model) + end end override :execute def execute(*args) check = Gitlab::CrossProjectAccess.find_check(self) - original = super + original = -> { super } - return original unless check - return original if should_skip_cross_project_check || can_read_cross_project? + return original.call unless check + return original.call if should_skip_cross_project_check || can_read_cross_project? if check.should_run?(self) - original.model.none + finder_model&.none || original.call.model.none else - original + original.call end end @@ -48,8 +64,6 @@ module FinderWithCrossProjectAccess skip_cross_project_check { super } end - private - attr_accessor :should_skip_cross_project_check def skip_cross_project_check diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb index 8df01f1dad9..234b7090fd9 100644 --- a/app/finders/events_finder.rb +++ b/app/finders/events_finder.rb @@ -3,22 +3,27 @@ class EventsFinder prepend FinderMethods prepend FinderWithCrossProjectAccess + + MAX_PER_PAGE = 100 + attr_reader :source, :params, :current_user - requires_cross_project_access unless: -> { source.is_a?(Project) } + requires_cross_project_access unless: -> { source.is_a?(Project) }, model: Event # Used to filter Events # # Arguments: # source - which user or project to looks for events on # current_user - only return events for projects visible to this user - # WARNING: does not consider project feature visibility! # params: # action: string # target_type: string # before: datetime # after: datetime - # + # per_page: integer (max. 100) + # page: integer + # with_associations: boolean + # sort: 'asc' or 'desc' def initialize(params = {}) @source = params.delete(:source) @current_user = params.delete(:current_user) @@ -33,15 +38,18 @@ class EventsFinder events = by_target_type(events) events = by_created_at_before(events) events = by_created_at_after(events) + events = sort(events) + + events = events.with_associations if params[:with_associations] - events + paginated_filtered_by_user_visibility(events) end private # rubocop: disable CodeReuse/ActiveRecord def by_current_user_access(events) - events.merge(ProjectsFinder.new(current_user: current_user).execute) # rubocop: disable CodeReuse/Finder + events.merge(Project.public_or_visible_to_user(current_user)) .joins(:project) end # rubocop: enable CodeReuse/ActiveRecord @@ -77,4 +85,31 @@ class EventsFinder events.where('events.created_at > ?', params[:after].end_of_day) end # rubocop: enable CodeReuse/ActiveRecord + + def sort(events) + return events unless params[:sort] + + if params[:sort] == 'asc' + events.order_id_asc + else + events.order_id_desc + end + end + + def paginated_filtered_by_user_visibility(events) + limited_events = events.page(page).per(per_page) + visible_events = limited_events.select { |event| event.visible_to_user?(current_user) } + + Kaminari.paginate_array(visible_events, total_count: events.count) + end + + def per_page + return MAX_PER_PAGE unless params[:per_page] + + [params[:per_page], MAX_PER_PAGE].min + end + + def page + params[:page] || 1 + end end diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index a9ce5be13f3..96a36db7ec8 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -112,7 +112,7 @@ class GroupDescendantsFinder # rubocop: disable CodeReuse/ActiveRecord def ancestors_of_groups(base_for_ancestors) group_ids = base_for_ancestors.except(:select, :sort).select(:id) - Gitlab::GroupHierarchy.new(Group.where(id: group_ids)) + Gitlab::ObjectHierarchy.new(Group.where(id: group_ids)) .base_and_ancestors(upto: parent_group.id) end # rubocop: enable CodeReuse/ActiveRecord @@ -132,7 +132,7 @@ class GroupDescendantsFinder end def subgroups - return Group.none unless Group.supports_nested_groups? + return Group.none unless Group.supports_nested_objects? # When filtering subgroups, we want to find all matches withing the tree of # descendants to show to the user @@ -183,7 +183,7 @@ class GroupDescendantsFinder # rubocop: disable CodeReuse/ActiveRecord def hierarchy_for_parent - @hierarchy ||= Gitlab::GroupHierarchy.new(Group.where(id: parent_group.id)) + @hierarchy ||= Gitlab::ObjectHierarchy.new(Group.where(id: parent_group.id)) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index ea954f98220..0080123407d 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -46,7 +46,7 @@ class GroupsFinder < UnionFinder return [Group.all] if current_user&.full_private_access? && all_available? groups = [] - groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups if current_user + groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects if current_user groups << Group.unscoped.public_to_user(current_user) if include_public_groups? groups << Group.none if groups.empty? groups @@ -66,7 +66,7 @@ class GroupsFinder < UnionFinder .groups .where('members.access_level >= ?', params[:min_access_level]) - Gitlab::GroupHierarchy + Gitlab::ObjectHierarchy .new(groups) .base_and_descendants end diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb new file mode 100644 index 00000000000..59e84198fde --- /dev/null +++ b/app/finders/releases_finder.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ReleasesFinder + def initialize(project, current_user = nil) + @project = project + @current_user = current_user + end + + def execute + return Release.none unless Ability.allowed?(@current_user, :read_release, @project) + + @project.releases.sorted + end +end diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 3f69af50f25..473c90c882c 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -11,7 +11,7 @@ module AppearancesHelper end def brand_image - image_tag(current_appearance.logo) if current_appearance&.logo? + image_tag(current_appearance.logo_path) if current_appearance&.logo? end def brand_text @@ -28,7 +28,7 @@ module AppearancesHelper def brand_header_logo if current_appearance&.header_logo? - image_tag current_appearance.header_logo + image_tag current_appearance.header_logo_path else render 'shared/logo.svg' end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 74042f0bae8..82bb2d1a805 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -171,7 +171,6 @@ module ApplicationHelper def page_filter_path(options = {}) without = options.delete(:without) - add_label = options.delete(:label) options = request.query_parameters.merge(options) @@ -181,11 +180,7 @@ module ApplicationHelper end end - params = options.compact - - params.delete(:label_name) unless add_label - - "#{request.path}?#{params.to_param}" + "#{request.path}?#{options.compact.to_param}" end def outdated_browser? diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 086bb38ce9a..c8e4e2e3df9 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -20,12 +20,24 @@ module ApplicationSettingsHelper def enabled_protocol case Gitlab::CurrentSettings.enabled_git_access_protocol when 'http' - gitlab_config.protocol + Gitlab.config.gitlab.protocol when 'ssh' 'ssh' end end + def all_protocols_enabled? + Gitlab::CurrentSettings.enabled_git_access_protocol.blank? + end + + def ssh_enabled? + all_protocols_enabled? || enabled_protocol == 'ssh' + end + + def http_enabled? + all_protocols_enabled? || Gitlab::CurrentSettings.enabled_git_access_protocol == 'http' + end + def enabled_project_button(project, protocol) case protocol when 'ssh' @@ -218,7 +230,8 @@ module ApplicationSettingsHelper :version_check_enabled, :web_ide_clientside_preview_enabled, :diff_max_patch_bytes, - :commit_email_hostname + :commit_email_hostname, + :protected_ci_variables ] end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index bd42f00944f..23d6684a8e6 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -140,36 +140,6 @@ module BlobHelper Gitlab::Sanitizers::SVG.clean(data) end - # Remove once https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 is closed - # and :workhorse_set_content_type flag is removed - # If we blindly set the 'real' content type when serving a Git blob we - # are enabling XSS attacks. An attacker could upload e.g. a Javascript - # file to a Git repository, trick the browser of a victim into - # downloading the blob, and then the 'application/javascript' content - # type would tell the browser to execute the attacker's Javascript. By - # overriding the content type and setting it to 'text/plain' (in the - # example of Javascript) we tell the browser of the victim not to - # execute untrusted data. - def safe_content_type(blob) - if blob.extension == 'svg' - blob.mime_type - elsif blob.text? - 'text/plain; charset=utf-8' - elsif blob.image? - blob.content_type - else - 'application/octet-stream' - end - end - - def content_disposition(blob, inline) - # Remove the following line when https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 - # is closed and :workhorse_set_content_type flag is removed - return 'attachment' if blob.extension == 'svg' - - inline ? 'inline' : 'attachment' - end - def ref_project @ref_project ||= @target_project || @project end @@ -207,7 +177,8 @@ module BlobHelper 'relative-url-root' => Rails.application.config.relative_url_root, 'assets-prefix' => Gitlab::Application.config.assets.prefix, 'blob-filename' => @blob && @blob.path, - 'project-id' => project.id + 'project-id' => project.id, + 'is-markdown' => @blob && @blob.path && Gitlab::MarkupHelper.gitlab_markdown?(@blob.path) } end @@ -223,7 +194,7 @@ module BlobHelper def open_raw_blob_button(blob) return if blob.empty? - return if blob.raw_binary? || blob.stored_externally? + return if blob.binary? || blob.stored_externally? title = 'Open raw' link_to icon('file-code-o'), blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } diff --git a/app/helpers/ci_variables_helper.rb b/app/helpers/ci_variables_helper.rb new file mode 100644 index 00000000000..e3728804c2a --- /dev/null +++ b/app/helpers/ci_variables_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module CiVariablesHelper + def ci_variable_protected_by_default? + Gitlab::CurrentSettings.current_application_settings.protected_ci_variables + end + + def ci_variable_protected?(variable, only_key_value) + if variable && !only_key_value + variable.protected + else + ci_variable_protected_by_default? + end + end +end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index b6844d36052..32431959851 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -138,30 +138,6 @@ module DiffHelper !diff_file.deleted_file? && @merge_request && @merge_request.source_project end - def diff_render_error_reason(viewer) - case viewer.render_error - when :too_large - "it is too large" - when :server_side_but_stored_externally - case viewer.diff_file.external_storage - when :lfs - 'it is stored in LFS' - else - 'it is stored externally' - end - end - end - - def diff_render_error_options(viewer) - diff_file = viewer.diff_file - options = [] - - blob_url = project_blob_path(@project, tree_join(diff_file.content_sha, diff_file.file_path)) - options << link_to('view the blob', blob_url) - - options - end - def diff_file_changed_icon(diff_file) if diff_file.deleted_file? "file-deletion" diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index e4c46ceeaa2..fa5d3ae474a 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -58,7 +58,7 @@ module EmailsHelper def header_logo if current_appearance&.header_logo? image_tag( - current_appearance.header_logo, + current_appearance.header_logo_path, style: 'height: 50px' ) else diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 866fc555856..4a9ed123161 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -126,7 +126,7 @@ module GroupsHelper end def supports_nested_groups? - Group.supports_nested_groups? + Group.supports_nested_objects? end private diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index da991458ea7..5f7147508c7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -23,30 +23,41 @@ module IssuablesHelper end end - def sidebar_due_date_tooltip_label(issuable) - if issuable.due_date - "#{_('Due date')}<br />#{due_date_remaining_days(issuable)}" - else - _('Due date') - end + def sidebar_milestone_tooltip_label(milestone) + return _('Milestone') unless milestone.present? + + [milestone[:title], sidebar_milestone_remaining_days(milestone) || _('Milestone')].join('<br/>') + end + + def sidebar_milestone_remaining_days(milestone) + due_date_with_remaining_days(milestone[:due_date], milestone[:start_date]) + end + + def sidebar_due_date_tooltip_label(due_date) + [_('Due date'), due_date_with_remaining_days(due_date)].compact.join('<br/>') end - def due_date_remaining_days(issuable) - remaining_days_in_words = remaining_days_in_words(issuable) + def due_date_with_remaining_days(due_date, start_date = nil) + return unless due_date - "#{issuable.due_date.to_s(:medium)} (#{remaining_days_in_words})" + "#{due_date.to_s(:medium)} (#{remaining_days_in_words(due_date, start_date)})" + end + + def sidebar_label_filter_path(base_path, label_name) + query_params = { label_name: [label_name] }.to_query + + "#{base_path}?#{query_params}" end def multi_label_name(current_labels, default_label) - if current_labels && current_labels.any? - title = current_labels.first.try(:title) - if current_labels.size > 1 - "#{title} +#{current_labels.size - 1} more" - else - title - end + return default_label if current_labels.blank? + + title = current_labels.first.try(:title) || current_labels.first[:title] + + if current_labels.size > 1 + "#{title} +#{current_labels.size - 1} more" else - default_label + title end end @@ -197,19 +208,11 @@ module IssuablesHelper output.join.html_safe end - # rubocop: disable CodeReuse/ActiveRecord - def issuable_todo(issuable) - if current_user - current_user.todos.find_by(target: issuable, state: :pending) - end - end - # rubocop: enable CodeReuse/ActiveRecord - def issuable_labels_tooltip(labels, limit: 5) first, last = labels.partition.with_index { |_, i| i < limit } if labels && labels.any? - label_names = first.collect(&:name) + label_names = first.collect { |label| label.fetch(:title) } label_names << "and #{last.size} more" unless last.empty? label_names.join(', ') @@ -356,12 +359,6 @@ module IssuablesHelper issuable.model_name.human.downcase end - def selected_labels - Array(params[:label_name]).map do |label_name| - Label.new(title: label_name) - end - end - def has_filter_bar_param? finder.class.scalar_params.any? { |p| params[p].present? } end @@ -386,19 +383,20 @@ module IssuablesHelper params[:issuable_template] if issuable_templates(issuable).any? { |template| template[:name] == params[:issuable_template] } end - def issuable_todo_button_data(issuable, todo, is_collapsed) + def issuable_todo_button_data(issuable, is_collapsed) { - todo_text: "Add todo", - mark_text: "Mark todo as done", - todo_icon: (is_collapsed ? sprite_icon('todo-add') : nil), - mark_icon: (is_collapsed ? sprite_icon('todo-done', css_class: 'todo-undone') : nil), - issuable_id: issuable.id, - issuable_type: issuable.class.name.underscore, - url: project_todos_path(@project), - delete_path: (dashboard_todo_path(todo) if todo), - placement: (is_collapsed ? 'left' : nil), - container: (is_collapsed ? 'body' : nil), - boundary: 'viewport' + todo_text: _('Add todo'), + mark_text: _('Mark todo as done'), + todo_icon: sprite_icon('todo-add'), + mark_icon: sprite_icon('todo-done', css_class: 'todo-undone'), + issuable_id: issuable[:id], + issuable_type: issuable[:type], + create_path: issuable[:create_todo_path], + delete_path: issuable.dig(:current_user, :todo, :delete_path), + placement: is_collapsed ? 'left' : nil, + container: is_collapsed ? 'body' : nil, + boundary: 'viewport', + is_collapsed: is_collapsed } end @@ -418,27 +416,20 @@ module IssuablesHelper end end - def issuable_sidebar_options(issuable, can_edit_issuable) + def issuable_sidebar_options(issuable) { - endpoint: "#{issuable_json_path(issuable)}?serializer=sidebar", - toggleSubscriptionEndpoint: toggle_subscription_path(issuable), - moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), - projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), - editable: can_edit_issuable, - currentUser: UserSerializer.new.represent(current_user), + endpoint: "#{issuable[:issuable_json_path]}?serializer=sidebar_extras", + toggleSubscriptionEndpoint: issuable[:toggle_subscription_path], + moveIssueEndpoint: issuable[:move_issue_path], + projectsAutocompleteEndpoint: issuable[:projects_autocomplete_path], + editable: issuable.dig(:current_user, :can_edit), + currentUser: issuable[:current_user], rootPath: root_path, - fullPath: @project.full_path + fullPath: issuable[:project_full_path] } end def parent @project || @group end - - def issuable_milestone_tooltip_title(issuable) - if issuable.milestone - milestone_tooltip = milestone_tooltip_title(issuable.milestone) - _('Milestone') + (milestone_tooltip ? ': ' + milestone_tooltip : '') - end - end end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 9666080092b..50aec83b867 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -114,12 +114,6 @@ module MilestonesHelper end end - def milestone_tooltip_title(milestone) - if milestone - "#{milestone.title}<br />#{milestone_tooltip_due_date(milestone)}" - end - end - def milestone_time_for(date, date_type) title = date_type == :start ? "Start date" : "End date" @@ -173,7 +167,7 @@ module MilestonesHelper def milestone_tooltip_due_date(milestone) if milestone.due_date - "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone)})" + "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone.due_date, milestone.start_date)})" else _('Milestone') end @@ -237,12 +231,15 @@ module MilestonesHelper end end - def group_or_dashboard_milestone_path(milestone) - if milestone.group_milestone? - group_milestone_path(milestone.group, milestone.iid, milestone: { title: milestone.title }) - else - dashboard_milestone_path(milestone.safe_title, title: milestone.title) - end + def group_or_project_milestone_path(milestone) + params = + if milestone.group_milestone? + { milestone: { title: milestone.title } } + else + { title: milestone.title } + end + + milestone_path(milestone.milestone, params) end def can_admin_project_milestones? diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 1186eb3ddcc..0cfc2db3285 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -271,13 +271,27 @@ module ProjectsHelper params[:legacy_render] ? { markdown_engine: :redcarpet } : {} end + def explore_projects_tab? + current_page?(explore_projects_path) || + current_page?(trending_explore_projects_path) || + current_page?(starred_explore_projects_path) + end + + def show_merge_request_count?(disabled: false, compact_mode: false) + !disabled && !compact_mode && Feature.enabled?(:project_list_show_mr_count, default_enabled: true) + end + + def show_issue_count?(disabled: false, compact_mode: false) + !disabled && !compact_mode && Feature.enabled?(:project_list_show_issue_count, default_enabled: true) + end + private def get_project_nav_tabs(project, current_user) nav_tabs = [:home] if !project.empty_repo? && can?(current_user, :download_code, project) - nav_tabs << [:files, :commits, :network, :graphs, :forks] + nav_tabs << [:files, :commits, :network, :graphs, :forks, :releases] end if project.repo_exists? && can?(current_user, :read_merge_request, project) @@ -515,24 +529,11 @@ module ProjectsHelper end end - def explore_projects_tab? - current_page?(explore_projects_path) || - current_page?(trending_explore_projects_path) || - current_page?(starred_explore_projects_path) - end - - def show_merge_request_count?(merge_requests, compact_mode) - merge_requests && !compact_mode && Feature.enabled?(:project_list_show_mr_count, default_enabled: true) - end - - def show_issue_count?(issues, compact_mode) - issues && !compact_mode && Feature.enabled?(:project_list_show_issue_count, default_enabled: true) - end - def sidebar_projects_paths %w[ projects#show projects#activity + releases#index cycle_analytics#show ] end @@ -564,7 +565,6 @@ module ProjectsHelper projects/repositories tags branches - releases graphs network ] diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 80cc568820a..0ee76a51f7d 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -24,10 +24,10 @@ module SearchHelper end def search_entries_info(collection, scope, term) - return unless collection.count > 0 + return if collection.to_a.empty? from = collection.offset_value + 1 - to = collection.offset_value + collection.count + to = collection.offset_value + collection.to_a.size count = collection.total_count "Showing #{from} - #{to} of #{count} #{scope.humanize(capitalize: false)} for \"#{term}\"" diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index c7d31f3469d..ecb2b2d707b 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -110,7 +110,7 @@ module SnippetsHelper def embedded_snippet_raw_button blob = @snippet.blob - return if blob.empty? || blob.raw_binary? || blob.stored_externally? + return if blob.empty? || blob.binary? || blob.stored_externally? snippet_raw_url = if @snippet.is_a?(PersonalSnippet) raw_snippet_url(@snippet) @@ -130,12 +130,4 @@ module SnippetsHelper link_to external_snippet_icon('download'), download_url, class: 'btn', target: '_blank', title: 'Download', rel: 'noopener noreferrer' end - - def public_snippet? - if @snippet.project_id? - can?(nil, :read_project_snippet, @snippet) - else - can?(nil, :read_personal_snippet, @snippet) - end - end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 67c808b167a..02762897c89 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -159,27 +159,28 @@ module SortingHelper sort_options_hash[sort_value] end + def issuable_sort_icon_suffix(sort_value) + case sort_value + when sort_value_milestone, sort_value_due_date, /_asc\z/ + 'lowest' + else + 'highest' + end + end + def issuable_sort_direction_button(sort_value) link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort' reverse_sort = issuable_reverse_sort_order_hash[sort_value] if reverse_sort - reverse_url = page_filter_path(sort: reverse_sort, label: true) + reverse_url = page_filter_path(sort: reverse_sort) else reverse_url = '#' link_class += ' disabled' end link_to(reverse_url, type: 'button', class: link_class, title: 'Sort direction') do - icon_suffix = - case sort_value - when sort_value_milestone, sort_value_due_date, /_asc\z/ - 'lowest' - else - 'highest' - end - - sprite_icon("sort-#{icon_suffix}", size: 16) + sprite_icon("sort-#{issuable_sort_icon_suffix(sort_value)}", size: 16) end end @@ -233,7 +234,7 @@ module SortingHelper end def sort_title_milestone - s_('SortOptions|Milestone') + s_('SortOptions|Milestone due date') end def sort_title_milestone_later diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index bde9ca0cbf2..73c1402eae5 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -8,7 +8,7 @@ module UsersHelper end def user_email_help_text(user) - return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present? + return 'We also use email for avatar detection if no avatar is uploaded' unless user.unconfirmed_email.present? confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb index ab77b149072..5e519cf5c19 100644 --- a/app/helpers/version_check_helper.rb +++ b/app/helpers/version_check_helper.rb @@ -6,8 +6,7 @@ module VersionCheckHelper return unless Gitlab::CurrentSettings.version_check_enabled return if User.single_user&.requires_usage_stats_consent? - image_url = VersionCheck.new.url - image_tag image_url, class: 'js-version-status-badge' + image_tag VersionCheck.url, class: 'js-version-status-badge' end def link_to_version diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb index e9fc39e451b..bb5b1555dc4 100644 --- a/app/helpers/workhorse_helper.rb +++ b/app/helpers/workhorse_helper.rb @@ -7,8 +7,7 @@ module WorkhorseHelper def send_git_blob(repository, blob, inline: true) headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob)) - headers['Content-Disposition'] = content_disposition(blob, inline) - headers['Content-Type'] = safe_content_type(blob) + headers['Content-Disposition'] = inline ? 'inline' : 'attachment' # If enabled, this will override the values set above workhorse_set_content_type! @@ -47,6 +46,6 @@ module WorkhorseHelper end def workhorse_set_content_type! - headers[Gitlab::Workhorse::DETECT_HEADER] = "true" if Feature.enabled?(:workhorse_set_content_type) + headers[Gitlab::Workhorse::DETECT_HEADER] = "true" end end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 93b51fb1774..370e6d2f90b 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -56,7 +56,9 @@ module Emails @milestone = milestone @milestone_url = milestone_url(@milestone) - mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason).merge({ + template_name: 'changed_milestone_email' + })) end def issue_status_changed_email(recipient_id, issue_id, status, updated_by_user_id, reason = nil) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 6524d0c2087..9ba8f92fcbf 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -51,7 +51,9 @@ module Emails @milestone = milestone @milestone_url = milestone_url(@milestone) - mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason)) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason).merge({ + template_name: 'changed_milestone_email' + })) end def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil) diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 15710bee4d4..efa1233b434 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -16,6 +16,7 @@ class Notify < BaseMailer include Emails::AutoDevops include Emails::RemoteMirrors + helper MilestonesHelper helper MergeRequestsHelper helper DiffHelper helper BlobHelper diff --git a/app/models/appearance.rb b/app/models/appearance.rb index bffba3e13fa..e114c435b67 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -28,4 +28,32 @@ class Appearance < ActiveRecord::Base errors.add(:single_appearance_row, 'Only 1 appearances row can exist') end end + + def logo_path + logo_system_path(logo, 'logo') + end + + def header_logo_path + logo_system_path(header_logo, 'header_logo') + end + + def favicon_path + logo_system_path(favicon, 'favicon') + end + + private + + def logo_system_path(logo, mount_type) + return unless logo&.upload + + # If we're using a CDN, we need to use the full URL + asset_host = ActionController::Base.asset_host + local_path = Gitlab::Routing.url_helpers.appearance_upload_path( + filename: logo.filename, + id: logo.upload.model_id, + model: 'appearance', + mounted_as: mount_type) + + Gitlab::Utils.append_path(asset_host, local_path) + end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 4319db42019..88746375c67 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -302,7 +302,8 @@ class ApplicationSetting < ActiveRecord::Base user_show_add_ssh_key_message: true, usage_stats_set_by_user_id: nil, diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES, - commit_email_hostname: default_commit_email_hostname + commit_email_hostname: default_commit_email_hostname, + protected_ci_variables: false } end @@ -311,7 +312,7 @@ class ApplicationSetting < ActiveRecord::Base end def self.create_from_defaults - create(defaults) + build_from_defaults.tap(&:save) end def self.human_attribute_name(attr, _options = {}) @@ -382,7 +383,7 @@ class ApplicationSetting < ActiveRecord::Base end def restricted_visibility_levels=(levels) - super(levels.map { |level| Gitlab::VisibilityLevel.level_value(level) }) + super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) }) end def strip_sentry_values diff --git a/app/models/blob.rb b/app/models/blob.rb index 66a0925c495..c5766eb0327 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -102,7 +102,7 @@ class Blob < SimpleDelegator # If the blob is a text based blob the content is converted to UTF-8 and any # invalid byte sequences are replaced. def data - if binary? + if binary_in_repo? super else @data ||= super.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) @@ -149,7 +149,7 @@ class Blob < SimpleDelegator # an LFS pointer, we assume the file stored in LFS is binary, unless a # text-based rich blob viewer matched on the file's extension. Otherwise, this # depends on the type of the blob itself. - def raw_binary? + def binary? if stored_externally? if rich_viewer rich_viewer.binary? @@ -161,7 +161,7 @@ class Blob < SimpleDelegator true end else - binary? + binary_in_repo? end end @@ -180,7 +180,7 @@ class Blob < SimpleDelegator end def readable_text? - text? && !stored_externally? && !truncated? + text_in_repo? && !stored_externally? && !truncated? end def simple_viewer @@ -220,7 +220,7 @@ class Blob < SimpleDelegator def simple_viewer_class if empty? BlobViewer::Empty - elsif raw_binary? + elsif binary? BlobViewer::Download else # text BlobViewer::Text diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index eaaf9af1330..df6b9bb2f0b 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -16,7 +16,7 @@ module BlobViewer def initialize(blob) @blob = blob - @initially_binary = blob.binary? + @initially_binary = blob.binary_in_repo? end def self.partial_path @@ -52,7 +52,7 @@ module BlobViewer end def self.can_render?(blob, verify_binary: true) - return false if verify_binary && binary? != blob.binary? + return false if verify_binary && binary? != blob.binary_in_repo? return true if extensions&.include?(blob.extension) return true if file_types&.include?(blob.file_type) @@ -72,7 +72,7 @@ module BlobViewer end def binary_detected_after_load? - !@initially_binary && blob.binary? + !@initially_binary && blob.binary_in_repo? end # This method is used on the server side to check whether we can attempt to diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index 277f7c2717c..2d237383e60 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -22,49 +22,30 @@ class BroadcastMessage < ActiveRecord::Base after_commit :flush_redis_cache def self.current - raw_messages = Rails.cache.fetch(CACHE_KEY, expires_in: cache_expires_in) do + messages = cache.fetch(CACHE_KEY, as: BroadcastMessage, expires_in: cache_expires_in) do remove_legacy_cache_key - current_and_future_messages.to_json + current_and_future_messages end - messages = decode_messages(raw_messages) - return [] unless messages&.present? now_or_future = messages.select(&:now_or_future?) # If there are cached entries but none are to be displayed we'll purge the # cache so we don't keep running this code all the time. - Rails.cache.delete(CACHE_KEY) if now_or_future.empty? + cache.expire(CACHE_KEY) if now_or_future.empty? now_or_future.select(&:now?) end - def self.decode_messages(raw_messages) - return unless raw_messages&.present? - - message_list = ActiveSupport::JSON.decode(raw_messages) - - return unless message_list.is_a?(Array) - - valid_attr = BroadcastMessage.attribute_names - - message_list.map do |raw| - BroadcastMessage.new(raw) if valid_cache_entry?(raw, valid_attr) - end.compact - rescue ActiveSupport::JSON.parse_error - end - - def self.valid_cache_entry?(raw, valid_attr) - return false unless raw.is_a?(Hash) - - (raw.keys - valid_attr).empty? - end - def self.current_and_future_messages where('ends_at > :now', now: Time.zone.now).order_id_asc end + def self.cache + Gitlab::JsonCache.new(cache_key_with_version: false) + end + def self.cache_expires_in nil end @@ -74,7 +55,7 @@ class BroadcastMessage < ActiveRecord::Base # environment a one-shot migration would not work because the cache # would be repopulated by a node that has not been upgraded. def self.remove_legacy_cache_key - Rails.cache.delete(LEGACY_CACHE_KEY) + cache.expire(LEGACY_CACHE_KEY) end def active? @@ -102,7 +83,7 @@ class BroadcastMessage < ActiveRecord::Base end def flush_redis_cache - Rails.cache.delete(CACHE_KEY) + self.class.cache.expire(CACHE_KEY) self.class.remove_legacy_cache_key end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index e2917049902..aeb35538d67 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -10,6 +10,7 @@ module Ci include Importable include Gitlab::Utils::StrongMemoize include Deployable + include HasRef belongs_to :project, inverse_of: :builds belongs_to :runner @@ -220,6 +221,10 @@ module Ci next unless build.project build.deployment&.drop + end + + after_transition any => [:failed] do |build| + next unless build.project if build.retry_failure? begin @@ -640,11 +645,11 @@ module Ci def secret_group_variables return [] unless project.group - project.group.ci_variables_for(ref, project) + project.group.ci_variables_for(git_ref, project) end def secret_project_variables(environment: persisted_environment) - project.ci_variables_for(ref: ref, environment: environment) + project.ci_variables_for(ref: git_ref, environment: environment) end def steps @@ -840,6 +845,7 @@ module Ci variables.append(key: 'CI_JOB_NAME', value: name) variables.append(key: 'CI_JOB_STAGE', value: stage) variables.append(key: 'CI_COMMIT_SHA', value: sha) + variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha) variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha) variables.append(key: 'CI_COMMIT_REF_NAME', value: ref) variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index d06022a0fb7..01134e133db 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -11,6 +11,7 @@ module Ci include Gitlab::Utils::StrongMemoize include AtomicInternalId include EnumWithNil + include HasRef belongs_to :project, inverse_of: :all_pipelines belongs_to :user @@ -56,11 +57,7 @@ module Ci validates :tag, inclusion: { in: [false], if: :merge_request? } validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? - - # Replace validator below with - # `validates :source, presence: { unless: :importing? }, on: :create` - # when removing Gitlab.rails5? code. - validate :valid_source, unless: :importing?, on: :create + validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create after_create :keep_around_commits, unless: :importing? @@ -68,11 +65,7 @@ module Ci # this `Hash` with new values. enum_with_nil source: ::Ci::PipelineEnums.sources - enum_with_nil config_source: { - unknown_source: nil, - repository_source: 1, - auto_devops_source: 2 - } + enum_with_nil config_source: ::Ci::PipelineEnums.config_sources # We use `Ci::PipelineEnums.failure_reasons` here so that EE can more easily # extend this `Hash` with new values. @@ -388,7 +381,7 @@ module Ci end def branch? - !tag? && !merge_request? + super && !merge_request? end def stuck? @@ -588,7 +581,7 @@ module Ci end def protected_ref? - strong_memoize(:protected_ref) { project.protected_for?(ref) } + strong_memoize(:protected_ref) { project.protected_for?(git_ref) } end def legacy_trigger @@ -642,7 +635,7 @@ module Ci def all_merge_requests @all_merge_requests ||= if merge_request? - project.merge_requests.where(id: merge_request.id) + project.merge_requests.where(id: merge_request_id) else project.merge_requests.where(source_branch: ref) end @@ -720,14 +713,16 @@ module Ci end def git_ref - if branch? - Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s - elsif merge_request? + if merge_request? + ## + # In the future, we're going to change this ref to + # merge request's merged reference, such as "refs/merge-requests/:iid/merge". + # In order to do that, we have to update GitLab-Runner's source pulling + # logic. + # See https://gitlab.com/gitlab-org/gitlab-runner/merge_requests/1092 Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s - elsif tag? - Gitlab::Git::TAG_REF_PREFIX + ref.to_s else - raise ArgumentError, 'Invalid pipeline type!' + super end end @@ -742,11 +737,5 @@ module Ci project.repository.keep_around(self.sha, self.before_sha) end - - def valid_source - if source.nil? || source == "unknown" - errors.add(:source, "invalid source") - end - end end end diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index c0f16066e0b..2994aaae4aa 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -25,5 +25,15 @@ module Ci merge_request: 10 } end + + # Returns the `Hash` to use for creating the `config_sources` enum for + # `Ci::Pipeline`. + def self.config_sources + { + unknown_source: nil, + repository_source: 1, + auto_devops_source: 2 + } + end end end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 2693386443a..8249199e76f 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -58,8 +58,7 @@ module Ci # BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb` scope :deprecated_shared, -> { instance_type } - # this should get replaced with `project_type.or(group_type)` once using Rails5 - scope :deprecated_specific, -> { where(runner_type: [runner_types[:project_type], runner_types[:group_type]]) } + scope :deprecated_specific, -> { project_type.or(group_type) } scope :belonging_to_project, -> (project_id) { joins(:runner_projects).where(ci_runner_projects: { project_id: project_id }) @@ -67,7 +66,7 @@ module Ci scope :belonging_to_parent_group_of_project, -> (project_id) { project_groups = ::Group.joins(:projects).where(projects: { id: project_id }) - hierarchy_groups = Gitlab::GroupHierarchy.new(project_groups).base_and_ancestors + hierarchy_groups = Gitlab::ObjectHierarchy.new(project_groups).base_and_ancestors joins(:groups).where(namespaces: { id: hierarchy_groups }) } diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index 74ef7c7e145..c758577815a 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -3,7 +3,7 @@ module Clusters module Applications class CertManager < ActiveRecord::Base - VERSION = 'v0.5.0'.freeze + VERSION = 'v0.5.2'.freeze self.table_name = 'clusters_applications_cert_managers' diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 8f8790585a3..7799f069742 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -23,7 +23,7 @@ module Clusters FETCH_IP_ADDRESS_DELAY = 30.seconds state_machine :status do - before_transition any => [:installed] do |application| + after_transition any => [:installed] do |application| application.run_after_commit do ClusterWaitForIngressIpAddressWorker.perform_in( FETCH_IP_ADDRESS_DELAY, application.name, application.id) diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 168a24da738..0a3168afe68 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -3,9 +3,9 @@ module Clusters module Applications class Knative < ActiveRecord::Base - VERSION = '0.1.3'.freeze + VERSION = '0.2.2'.freeze REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'.freeze - + METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml'.freeze FETCH_IP_ADDRESS_DELAY = 30.seconds self.table_name = 'clusters_applications_knative' @@ -20,7 +20,7 @@ module Clusters self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] } state_machine :status do - before_transition any => [:installed] do |application| + after_transition any => [:installed] do |application| application.run_after_commit do ClusterWaitForIngressIpAddressWorker.perform_in( FETCH_IP_ADDRESS_DELAY, application.name, application.id) @@ -49,7 +49,8 @@ module Clusters rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files, - repository: REPOSITORY + repository: REPOSITORY, + postinstall: install_knative_metrics ) end @@ -94,6 +95,10 @@ module Clusters rescue Kubeclient::ResourceNotFoundError [] end + + def install_knative_metrics + ["kubectl apply -f #{METRICS_CONFIG}"] if cluster.application_prometheus_available? + end end end end diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 46d0388a464..e25be522d68 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -50,7 +50,8 @@ module Clusters version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, - files: files + files: files, + postinstall: install_knative_metrics ) end @@ -74,6 +75,10 @@ module Clusters def kube_client cluster&.kubeclient&.core_client end + + def install_knative_metrics + ["kubectl apply -f #{Clusters::Applications::Knative::METRICS_CONFIG}"] if cluster.application_knative_available? + end end end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index c931b340b24..0c0247da1fb 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ActiveRecord::Base - VERSION = '0.1.39'.freeze + VERSION = '0.1.43'.freeze self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 7fe43cd2de0..6050955fbd8 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -63,6 +63,7 @@ module Clusters delegate :available?, to: :application_helm, prefix: true, allow_nil: true delegate :available?, to: :application_ingress, prefix: true, allow_nil: true delegate :available?, to: :application_prometheus, prefix: true, allow_nil: true + delegate :available?, to: :application_knative, prefix: true, allow_nil: true enum cluster_type: { instance_type: 1, diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 867f0edcb07..1cc170c8c4d 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -65,6 +65,8 @@ module Clusters abac: 2 } + default_value_for :authorization_type, :rbac + def actual_namespace if namespace.present? namespace @@ -106,7 +108,7 @@ module Clusters def terminals(environment) with_reactive_cache do |data| pods = filter_by_label(data[:pods], app: environment.slug) - terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) } + terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }.compact terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } end end @@ -228,7 +230,7 @@ module Clusters return unless namespace_changed? run_after_commit do - ClusterPlatformConfigureWorker.perform_async(cluster_id) + ClusterConfigureWorker.perform_async(cluster_id) end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index a422a0995ff..01f4c58daa1 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -469,6 +469,10 @@ class Commit !!merged_merge_request(user) end + def cache_key + "commit:#{sha}" + end + private def commit_reference(from, referable_commit_id, full: false) diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index b42236c1fa2..4687ec7d166 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -43,7 +43,18 @@ module Avatarable end def avatar_path(only_path: true, size: nil) - return unless self[:avatar].present? + unless self.try(:id) + return uncached_avatar_path(only_path: only_path, size: size) + end + + # Cache this avatar path only within the request because avatars in + # object storage may be generated with time-limited, signed URLs. + key = "#{self.class.name}:#{self.id}:#{only_path}:#{size}" + Gitlab::SafeRequestStore[key] ||= uncached_avatar_path(only_path: only_path, size: size) + end + + def uncached_avatar_path(only_path: true, size: nil) + return unless self.try(:avatar).present? asset_host = ActionController::Base.asset_host use_asset_host = asset_host.present? diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb index f20f01486a5..dc80f8d62f4 100644 --- a/app/models/concerns/blob_like.rb +++ b/app/models/concerns/blob_like.rb @@ -28,7 +28,7 @@ module BlobLike nil end - def binary? + def binary_in_repo? false end diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb index 75592bb63e2..3d60f6924c1 100644 --- a/app/models/concerns/cacheable_attributes.rb +++ b/app/models/concerns/cacheable_attributes.rb @@ -23,7 +23,12 @@ module CacheableAttributes end def build_from_defaults(attributes = {}) - new(defaults.merge(attributes)) + final_attributes = defaults + .merge(attributes) + .stringify_keys + .slice(*column_names) + + new(final_attributes) end def cached diff --git a/app/models/concerns/descendant.rb b/app/models/concerns/descendant.rb new file mode 100644 index 00000000000..4c436522122 --- /dev/null +++ b/app/models/concerns/descendant.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Descendant + extend ActiveSupport::Concern + + class_methods do + def supports_nested_objects? + Gitlab::Database.postgresql? + end + end +end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index 266c37fa3a1..e4e5928f5cf 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -9,7 +9,7 @@ module DiscussionOnDiff included do delegate :line_code, :original_line_code, - :diff_file, + :note_diff_file, :diff_line, :active?, :created_at_diff?, @@ -39,6 +39,7 @@ module DiscussionOnDiff # Returns an array of at most 16 highlighted lines above a diff note def truncated_diff_lines(highlight: true, diff_limit: nil) + return [] unless on_text? return [] if diff_line.nil? && first_note.is_a?(LegacyDiffNote) diff_limit = [diff_limit, NUMBER_OF_TRUNCATED_DIFF_LINES].compact.min @@ -59,6 +60,13 @@ module DiscussionOnDiff prev_lines end + def diff_file + strong_memoize(:diff_file) do + # Falling back here is important as `note_diff_files` are created async. + fetch_preloaded_diff_file || first_note.diff_file + end + end + def line_code_in_diffs(diff_refs) if active?(diff_refs) line_code @@ -66,4 +74,15 @@ module DiscussionOnDiff original_line_code end end + + private + + def fetch_preloaded_diff_file + fetch_preloaded_diff = + context_noteable && + context_noteable.preloads_discussion_diff_highlighting? && + note_diff_file + + context_noteable.discussions_diffs.find_by_id(note_diff_file.id) if fetch_preloaded_diff + end end diff --git a/app/models/concerns/enum_with_nil.rb b/app/models/concerns/enum_with_nil.rb index 23acfe9a55f..6d0a21cf070 100644 --- a/app/models/concerns/enum_with_nil.rb +++ b/app/models/concerns/enum_with_nil.rb @@ -16,7 +16,7 @@ module EnumWithNil # E.g. for enum_with_nil failure_reason: { unknown_failure: nil } # this overrides auto-generated method `unknown_failure?` define_method("#{key_with_nil}?") do - Gitlab.rails5? ? self[name].nil? : super() + self[name].nil? end # E.g. for enum_with_nil failure_reason: { unknown_failure: nil } @@ -24,7 +24,6 @@ module EnumWithNil define_method(name) do orig = super() - return orig unless Gitlab.rails5? return orig unless orig.nil? self.class.public_send(name.to_s.pluralize).key(nil) # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/models/concerns/has_ref.rb b/app/models/concerns/has_ref.rb new file mode 100644 index 00000000000..d7089294efc --- /dev/null +++ b/app/models/concerns/has_ref.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module HasRef + extend ActiveSupport::Concern + + def branch? + !tag? + end + + def git_ref + if branch? + Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s + elsif tag? + Gitlab::Git::TAG_REF_PREFIX + ref.to_s + end + end +end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index e44a069b730..055ffe04646 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -42,7 +42,7 @@ module Milestoneish def issues_visible_to_user(user) memoize_per_user(user, :issues_visible_to_user) do IssuesFinder.new(user, issues_finder_params) - .execute.preload(:assignees).where(milestone_id: milestoneish_ids) + .execute.preload(:assignees).where(milestone_id: milestoneish_id) end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index eb315058c3a..29476654bf7 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -26,10 +26,18 @@ module Noteable DiscussionNote.noteable_types.include?(base_class_name) end + def supports_suggestion? + false + end + def discussions_rendered_on_frontend? false end + def preloads_discussion_diff_highlighting? + false + end + def discussion_notes notes end diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb index 69554f18ea2..4bb4ffe2a8e 100644 --- a/app/models/concerns/redis_cacheable.rb +++ b/app/models/concerns/redis_cacheable.rb @@ -49,10 +49,6 @@ module RedisCacheable end def cast_value_from_cache(attribute, value) - if Gitlab.rails5? - self.class.type_for_attribute(attribute.to_s).cast(value) - else - self.class.column_for_attribute(attribute).type_cast_from_database(value) - end + self.class.type_for_attribute(attribute.to_s).cast(value) end end diff --git a/app/models/dashboard_group_milestone.rb b/app/models/dashboard_group_milestone.rb index 32e8104125c..9bcc95e35a5 100644 --- a/app/models/dashboard_group_milestone.rb +++ b/app/models/dashboard_group_milestone.rb @@ -5,9 +5,8 @@ class DashboardGroupMilestone < GlobalMilestone attr_reader :group_name - override :initialize def initialize(milestone) - super(milestone.title, Array(milestone)) + super @group_name = milestone.group.full_name end @@ -19,22 +18,4 @@ class DashboardGroupMilestone < GlobalMilestone .active .map { |m| new(m) } end - - override :group_milestone? - def group_milestone? - @first_milestone.group_milestone? - end - - override :milestoneish_ids - def milestoneish_ids - milestones.map(&:id) - end - - def group - @first_milestone.group - end - - def iid - @first_milestone.iid - end end diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb index 96bc8090b81..9b377b70e5b 100644 --- a/app/models/dashboard_milestone.rb +++ b/app/models/dashboard_milestone.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true class DashboardMilestone < GlobalMilestone - def issues_finder_params - { authorized_only: true } + attr_reader :project_name + + def initialize(milestone) + super + + @project_name = milestone.project.full_name end - def dashboard_milestone? + def project_milestone? true end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index c32008aa9c7..279603496b0 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -66,10 +66,23 @@ class DiffNote < Note self.original_position.diff_refs == diff_refs end + def supports_suggestion? + return false unless noteable.supports_suggestion? && on_text? + # We don't want to trigger side-effects of `diff_file` call. + return false unless file = fetch_diff_file + return false unless line = file.line_for_position(self.original_position) + + line&.suggestible? + end + def discussion_first_note? self == discussion.first_note end + def banzai_render_context(field) + super.merge(suggestions_filter_enabled: supports_suggestion?) + end + private def enqueue_diff_file_creation_job diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb index 1176861a827..527ee33b83b 100644 --- a/app/models/diff_viewer/base.rb +++ b/app/models/diff_viewer/base.rb @@ -18,7 +18,7 @@ module DiffViewer def initialize(diff_file) @diff_file = diff_file - @initially_binary = diff_file.binary? + @initially_binary = diff_file.binary_in_repo? end def self.partial_path @@ -48,7 +48,7 @@ module DiffViewer def self.can_render_blob?(blob, verify_binary: true) return true if blob.nil? - return false if verify_binary && binary? != blob.binary? + return false if verify_binary && binary? != blob.binary_in_repo? return true if extensions&.include?(blob.extension) return true if file_types&.include?(blob.file_type) @@ -70,20 +70,49 @@ module DiffViewer end def binary_detected_after_load? - !@initially_binary && diff_file.binary? + !@initially_binary && diff_file.binary_in_repo? end # This method is used on the server side to check whether we can attempt to - # render the diff_file at all. Human-readable error messages are found in the - # `BlobHelper#diff_render_error_reason` helper. + # render the diff_file at all. The human-readable error message can be + # retrieved by #render_error_message. def render_error if too_large? :too_large end end + def render_error_message + return unless render_error + + _("This %{viewer} could not be displayed because %{reason}. You can %{options} instead.") % + { + viewer: switcher_title, + reason: render_error_reason, + options: render_error_options.to_sentence(two_words_connector: _(' or '), last_word_connector: _(', or ')) + } + end + def prepare! # To be overridden by subclasses end + + private + + def render_error_options + options = [] + + blob_url = Gitlab::Routing.url_helpers.project_blob_path(diff_file.repository.project, + File.join(diff_file.content_sha, diff_file.file_path)) + options << ActionController::Base.helpers.link_to(_('view the blob'), blob_url) + + options + end + + def render_error_reason + if render_error == :too_large + _("it is too large") + end + end end end diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb index c356c2ca50e..350bef1d42a 100644 --- a/app/models/diff_viewer/image.rb +++ b/app/models/diff_viewer/image.rb @@ -9,6 +9,6 @@ module DiffViewer self.extensions = UploaderHelper::IMAGE_EXT self.binary = true self.switcher_icon = 'picture-o' - self.switcher_title = 'image diff' + self.switcher_title = _('image diff') end end diff --git a/app/models/diff_viewer/rich.rb b/app/models/diff_viewer/rich.rb index 2faa1be6567..5caefa2031c 100644 --- a/app/models/diff_viewer/rich.rb +++ b/app/models/diff_viewer/rich.rb @@ -7,7 +7,7 @@ module DiffViewer included do self.type = :rich self.switcher_icon = 'file-text-o' - self.switcher_title = 'rendered diff' + self.switcher_title = _('rendered diff') end end end diff --git a/app/models/diff_viewer/server_side.rb b/app/models/diff_viewer/server_side.rb index 977204e6c97..0877c9dddec 100644 --- a/app/models/diff_viewer/server_side.rb +++ b/app/models/diff_viewer/server_side.rb @@ -24,5 +24,17 @@ module DiffViewer super end + + private + + def render_error_reason + return super unless render_error == :server_side_but_stored_externally + + if diff_file.external_storage == :lfs + _('it is stored in LFS') + else + _('it is stored externally') + end + end end end diff --git a/app/models/diff_viewer/simple.rb b/app/models/diff_viewer/simple.rb index 8d28ca5239a..929d8ad5a7e 100644 --- a/app/models/diff_viewer/simple.rb +++ b/app/models/diff_viewer/simple.rb @@ -7,7 +7,7 @@ module DiffViewer included do self.type = :simple self.switcher_icon = 'code' - self.switcher_title = 'source diff' + self.switcher_title = _('source diff') end end end diff --git a/app/models/environment.rb b/app/models/environment.rb index 934828946b9..cdfe3b7c023 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Environment < ActiveRecord::Base + include Gitlab::Utils::StrongMemoize # Used to generate random suffixes for the slug LETTERS = 'a'..'z' NUMBERS = '0'..'9' @@ -231,7 +232,9 @@ class Environment < ActiveRecord::Base end def deployment_platform - project.deployment_platform(environment: self.name) + strong_memoize(:deployment_platform) do + project.deployment_platform(environment: self.name) + end end private diff --git a/app/models/event.rb b/app/models/event.rb index 2ceef412af5..6a35bca72c5 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -114,19 +114,6 @@ class Event < ActiveRecord::Base end end - # Remove this method when removing Gitlab.rails5? code. - def subclass_from_attributes(attrs) - return super if Gitlab.rails5? - - # Without this Rails will keep calling this method on the returned class, - # resulting in an infinite loop. - return unless self == Event - - action = attrs.with_indifferent_access[inheritance_column].to_i - - PushEvent if action == PUSHED - end - # Update Gitlab::ContributionsCalendar#activity_dates if this changes def contributions where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)", diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 085ffd16c6a..4e82f3fed27 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -3,69 +3,78 @@ class GlobalMilestone include Milestoneish - EPOCH = DateTime.parse('1970-01-01') STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze - attr_accessor :title, :milestones + attr_reader :milestone alias_attribute :name, :title + delegate :title, :state, :due_date, :start_date, :participants, :project, :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title, :milestoneish_id, to: :milestone + + def to_hash + { + name: title, + title: title, + group_name: group&.full_name, + project_name: project&.full_name + } + end + def for_display - @first_milestone + @milestone end def self.build_collection(projects, params) - params = - { project_ids: projects.map(&:id), state: params[:state] } - - child_milestones = MilestonesFinder.new(params).execute # rubocop: disable CodeReuse/Finder - - milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped| - milestones_relation = Milestone.where(id: grouped.map(&:id)) - new(title, milestones_relation) - end + items = Milestone.of_projects(projects) + .reorder_by_due_date_asc + .order_by_name_asc - milestones.sort_by { |milestone| milestone.due_date || EPOCH } + Milestone.filter_by_state(items, params[:state]).map { |m| new(m) } end + # necessary for legacy milestones def self.build(projects, title) - child_milestones = Milestone.of_projects(projects).where(title: title) - return if child_milestones.blank? + milestones = Milestone.of_projects(projects).where(title: title) + return if milestones.blank? - new(title, child_milestones) + new(milestones.first) end - def self.count_by_state(milestones_by_state_and_title, state) - milestones_by_state_and_title.count do |(milestone_state, _), _| - milestone_state == state + def self.states_count(projects, group = nil) + legacy_group_milestones_count = legacy_group_milestone_states_count(projects) + group_milestones_count = group_milestones_states_count(group) + + legacy_group_milestones_count.merge(group_milestones_count) do |k, legacy_group_milestones_count, group_milestones_count| + legacy_group_milestones_count + group_milestones_count end end - private_class_method :count_by_state - def initialize(title, milestones) - @title = title - @name = title - @milestones = milestones - @first_milestone = milestones.find {|m| m.description.present? } || milestones.first - end + def self.group_milestones_states_count(group) + return STATE_COUNT_HASH unless group - def milestoneish_ids - milestones.select(:id) - end + counts_by_state = Milestone.of_groups(group).count_by_state - def safe_title - @title.to_slug.normalize.to_s + { + opened: counts_by_state['active'] || 0, + closed: counts_by_state['closed'] || 0, + all: counts_by_state.values.sum + } end - def projects - @projects ||= Project.for_milestones(milestoneish_ids) - end + def self.legacy_group_milestone_states_count(projects) + return STATE_COUNT_HASH unless projects - def state - milestones.each do |milestone| - return 'active' if milestone.state != 'closed' - end + # We need to reorder(nil) on the projects, because the controller passes them in sorted. + relation = Milestone.of_projects(projects.reorder(nil)).count_by_state - 'closed' + { + opened: relation['active'] || 0, + closed: relation['closed'] || 0, + all: relation.values.sum + } + end + + def initialize(milestone) + @milestone = milestone end def active? @@ -77,37 +86,14 @@ class GlobalMilestone end def issues - @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels) + @issues ||= Issue.of_milestones(milestone).includes(:project, :assignees, :labels) end def merge_requests - @merge_requests ||= MergeRequest.of_milestones(milestoneish_ids).includes(:target_project, :assignee, :labels) - end - - def participants - @participants ||= milestones.map(&:participants).flatten.uniq + @merge_requests ||= MergeRequest.of_milestones(milestone).includes(:target_project, :assignee, :labels) end def labels - @labels ||= GlobalLabel.build_collection(milestones.includes(:labels).map(&:labels).flatten) - .sort_by!(&:title) - end - - def due_date - return @due_date if defined?(@due_date) - - @due_date = - if @milestones.all? { |x| x.due_date == @milestones.first.due_date } - @milestones.first.due_date - end - end - - def start_date - return @start_date if defined?(@start_date) - - @start_date = - if @milestones.all? { |x| x.start_date == @milestones.first.start_date } - @milestones.first.start_date - end + @labels ||= GlobalLabel.build_collection(milestone.labels).sort_by!(&:title) end end diff --git a/app/models/group.rb b/app/models/group.rb index 233747cc2c2..edac2444c4d 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -10,6 +10,7 @@ class Group < Namespace include Referable include SelectForProjectAuthorization include LoadedInGroupList + include Descendant include GroupDescendant include TokenAuthenticatable include WithUploads @@ -63,10 +64,6 @@ class Group < Namespace after_update :path_changed_hook, if: :path_changed? class << self - def supports_nested_groups? - Gitlab::Database.postgresql? - end - def sort_by_attribute(method) if method == 'storage_size_desc' # storage_size is a virtual column so we need to diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb index 9dfaebacc83..a58537de319 100644 --- a/app/models/group_milestone.rb +++ b/app/models/group_milestone.rb @@ -1,18 +1,35 @@ # frozen_string_literal: true # Group Milestones are milestones that can be shared among many projects within the same group class GroupMilestone < GlobalMilestone - attr_accessor :group + attr_reader :group, :milestones def self.build_collection(group, projects, params) - super(projects, params).each do |milestone| - milestone.group = group + params = + { state: params[:state] } + + project_milestones = Milestone.of_projects(projects) + child_milestones = Milestone.filter_by_state(project_milestones, params[:state]) + grouped_milestones = child_milestones.group_by(&:title) + + grouped_milestones.map do |title, grouped| + new(title, grouped, group) end end def self.build(group, projects, title) - super(projects, title).tap do |milestone| - milestone&.group = group - end + child_milestones = Milestone.of_projects(projects).where(title: title) + return if child_milestones.blank? + + new(title, child_milestones, group) + end + + def initialize(title, milestones, group) + @milestones = milestones + @group = group + end + + def milestone + @milestone ||= milestones.find { |m| m.description.present? } || milestones.first end def issues_finder_params diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index a13cac73d04..6092c56b925 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -48,8 +48,8 @@ class MergeRequest < ActiveRecord::Base # is the inverse of MergeRequest#merge_request_diff, which means it may not be # the latest diff, because we could have loaded any diff from this particular # MR. If we haven't already loaded a diff, then it's fine to load the latest. - def merge_request_diff(*args) - fallback = latest_merge_request_diff if args.empty? && !association(:merge_request_diff).loaded? + def merge_request_diff + fallback = latest_merge_request_diff unless association(:merge_request_diff).loaded? fallback || super end @@ -105,7 +105,9 @@ class MergeRequest < ActiveRecord::Base before_transition any => :opened do |merge_request| merge_request.merge_jid = nil + end + after_transition any => :opened do |merge_request| merge_request.run_after_commit do UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) end @@ -363,6 +365,10 @@ class MergeRequest < ActiveRecord::Base end end + def supports_suggestion? + true + end + # Calls `MergeWorker` to proceed with the merge process and # updates `merge_jid` with the MergeWorker#jid. # This helps tracking enqueued and ongoing merge jobs. @@ -404,6 +410,28 @@ class MergeRequest < ActiveRecord::Base merge_request_diffs.where.not(id: merge_request_diff.id) end + def preloads_discussion_diff_highlighting? + true + end + + def preload_discussions_diff_highlight + preloadable_files = note_diff_files.for_commit_or_unresolved + + discussions_diffs.load_highlight(preloadable_files.pluck(:id)) + end + + def discussions_diffs + strong_memoize(:discussions_diffs) do + Gitlab::DiscussionsDiff::FileCollection.new(note_diff_files.to_a) + end + end + + def note_diff_files + NoteDiffFile + .where(diff_note: discussion_notes) + .includes(diff_note: :project) + end + def diff_size # Calling `merge_request_diff.diffs.real_size` will also perform # highlighting, which we don't need here. @@ -615,10 +643,6 @@ class MergeRequest < ActiveRecord::Base end end - def reload_merge_request_diff - merge_request_diff(true) - end - def viewable_diffs @viewable_diffs ||= merge_request_diffs.viewable.to_a end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 3cc8e2c44bb..f55c39d9912 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -94,6 +94,10 @@ class Milestone < ActiveRecord::Base end end + def count_by_state + reorder(nil).group(:state).count + end + def predefined?(milestone) milestone == Any || milestone == None || @@ -212,10 +216,10 @@ class Milestone < ActiveRecord::Base end def reference_link_text(from = nil) - self.title + self.class.reference_prefix + self.title end - def milestoneish_ids + def milestoneish_id id end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 3c9b1d32a53..a0bebc5e9a2 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -175,16 +175,16 @@ class Namespace < ActiveRecord::Base # Returns all ancestors, self, and descendants of the current namespace. def self_and_hierarchy - Gitlab::GroupHierarchy + Gitlab::ObjectHierarchy .new(self.class.where(id: id)) - .all_groups + .all_objects end # Returns all the ancestors of the current namespaces. def ancestors return self.class.none unless parent_id - Gitlab::GroupHierarchy + Gitlab::ObjectHierarchy .new(self.class.where(id: parent_id)) .base_and_ancestors end @@ -192,27 +192,27 @@ class Namespace < ActiveRecord::Base # returns all ancestors upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned def ancestors_upto(top = nil, hierarchy_order: nil) - Gitlab::GroupHierarchy.new(self.class.where(id: id)) + Gitlab::ObjectHierarchy.new(self.class.where(id: id)) .ancestors(upto: top, hierarchy_order: hierarchy_order) end def self_and_ancestors return self.class.where(id: id) unless parent_id - Gitlab::GroupHierarchy + Gitlab::ObjectHierarchy .new(self.class.where(id: id)) .base_and_ancestors end # Returns all the descendants of the current namespace. def descendants - Gitlab::GroupHierarchy + Gitlab::ObjectHierarchy .new(self.class.where(parent_id: id)) .base_and_descendants end def self_and_descendants - Gitlab::GroupHierarchy + Gitlab::ObjectHierarchy .new(self.class.where(id: id)) .base_and_descendants end @@ -293,7 +293,7 @@ class Namespace < ActiveRecord::Base end def force_share_with_group_lock_on_descendants - return unless Group.supports_nested_groups? + return unless Group.supports_nested_objects? # We can't use `descendants.update_all` since Rails will throw away the WITH # RECURSIVE statement. We also can't use WHERE EXISTS since we can't use @@ -306,6 +306,7 @@ class Namespace < ActiveRecord::Base def write_projects_repository_config all_projects.find_each do |project| project.write_repository_config + project.track_project_repository end end end diff --git a/app/models/note.rb b/app/models/note.rb index 17c7d97fa0a..becf14e9785 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -69,6 +69,12 @@ class Note < ActiveRecord::Base belongs_to :last_edited_by, class_name: 'User' has_many :todos + + # The delete_all definition is required here in order + # to generate the correct DELETE sql for + # suggestions.delete_all calls + has_many :suggestions, -> { order(:relative_order) }, + inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :system_note_metadata has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id @@ -110,7 +116,7 @@ class Note < ActiveRecord::Base scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> do includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji, - :system_note_metadata, :note_diff_file) + :system_note_metadata, :note_diff_file, :suggestions) end scope :with_notes_filter, -> (notes_filter) do @@ -226,6 +232,10 @@ class Note < ActiveRecord::Base Gitlab::HookData::NoteBuilder.new(self).build end + def supports_suggestion? + false + end + def for_commit? noteable_type == "Commit" end diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb index 27aef7adc48..e369122003e 100644 --- a/app/models/note_diff_file.rb +++ b/app/models/note_diff_file.rb @@ -3,7 +3,22 @@ class NoteDiffFile < ActiveRecord::Base include DiffFile + scope :for_commit_or_unresolved, -> do + joins(:diff_note).where("resolved_at IS NULL OR noteable_type = 'Commit'") + end + + delegate :original_position, :project, to: :diff_note + belongs_to :diff_note, inverse_of: :note_diff_file validates :diff_note, presence: true + + def raw_diff_file + raw_diff = Gitlab::Git::Diff.new(to_hash) + + Gitlab::Diff::File.new(raw_diff, + repository: project.repository, + diff_refs: original_position.diff_refs, + unique_identifier: id) + end end diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 47da0209c2f..ad6a008dee8 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -18,6 +18,7 @@ class PoolRepository < ActiveRecord::Base state :scheduled state :ready state :failed + state :obsolete event :schedule do transition none: :scheduled @@ -31,6 +32,10 @@ class PoolRepository < ActiveRecord::Base transition all => :failed end + event :mark_obsolete do + transition all => :obsolete + end + state all - [:ready] do def joinable? false @@ -54,6 +59,12 @@ class PoolRepository < ActiveRecord::Base ::ObjectPool::ScheduleJoinWorker.perform_async(pool.id) end end + + after_transition any => :obsolete do |pool, _| + pool.run_after_commit do + ::ObjectPool::DestroyWorker.perform_async(pool.id) + end + end end def create_object_pool @@ -71,10 +82,10 @@ class PoolRepository < ActiveRecord::Base end # This RPC can cause data loss, as not all objects are present the local repository - # No execution path yet, will be added through: - # https://gitlab.com/gitlab-org/gitaly/issues/1415 - def delete_repository_alternate(repository) + def unlink_repository(repository) object_pool.unlink_repository(repository.raw) + + mark_obsolete unless member_projects.where.not(id: repository.project.id).exists? end def object_pool diff --git a/app/models/project.rb b/app/models/project.rb index 67262ecce85..58b10662ff0 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -256,7 +256,7 @@ class Project < ActiveRecord::Base # other pipelines, like webide ones, that we won't retrieve # if we use this relation. has_many :ci_pipelines, - -> { Feature.enabled?(:pipeline_ci_sources_only, default_enabled: true) ? ci_sources : all }, + -> { ci_sources }, class_name: 'Ci::Pipeline', inverse_of: :project has_many :stages, class_name: 'Ci::Stage', inverse_of: :project @@ -324,15 +324,14 @@ class Project < ActiveRecord::Base validates :namespace, presence: true validates :name, uniqueness: { scope: :namespace_id } - validates :import_url, url: { protocols: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, - ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS }, - allow_localhost: false, - enforce_user: true }, if: [:external_import?, :import_url_changed?] + validates :import_url, public_url: { protocols: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS }, + ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS }, + enforce_user: true }, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } validate :check_limit, on: :create validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? } - validate :visibility_level_allowed_by_group - validate :visibility_level_allowed_as_fork + validate :visibility_level_allowed_by_group, if: -> { changes.has_key?(:visibility_level) } + validate :visibility_level_allowed_as_fork, if: -> { changes.has_key?(:visibility_level) } validate :check_wiki_path_conflict validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) } validates :repository_storage, @@ -570,7 +569,7 @@ class Project < ActiveRecord::Base # returns all ancestor-groups upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned def ancestors_upto(top = nil, hierarchy_order: nil) - Gitlab::GroupHierarchy.new(Group.where(id: namespace_id)) + Gitlab::ObjectHierarchy.new(Group.where(id: namespace_id)) .base_and_ancestors(upto: top, hierarchy_order: hierarchy_order) end @@ -913,11 +912,16 @@ class Project < ActiveRecord::Base def new_issuable_address(author, address_type) return unless Gitlab::IncomingEmail.supports_issue_creation? && author + # check since this can come from a request parameter + return unless %w(issue merge_request).include?(address_type) + author.ensure_incoming_email_token! - suffix = address_type == 'merge_request' ? '+merge-request' : '' - Gitlab::IncomingEmail.reply_address( - "#{full_path}#{suffix}+#{author.incoming_email_token}") + suffix = address_type.dasherize + + # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-issue@localhost.com + # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-merge-request@localhost.com + Gitlab::IncomingEmail.reply_address("#{full_path_slug}-#{project_id}-#{author.incoming_email_token}-#{suffix}") end def build_commit_note(commit) @@ -1244,10 +1248,8 @@ class Project < ActiveRecord::Base end def track_project_repository - return unless hashed_storage?(:repository) - - project_repo = project_repository || build_project_repository - project_repo.update!(shard_name: repository_storage, disk_path: disk_path) + repository = project_repository || build_project_repository + repository.update!(shard_name: repository_storage, disk_path: disk_path) end def create_repository(force: false) @@ -1736,10 +1738,21 @@ class Project < ActiveRecord::Base end def protected_for?(ref) - if repository.branch_exists?(ref) - ProtectedBranch.protected?(self, ref) - elsif repository.tag_exists?(ref) - ProtectedTag.protected?(self, ref) + raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref) + + resolved_ref = repository.expand_ref(ref) || ref + return false unless Gitlab::Git.tag_ref?(resolved_ref) || Gitlab::Git.branch_ref?(resolved_ref) + + ref_name = if resolved_ref == ref + Gitlab::Git.ref_name(resolved_ref) + else + ref + end + + if Gitlab::Git.branch_ref?(resolved_ref) + ProtectedBranch.protected?(self, ref_name) + elsif Gitlab::Git.tag_ref?(resolved_ref) + ProtectedTag.protected?(self, ref_name) end end @@ -1920,23 +1933,15 @@ class Project < ActiveRecord::Base .where('project_authorizations.project_id = merge_requests.target_project_id') .limit(1) .select(1) - source_of_merge_requests.opened - .where(allow_collaboration: true) - .where('EXISTS (?)', developer_access_exists) + merge_requests_allowing_collaboration.where('EXISTS (?)', developer_access_exists) end - def branch_allows_collaboration?(user, branch_name) - return false unless user - - cache_key = "user:#{user.id}:#{branch_name}:branch_allows_push" - - memoized_results = strong_memoize(:branch_allows_collaboration) do - Hash.new do |result, cache_key| - result[cache_key] = fetch_branch_allows_collaboration?(user, branch_name) - end - end + def any_branch_allows_collaboration?(user) + fetch_branch_allows_collaboration(user) + end - memoized_results[cache_key] + def branch_allows_collaboration?(user, branch_name) + fetch_branch_allows_collaboration(user, branch_name) end def licensed_features @@ -2004,8 +2009,18 @@ class Project < ActiveRecord::Base Feature.enabled?(:object_pools, self) end + def leave_pool_repository + pool_repository&.unlink_repository(repository) + end + private + def merge_requests_allowing_collaboration(source_branch = nil) + relation = source_of_merge_requests.opened.where(allow_collaboration: true) + relation = relation.where(source_branch: source_branch) if source_branch + relation + end + def create_new_pool_repository pool = begin create_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self) @@ -2130,26 +2145,19 @@ class Project < ActiveRecord::Base raise ex end - def fetch_branch_allows_collaboration?(user, branch_name) - check_access = -> do - next false if empty_repo? + def fetch_branch_allows_collaboration(user, branch_name = nil) + return false unless user - merge_requests = source_of_merge_requests.opened - .where(allow_collaboration: true) + Gitlab::SafeRequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_collaboration") do + next false if empty_repo? # Issue for N+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/49322 Gitlab::GitalyClient.allow_n_plus_1_calls do - if branch_name - merge_requests.find_by(source_branch: branch_name)&.can_be_merged_by?(user) - else - merge_requests.any? { |merge_request| merge_request.can_be_merged_by?(user) } + merge_requests_allowing_collaboration(branch_name).any? do |merge_request| + merge_request.can_be_merged_by?(user) end end end - - Gitlab::SafeRequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_collaboration") do - check_access.call - end end def services_templates diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index b801fd84a07..f69edd60003 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -53,7 +53,7 @@ class KubernetesService < DeploymentService end def description - 'Kubernetes / Openshift integration' + 'Kubernetes / OpenShift integration' end def help diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index ce2db9cb44c..5594594a48d 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -5,11 +5,12 @@ class PrometheusMetric < ActiveRecord::Base enum group: { # built-in groups - nginx_ingress: -1, + nginx_ingress_vts: -1, ha_proxy: -2, aws_elb: -3, nginx: -4, kubernetes: -5, + nginx_ingress: -6, # custom/user groups business: 0, @@ -17,6 +18,54 @@ class PrometheusMetric < ActiveRecord::Base system: 2 } + GROUP_DETAILS = { + # built-in groups + nginx_ingress_vts: { + group_title: _('Response metrics (NGINX Ingress VTS)'), + required_metrics: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg), + priority: 10 + }.freeze, + nginx_ingress: { + group_title: _('Response metrics (NGINX Ingress)'), + required_metrics: %w(nginx_ingress_controller_requests nginx_ingress_controller_ingress_upstream_latency_seconds_sum), + priority: 10 + }.freeze, + ha_proxy: { + group_title: _('Response metrics (HA Proxy)'), + required_metrics: %w(haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total), + priority: 10 + }.freeze, + aws_elb: { + group_title: _('Response metrics (AWS ELB)'), + required_metrics: %w(aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum), + priority: 10 + }.freeze, + nginx: { + group_title: _('Response metrics (NGINX)'), + required_metrics: %w(nginx_server_requests nginx_server_requestMsec), + priority: 10 + }.freeze, + kubernetes: { + group_title: _('System metrics (Kubernetes)'), + required_metrics: %w(container_memory_usage_bytes container_cpu_usage_seconds_total), + priority: 5 + }.freeze, + + # custom/user groups + business: { + group_title: _('Business metrics (Custom)'), + priority: 0 + }.freeze, + response: { + group_title: _('Response metrics (Custom)'), + priority: -5 + }.freeze, + system: { + group_title: _('System metrics (Custom)'), + priority: -10 + }.freeze + }.freeze + validates :title, presence: true validates :query, presence: true validates :group, presence: true @@ -28,34 +77,16 @@ class PrometheusMetric < ActiveRecord::Base scope :common, -> { where(common: true) } - GROUP_TITLES = { - # built-in groups - nginx_ingress: _('Response metrics (NGINX Ingress)'), - ha_proxy: _('Response metrics (HA Proxy)'), - aws_elb: _('Response metrics (AWS ELB)'), - nginx: _('Response metrics (NGINX)'), - kubernetes: _('System metrics (Kubernetes)'), - - # custom/user groups - business: _('Business metrics (Custom)'), - response: _('Response metrics (Custom)'), - system: _('System metrics (Custom)') - }.freeze - - REQUIRED_METRICS = { - nginx_ingress: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg), - ha_proxy: %w(haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total), - aws_elb: %w(aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum), - nginx: %w(nginx_server_requests nginx_server_requestMsec), - kubernetes: %w(container_memory_usage_bytes container_cpu_usage_seconds_total) - }.freeze + def priority + group_details(group).fetch(:priority) + end def group_title - GROUP_TITLES[group.to_sym] + group_details(group).fetch(:group_title) end def required_metrics - REQUIRED_METRICS[group.to_sym].to_a.map(&:to_s) + group_details(group).fetch(:required_metrics, []).map(&:to_s) end def to_query_metric @@ -86,4 +117,10 @@ class PrometheusMetric < ActiveRecord::Base }] end end + + private + + def group_details(group) + GROUP_DETAILS.fetch(group.to_sym) + end end diff --git a/app/models/release.rb b/app/models/release.rb index cba80ad30ca..df3dfe1cf2f 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -2,10 +2,39 @@ class Release < ActiveRecord::Base include CacheMarkdownField + include Gitlab::Utils::StrongMemoize cache_markdown_field :description belongs_to :project + # releases prior to 11.7 have no author + belongs_to :author, class_name: 'User' validates :description, :project, :tag, presence: true + + scope :sorted, -> { order(created_at: :desc) } + + delegate :repository, to: :project + + def commit + strong_memoize(:commit) do + repository.commit(actual_sha) + end + end + + def tag_missing? + actual_tag.nil? + end + + private + + def actual_sha + sha || actual_tag&.dereferenced_target + end + + def actual_tag + strong_memoize(:actual_tag) do + repository.find_tag(tag) + end + end end diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index 5a6895aefab..a3fa67c72bf 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -17,7 +17,7 @@ class RemoteMirror < ActiveRecord::Base belongs_to :project, inverse_of: :remote_mirrors - validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true } + validates :url, presence: true, public_url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true } before_save :set_new_remote_name, if: :mirror_url_changed? diff --git a/app/models/repository.rb b/app/models/repository.rb index 015a179f374..b19ae2e0e6a 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -25,6 +25,7 @@ class Repository delegate :bundle_to_disk, to: :raw_repository CreateTreeError = Class.new(StandardError) + AmbiguousRefError = Class.new(StandardError) # Methods that cache data from the Git repository. # @@ -181,6 +182,18 @@ class Repository tags.find { |tag| tag.name == name } end + def ambiguous_ref?(ref) + tag_exists?(ref) && branch_exists?(ref) + end + + def expand_ref(ref) + if tag_exists?(ref) + Gitlab::Git::TAG_REF_PREFIX + ref + elsif branch_exists?(ref) + Gitlab::Git::BRANCH_REF_PREFIX + ref + end + end + def add_branch(user, branch_name, ref) branch = raw_repository.add_branch(branch_name, user: user, target: ref) diff --git a/app/models/service.rb b/app/models/service.rb index 5b8bf6e7cf0..9dcb0aab0a3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -210,11 +210,7 @@ class Service < ActiveRecord::Base class_eval %{ def #{arg}? # '!!' is used because nil or empty string is converted to nil - if Gitlab.rails5? - !!ActiveRecord::Type::Boolean.new.cast(#{arg}) - else - !!ActiveRecord::Type::Boolean.new.type_cast_from_database(#{arg}) - end + !!ActiveRecord::Type::Boolean.new.cast(#{arg}) end } end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 11856b55902..f9b23bbbf6c 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -175,6 +175,12 @@ class Snippet < ActiveRecord::Base :visibility_level end + def embeddable? + ability = project_id? ? :read_project_snippet : :read_personal_snippet + + Ability.allowed?(nil, ability, self) + end + def notes_with_associations notes.includes(:author) end diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb new file mode 100644 index 00000000000..c76b8e71507 --- /dev/null +++ b/app/models/suggestion.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Suggestion < ApplicationRecord + belongs_to :note, inverse_of: :suggestions + validates :note, presence: true + validates :commit_id, presence: true, if: :applied? + + delegate :original_position, :position, :diff_file, + :noteable, to: :note + + def project + noteable.source_project + end + + def branch + noteable.source_branch + end + + # For now, suggestions only serve as a way to send patches that + # will change a single line (being able to apply multiple in the same place), + # which explains `from_line` and `to_line` being the same line. + # We'll iterate on that in https://gitlab.com/gitlab-org/gitlab-ce/issues/53310 + # when allowing multi-line suggestions. + def from_line + position.new_line + end + alias_method :to_line, :from_line + + def from_original_line + original_position.new_line + end + alias_method :to_original_line, :from_original_line + + # `from_line_index` and `to_line_index` represents diff/blob line numbers in + # index-like way (N-1). + def from_line_index + from_line - 1 + end + alias_method :to_line_index, :from_line_index + + def appliable? + return false unless note.supports_suggestion? + + !applied? && + noteable.opened? && + different_content? && + note.active? + end + + private + + def different_content? + from_content != to_content + end +end diff --git a/app/models/todo.rb b/app/models/todo.rb index 7b64615f699..d9b86d941b6 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -4,6 +4,11 @@ class Todo < ActiveRecord::Base include Sortable include FromUnion + # Time to wait for todos being removed when not visible for user anymore. + # Prevents TODOs being removed by mistake, for example, removing access from a user + # and giving it back again. + WAIT_FOR_DELETE = 1.hour + ASSIGNED = 1 MENTIONED = 2 BUILD_FAILED = 3 diff --git a/app/models/user.rb b/app/models/user.rb index dbd754dd25a..26fd2d903a1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -130,6 +130,7 @@ class User < ActiveRecord::Base has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :merge_requests, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :events, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent + has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :abuse_report, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent @@ -708,13 +709,13 @@ class User < ActiveRecord::Base # Returns the groups a user is a member of, either directly or through a parent group def membership_groups - Gitlab::GroupHierarchy.new(groups).base_and_descendants + Gitlab::ObjectHierarchy.new(groups).base_and_descendants end # Returns a relation of groups the user has access to, including their parent # and child groups (recursively). def all_expanded_groups - Gitlab::GroupHierarchy.new(groups).all_groups + Gitlab::ObjectHierarchy.new(groups).all_objects end def expanded_groups_requiring_two_factor_authentication @@ -1152,7 +1153,7 @@ class User < ActiveRecord::Base end def manageable_groups - Gitlab::GroupHierarchy.new(owned_or_maintainers_groups).base_and_descendants + Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants end def namespaces @@ -1421,6 +1422,10 @@ class User < ActiveRecord::Base todos.where(id: ids) end + def pending_todo_for(target) + todos.find_by(target: target, state: :pending) + end + # @deprecated alias_method :owned_or_masters_groups, :owned_or_maintainers_groups diff --git a/app/policies/concerns/clusterable_actions.rb b/app/policies/concerns/clusterable_actions.rb new file mode 100644 index 00000000000..08ddd742ea9 --- /dev/null +++ b/app/policies/concerns/clusterable_actions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ClusterableActions + private + + # Overridden on EE module + def multiple_clusters_available? + false + end + + def clusterable_has_clusters? + !subject.clusters.empty? + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 6b4e56ef5e4..c25766a5af8 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class GroupPolicy < BasePolicy + include ClusterableActions + desc "Group is public" with_options scope: :subject, score: 0 condition(:public_group) { @subject.public? } @@ -16,7 +18,7 @@ class GroupPolicy < BasePolicy condition(:maintainer) { access_level >= GroupMember::MAINTAINER } condition(:reporter) { access_level >= GroupMember::REPORTER } - condition(:nested_groups_supported, scope: :global) { Group.supports_nested_groups? } + condition(:nested_groups_supported, scope: :global) { Group.supports_nested_objects? } condition(:has_parent, scope: :subject) { @subject.has_parent? } condition(:share_with_group_locked, scope: :subject) { @subject.share_with_group_lock? } @@ -27,6 +29,9 @@ class GroupPolicy < BasePolicy GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true }).execute.any? end + condition(:has_clusters, scope: :subject) { clusterable_has_clusters? } + condition(:can_have_multiple_clusters) { multiple_clusters_available? } + with_options scope: :subject, score: 0 condition(:request_access_enabled) { @subject.request_access_enabled } @@ -40,11 +45,12 @@ class GroupPolicy < BasePolicy rule { guest }.policy do enable :read_group + enable :read_list enable :upload_file enable :read_label end - rule { admin } .enable :read_group + rule { admin }.enable :read_group rule { has_projects }.policy do enable :read_group @@ -66,6 +72,7 @@ class GroupPolicy < BasePolicy enable :admin_pipeline enable :admin_build enable :read_cluster + enable :add_cluster enable :create_cluster enable :update_cluster enable :admin_cluster @@ -105,6 +112,8 @@ class GroupPolicy < BasePolicy rule { owner & (~share_with_group_locked | ~has_parent | ~parent_share_with_group_locked | can_change_parent_share_with_group_lock) }.enable :change_share_with_group_lock + rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster + def access_level return GroupMember::NO_ACCESS if @user.nil? diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 6d8b575102e..ecb2797d1d9 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -11,7 +11,7 @@ class IssuablePolicy < BasePolicy @user && @subject.assignee_or_author?(@user) end - rule { assignee_or_author }.policy do + rule { can?(:guest_access) & assignee_or_author }.policy do enable :read_issue enable :update_issue enable :reopen_issue diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 1c082945299..3146f26bed5 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -2,6 +2,7 @@ class ProjectPolicy < BasePolicy extend ClassMethods + include ClusterableActions READONLY_FEATURES_WHEN_ARCHIVED = %i[ issue @@ -22,6 +23,7 @@ class ProjectPolicy < BasePolicy container_image pages cluster + release ].freeze desc "User is a project owner" @@ -103,6 +105,9 @@ class ProjectPolicy < BasePolicy @subject.feature_available?(:merge_requests, @user) end + condition(:has_clusters, scope: :subject) { clusterable_has_clusters? } + condition(:can_have_multiple_clusters) { multiple_clusters_available? } + features = %w[ merge_requests issues @@ -169,6 +174,7 @@ class ProjectPolicy < BasePolicy enable :read_cycle_analytics enable :award_emoji enable :read_pages_content + enable :read_release end # These abilities are not allowed to admins that are not members of the project, @@ -235,6 +241,8 @@ class ProjectPolicy < BasePolicy enable :update_container_image enable :create_environment enable :create_deployment + enable :create_release + enable :update_release end rule { can?(:maintainer_access) }.policy do @@ -257,10 +265,12 @@ class ProjectPolicy < BasePolicy enable :read_pages enable :update_pages enable :read_cluster + enable :add_cluster enable :create_cluster enable :update_cluster enable :admin_cluster enable :create_environment_terminal + enable :destroy_release end rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror @@ -320,6 +330,7 @@ class ProjectPolicy < BasePolicy prevent :download_code prevent :fork_project prevent :read_commit_status + prevent(*create_read_update_admin_destroy(:release)) end rule { container_registry_disabled }.policy do @@ -349,6 +360,7 @@ class ProjectPolicy < BasePolicy enable :read_commit_status enable :read_container_image enable :download_code + enable :read_release enable :download_wiki_code enable :read_cycle_analytics enable :read_pages_content @@ -381,6 +393,8 @@ class ProjectPolicy < BasePolicy (can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request) end.enable :read_merge_request_iid + rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster + private def team_member? diff --git a/app/policies/release_policy.rb b/app/policies/release_policy.rb new file mode 100644 index 00000000000..d7f9e5d7445 --- /dev/null +++ b/app/policies/release_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ReleasePolicy < BasePolicy + delegate { @subject.project } +end diff --git a/app/policies/suggestion_policy.rb b/app/policies/suggestion_policy.rb new file mode 100644 index 00000000000..301b7d965f5 --- /dev/null +++ b/app/policies/suggestion_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class SuggestionPolicy < BasePolicy + delegate { @subject.project } + + condition(:can_push_to_branch) do + Gitlab::UserAccess.new(@user, project: @subject.project).can_push_to_branch?(@subject.branch) + end + + rule { can_push_to_branch }.enable :apply_suggestion +end diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 9cc137aa3bd..d94d9118eee 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -12,6 +12,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated .fabricate! end + def can_add_cluster? + can?(current_user, :add_cluster, clusterable) + end + def can_create_cluster? can?(current_user, :create_cluster, clusterable) end diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index 7e6eccb648c..7a5b68f9a4b 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -2,8 +2,22 @@ module Clusters class ClusterPresenter < Gitlab::View::Presenter::Delegated + include ActionView::Helpers::SanitizeHelper + include ActionView::Helpers::UrlHelper + include IconsHelper + presents :cluster + # We do not want to show the group path for clusters belonging to the + # clusterable, only for the ancestor clusters. + def item_link(clusterable_presenter) + if cluster.group_type? && clusterable != clusterable_presenter.subject + contracted_group_name(cluster.group) + ' / ' + link_to_cluster + else + link_to_cluster + end + end + def gke_cluster_url "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp? end @@ -12,6 +26,18 @@ module Clusters can?(current_user, :update_cluster, cluster) && created? end + def can_read_cluster? + can?(current_user, :read_cluster, cluster) + end + + def cluster_type_description + if cluster.project_type? + s_("ClusterIntegration|Project cluster") + elsif cluster.group_type? + s_("ClusterIntegration|Group cluster") + end + end + def show_path if cluster.project_type? project_cluster_path(project, cluster) @@ -21,5 +47,29 @@ module Clusters raise NotImplementedError end end + + private + + def clusterable + if cluster.group_type? + cluster.group + elsif cluster.project_type? + cluster.project + end + end + + def contracted_group_name(group) + sanitize(group.full_name) + .sub(%r{\/.*\/}, "/ #{contracted_icon} /") + .html_safe + end + + def contracted_icon + sprite_icon('ellipsis_h', size: 12, css_class: 'vertical-align-middle') + end + + def link_to_cluster + link_to_if(can_read_cluster?, cluster.name, show_path) + end end end diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index f0881829efd..b0aaec3326d 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -5,6 +5,7 @@ class DiffFileEntity < DiffFileBaseEntity include IconsHelper expose :too_large?, as: :too_large + expose :empty?, as: :empty expose :added_lines expose :removed_lines diff --git a/app/serializers/diff_line_entity.rb b/app/serializers/diff_line_entity.rb index 942714b7787..bfef6d3bde8 100644 --- a/app/serializers/diff_line_entity.rb +++ b/app/serializers/diff_line_entity.rb @@ -11,4 +11,6 @@ class DiffLineEntity < Grape::Entity expose :rich_text do |line| ERB::Util.html_escape(line.rich_text || line.text) end + + expose :suggestible?, as: :can_receive_suggestion end diff --git a/app/serializers/diff_viewer_entity.rb b/app/serializers/diff_viewer_entity.rb index 27fba03cb3f..587fa2347fd 100644 --- a/app/serializers/diff_viewer_entity.rb +++ b/app/serializers/diff_viewer_entity.rb @@ -4,4 +4,7 @@ class DiffViewerEntity < Grape::Entity # Partial name refers directly to a Rails feature, let's avoid # using this on the frontend. expose :partial_name, as: :name + expose :error do |diff_viewer| + diff_viewer.render_error_message + end end diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb index cc0c2abf863..f515abe5917 100644 --- a/app/serializers/entity_date_helper.rb +++ b/app/serializers/entity_date_helper.rb @@ -44,14 +44,14 @@ module EntityDateHelper # It returns "Upcoming" for upcoming entities # If due date is provided, it returns "# days|weeks|months remaining|ago" # If start date is provided and elapsed, with no due date, it returns "# days elapsed" - def remaining_days_in_words(entity) - if entity.try(:expired?) + def remaining_days_in_words(due_date, start_date = nil) + if due_date&.past? content_tag(:strong, 'Past due') - elsif entity.try(:upcoming?) + elsif start_date&.future? content_tag(:strong, 'Upcoming') - elsif entity.due_date - is_upcoming = (entity.due_date - Date.today).to_i > 0 - time_ago = time_ago_in_words(entity.due_date) + elsif due_date + is_upcoming = (due_date - Date.today).to_i > 0 + time_ago = time_ago_in_words(due_date) # https://gitlab.com/gitlab-org/gitlab-ce/issues/49440 # @@ -63,8 +63,8 @@ module EntityDateHelper remaining_or_ago = is_upcoming ? _("remaining") : _("ago") "#{content} #{remaining_or_ago}".html_safe - elsif entity.start_date && entity.start_date.past? - days = entity.elapsed_days + elsif start_date&.past? + days = (Date.today - start_date).to_i "#{content_tag(:strong, days)} #{'day'.pluralize(days)} elapsed".html_safe end end diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 07a13c33b89..4a7d13915dd 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -23,6 +23,10 @@ class EnvironmentEntity < Grape::Entity stop_project_environment_path(environment.project, environment) end + expose :cluster_type, if: ->(environment, _) { cluster_platform_kubernetes? } do |environment| + cluster.cluster_type + end + expose :terminal_path, if: ->(*) { environment.has_terminals? && can_access_terminal? } do |environment| terminal_project_environment_path(environment.project, environment) end @@ -48,4 +52,16 @@ class EnvironmentEntity < Grape::Entity def can_access_terminal? can?(request.current_user, :create_environment_terminal, environment) end + + def cluster_platform_kubernetes? + deployment_platform && deployment_platform.is_a?(Clusters::Platforms::Kubernetes) + end + + def deployment_platform + environment.deployment_platform + end + + def cluster + deployment_platform.cluster + end end diff --git a/app/serializers/issuable_sidebar_basic_entity.rb b/app/serializers/issuable_sidebar_basic_entity.rb new file mode 100644 index 00000000000..61de3c93337 --- /dev/null +++ b/app/serializers/issuable_sidebar_basic_entity.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +class IssuableSidebarBasicEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :type do |issuable| + issuable.to_ability_name + end + expose :author_id + expose :project_id do |issuable| + issuable.project.id + end + expose :discussion_locked + expose :reference do |issuable| + issuable.to_reference(issuable.project, full: true) + end + + expose :milestone, using: ::API::Entities::Milestone + expose :labels, using: LabelEntity + + expose :current_user, if: lambda { |_issuable| current_user } do + expose :current_user, merge: true, using: API::Entities::UserBasic + + expose :todo, using: IssuableSidebarTodoEntity do |issuable| + current_user.pending_todo_for(issuable) + end + + expose :can_edit do |issuable| + can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) + end + + expose :can_move do |issuable| + issuable.can_move?(current_user) + end + + expose :can_admin_label do |issuable| + can?(current_user, :admin_label, issuable.project) + end + end + + expose :issuable_json_path do |issuable| + if issuable.is_a?(MergeRequest) + project_merge_request_path(issuable.project, issuable.iid, :json) + else + project_issue_path(issuable.project, issuable.iid, :json) + end + end + + expose :namespace_path do |issuable| + issuable.project.namespace.full_path + end + + expose :project_path do |issuable| + issuable.project.path + end + + expose :project_full_path do |issuable| + issuable.project.full_path + end + + expose :project_issuables_path do |issuable| + project = issuable.project + namespace = project.namespace + + if issuable.is_a?(MergeRequest) + namespace_project_merge_requests_path(namespace, project) + else + namespace_project_issues_path(namespace, project) + end + end + + expose :create_todo_path do |issuable| + project_todos_path(issuable.project) + end + + expose :project_milestones_path do |issuable| + project_milestones_path(issuable.project, :json) + end + + expose :project_labels_path do |issuable| + project_labels_path(issuable.project, :json, include_ancestor_groups: true) + end + + expose :toggle_subscription_path do |issuable| + toggle_subscription_path(issuable) + end + + expose :move_issue_path do |issuable| + move_namespace_project_issue_path( + namespace_id: issuable.project.namespace.to_param, + project_id: issuable.project, + id: issuable + ) + end + + expose :projects_autocomplete_path do |issuable| + autocomplete_projects_path(project_id: issuable.project.id) + end + + private + + def current_user + request.current_user + end +end diff --git a/app/serializers/issuable_sidebar_entity.rb b/app/serializers/issuable_sidebar_extras_entity.rb index 773d78d324c..d60253564e1 100644 --- a/app/serializers/issuable_sidebar_entity.rb +++ b/app/serializers/issuable_sidebar_extras_entity.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -class IssuableSidebarEntity < Grape::Entity - include TimeTrackableEntity +class IssuableSidebarExtrasEntity < Grape::Entity include RequestAwareEntity + include TimeTrackableEntity expose :participants, using: ::API::Entities::UserBasic do |issuable| issuable.participants(request.current_user) diff --git a/app/serializers/issuable_sidebar_todo_entity.rb b/app/serializers/issuable_sidebar_todo_entity.rb new file mode 100644 index 00000000000..b2c98433f05 --- /dev/null +++ b/app/serializers/issuable_sidebar_todo_entity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class IssuableSidebarTodoEntity < Grape::Entity + include Gitlab::Routing + + expose :id + + expose :delete_path do |todo| + dashboard_todo_path(todo) if todo + end +end diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb index 58ab804a3c8..f7719447b92 100644 --- a/app/serializers/issue_board_entity.rb +++ b/app/serializers/issue_board_entity.rb @@ -17,7 +17,7 @@ class IssueBoardEntity < Grape::Entity end expose :milestone, expose_nil: false do |issue| - API::Entities::Project.represent issue.milestone, only: [:id, :title] + API::Entities::Milestone.represent issue.milestone, only: [:id, :title] end expose :assignees do |issue| @@ -37,7 +37,7 @@ class IssueBoardEntity < Grape::Entity end expose :issue_sidebar_endpoint, if: -> (issue) { issue.project } do |issue| - project_issue_path(issue.project, issue, format: :json, serializer: 'sidebar') + project_issue_path(issue.project, issue, format: :json, serializer: 'sidebar_extras') end expose :toggle_subscription_endpoint, if: -> (issue) { issue.project } do |issue| diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb index d66f0a5acb7..0fa76f098cd 100644 --- a/app/serializers/issue_serializer.rb +++ b/app/serializers/issue_serializer.rb @@ -2,13 +2,15 @@ class IssueSerializer < BaseSerializer # This overrided method takes care of which entity should be used - # to serialize the `issue` based on `basic` key in `opts` param. + # to serialize the `issue` based on `serializer` key in `opts` param. # Hence, `entity` doesn't need to be declared on the class scope. def represent(issue, opts = {}) entity = case opts[:serializer] when 'sidebar' - IssueSidebarEntity + IssueSidebarBasicEntity + when 'sidebar_extras' + IssueSidebarExtrasEntity when 'board' IssueBoardEntity else diff --git a/app/serializers/issue_sidebar_basic_entity.rb b/app/serializers/issue_sidebar_basic_entity.rb new file mode 100644 index 00000000000..723875809ec --- /dev/null +++ b/app/serializers/issue_sidebar_basic_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class IssueSidebarBasicEntity < IssuableSidebarBasicEntity + expose :due_date + expose :confidential +end diff --git a/app/serializers/issue_sidebar_entity.rb b/app/serializers/issue_sidebar_extras_entity.rb index 349ad9d1fef..7b6e860140b 100644 --- a/app/serializers/issue_sidebar_entity.rb +++ b/app/serializers/issue_sidebar_extras_entity.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -class IssueSidebarEntity < IssuableSidebarEntity +class IssueSidebarExtrasEntity < IssuableSidebarExtrasEntity expose :assignees, using: API::Entities::UserBasic end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index f7eb74cf392..084627f9dbe 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class MergeRequestBasicEntity < IssuableSidebarEntity +class MergeRequestBasicEntity < Grape::Entity expose :assignee_id expose :merge_status expose :merge_error diff --git a/app/serializers/merge_request_basic_serializer.rb b/app/serializers/merge_request_basic_serializer.rb deleted file mode 100644 index a68b48b00db..00000000000 --- a/app/serializers/merge_request_basic_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class MergeRequestBasicSerializer < BaseSerializer - entity MergeRequestBasicEntity -end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index 1f8c830e1aa..4cf84336aa4 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -7,9 +7,14 @@ class MergeRequestSerializer < BaseSerializer def represent(merge_request, opts = {}) entity = case opts[:serializer] - when 'basic', 'sidebar' + when 'sidebar' + MergeRequestSidebarBasicEntity + when 'sidebar_extras' + IssuableSidebarExtrasEntity + when 'basic' MergeRequestBasicEntity - else # It's 'widget' + else + # fallback to widget for old poll requests without `serializer` set MergeRequestWidgetEntity end diff --git a/app/serializers/merge_request_sidebar_basic_entity.rb b/app/serializers/merge_request_sidebar_basic_entity.rb new file mode 100644 index 00000000000..0ae7298a7c1 --- /dev/null +++ b/app/serializers/merge_request_sidebar_basic_entity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class MergeRequestSidebarBasicEntity < IssuableSidebarBasicEntity + expose :assignee, if: lambda { |issuable| issuable.assignee } do + expose :assignee, merge: true, using: API::Entities::UserBasic + + expose :can_merge do |issuable| + issuable.can_be_merged_by?(issuable.assignee) + end + end +end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index f33a1654d5e..9731b52f1ad 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -238,6 +238,8 @@ class MergeRequestWidgetEntity < IssuableEntity end end + expose :supports_suggestion?, as: :can_receive_suggestion + private delegate :current_user, to: :request diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index c6d27817411..1d3b59eb1b7 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -36,6 +36,7 @@ class NoteEntity < API::Entities::Note end end + expose :suggestions, using: SuggestionEntity expose :resolved?, as: :resolved expose :resolvable?, as: :resolvable diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb new file mode 100644 index 00000000000..4d0d4da10be --- /dev/null +++ b/app/serializers/suggestion_entity.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class SuggestionEntity < API::Entities::Suggestion + include RequestAwareEntity + + expose :current_user do + expose :can_apply do |suggestion| + Ability.allowed?(current_user, :apply_suggestion, suggestion) + end + end + + private + + def current_user + request.current_user + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 19b5552887f..f8d8ef04001 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -31,7 +31,8 @@ module Ci seeds_block: block, variables_attributes: params[:variables_attributes], project: project, - current_user: current_user) + current_user: current_user, + push_options: params[:push_options]) sequence = Gitlab::Ci::Pipeline::Chain::Sequence .new(pipeline, command, SEQUENCE) diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 13321b2682e..6707a1363d0 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -118,7 +118,7 @@ module Ci # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL` groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces) - hierarchy_groups = Gitlab::GroupHierarchy.new(groups).base_and_descendants + hierarchy_groups = Gitlab::ObjectHierarchy.new(groups).base_and_descendants projects = Project.where(namespace_id: hierarchy_groups) .with_group_runners_enabled .with_builds_enabled diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb index 301059f0326..5525c1b9b7f 100644 --- a/app/services/clusters/gcp/finalize_creation_service.rb +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -13,17 +13,17 @@ module Clusters configure_kubernetes cluster.save! - ClusterPlatformConfigureWorker.perform_async(cluster.id) + ClusterConfigureWorker.perform_async(cluster.id) rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e log_service_error(e.class.name, provider.id, e.message) - provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") + provider.make_errored!(s_('ClusterIntegration|Failed to request to Google Cloud Platform: %{message}') % { message: e.message }) rescue Kubeclient::HttpError => e log_service_error(e.class.name, provider.id, e.message) - provider.make_errored!("Failed to run Kubeclient: #{e.message}") + provider.make_errored!(s_('ClusterIntegration|Failed to run Kubeclient: %{message}') % { message: e.message }) rescue ActiveRecord::RecordInvalid => e log_service_error(e.class.name, provider.id, e.message) - provider.make_errored!("Failed to configure Google Kubernetes Engine Cluster: #{e.message}") + provider.make_errored!(s_('ClusterIntegration|Failed to configure Google Kubernetes Engine Cluster: %{message}') % { message: e.message }) end private diff --git a/app/services/commits/tag_service.rb b/app/services/commits/tag_service.rb index 7961ba4d3c4..bb8cfb63f98 100644 --- a/app/services/commits/tag_service.rb +++ b/app/services/commits/tag_service.rb @@ -9,11 +9,10 @@ module Commits tag_name = params[:tag_name] message = params[:tag_message] - release_description = nil result = Tags::CreateService .new(commit.project, current_user) - .execute(tag_name, commit.sha, message, release_description) + .execute(tag_name, commit.sha, message) if result[:status] == :success tag = result[:tag] diff --git a/app/services/create_release_service.rb b/app/services/create_release_service.rb deleted file mode 100644 index 8d1fdbe11c3..00000000000 --- a/app/services/create_release_service.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -class CreateReleaseService < BaseService - # rubocop: disable CodeReuse/ActiveRecord - def execute(tag_name, release_description) - repository = project.repository - existing_tag = repository.find_tag(tag_name) - - # Only create a release if the tag exists - if existing_tag - release = project.releases.find_by(tag: tag_name) - - if release - error('Release already exists', 409) - else - release = project.releases.new({ tag: tag_name, description: release_description }) - release.save - - success(release) - end - else - error('Tag does not exist', 404) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def success(release) - super().merge(release: release) - end -end diff --git a/app/services/deploy_keys/create_service.rb b/app/services/deploy_keys/create_service.rb index a25e73666f8..0c935285657 100644 --- a/app/services/deploy_keys/create_service.rb +++ b/app/services/deploy_keys/create_service.rb @@ -2,7 +2,7 @@ module DeployKeys class CreateService < Keys::BaseService - def execute + def execute(project: nil) DeployKey.create(params.merge(user: user)) end end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index f1883877d56..9ecee7c6156 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -174,7 +174,8 @@ class GitPushService < BaseService params[:newrev], params[:ref], @push_commits, - commits_count: commits_count) + commits_count: commits_count, + push_options: params[:push_options] || []) end def push_to_existing_branch? diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index dbadafc0f52..03fcf614c64 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -45,7 +45,8 @@ class GitTagPushService < BaseService params[:newrev], params[:ref], commits, - message) + message, + push_options: params[:push_options] || []) end def build_system_push_data diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb index 50d34d8cb91..f01f5656296 100644 --- a/app/services/groups/nested_create_service.rb +++ b/app/services/groups/nested_create_service.rb @@ -18,7 +18,7 @@ module Groups return namespace end - if group_path.include?('/') && !Group.supports_nested_groups? + if group_path.include?('/') && !Group.supports_nested_objects? raise 'Nested groups are not supported on MySQL' end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 5efa746dfb9..f64e327416a 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -40,7 +40,7 @@ module Groups def ensure_allowed_transfer raise_transfer_error(:group_is_already_root) if group_is_already_root? - raise_transfer_error(:database_not_supported) unless Group.supports_nested_groups? + raise_transfer_error(:database_not_supported) unless Group.supports_nested_objects? raise_transfer_error(:same_parent_as_current) if same_parent? raise_transfer_error(:invalid_policies) unless valid_policies? raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path? diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 0bf0e967dcc..de78a3f7b27 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -31,12 +31,12 @@ module Groups def after_update if group.previous_changes.include?(:visibility_level) && group.private? # don't enqueue immediately to prevent todos removal in case of a mistake - TodosDestroyer::GroupPrivateWorker.perform_in(1.hour, group.id) + TodosDestroyer::GroupPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, group.id) end end def reject_parent_id! - params.except!(:parent_id) + params.delete(:parent_id) end def valid_share_with_group_lock_change? diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index 765de9c66b0..885e14bba8f 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -4,20 +4,23 @@ module Issuable class CommonSystemNotesService < ::BaseService attr_reader :issuable - def execute(issuable, old_labels) + def execute(issuable, old_labels: [], is_update: true) @issuable = issuable - if issuable.previous_changes.include?('title') - create_title_change_note(issuable.previous_changes['title'].first) - end + if is_update + if issuable.previous_changes.include?('title') + create_title_change_note(issuable.previous_changes['title'].first) + end - handle_description_change_note + handle_description_change_note + + handle_time_tracking_note if issuable.is_a?(TimeTrackable) + create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked') + end - handle_time_tracking_note if issuable.is_a?(TimeTrackable) - create_labels_note(old_labels) if issuable.labels != old_labels - create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked') - create_milestone_note if issuable.previous_changes.include?('milestone_id') create_due_date_note if issuable.previous_changes.include?('due_date') + create_milestone_note if issuable.previous_changes.include?('milestone_id') + create_labels_note(old_labels) if issuable.labels != old_labels end private diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index e32e262ac31..c7e7bb55e4b 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -152,6 +152,10 @@ class IssuableBaseService < BaseService before_create(issuable) if issuable.save + ActiveRecord::Base.no_touching do + Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false) + end + after_create(issuable) execute_hooks(issuable) invalidate_cache_counts(issuable, users: issuable.assignees) @@ -207,7 +211,7 @@ class IssuableBaseService < BaseService if issuable.with_transaction_returning_status { issuable.save } # We do not touch as it will affect a update on updated_at field ActiveRecord::Base.no_touching do - Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_associations[:labels]) + Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels]) end handle_changes(issuable, old_associations: old_associations) diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index a1d0cc0e568..e992d682c79 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -44,7 +44,7 @@ module Issues if issue.previous_changes.include?('confidential') # don't enqueue immediately to prevent todos removal in case of a mistake - TodosDestroyer::ConfidentialIssueWorker.perform_in(1.hour, issue.id) if issue.confidential? + TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, issue.id) if issue.confidential? create_confidentiality_note(issue) end diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb index f30ad706c63..3c0e6196d4f 100644 --- a/app/services/labels/promote_service.rb +++ b/app/services/labels/promote_service.rb @@ -57,7 +57,7 @@ module Labels def update_issuables(new_label, label_ids) LabelLink .where(label: label_ids) - .update_all(label_id: new_label) + .update_all(label_id: new_label.id) end # rubocop: enable CodeReuse/ActiveRecord @@ -65,7 +65,7 @@ module Labels def update_resource_label_events(new_label, label_ids) ResourceLabelEvent .where(label: label_ids) - .update_all(label_id: new_label) + .update_all(label_id: new_label.id) end # rubocop: enable CodeReuse/ActiveRecord @@ -73,7 +73,7 @@ module Labels def update_issue_board_lists(new_label, label_ids) List .where(label: label_ids) - .update_all(label_id: new_label) + .update_all(label_id: new_label.id) end # rubocop: enable CodeReuse/ActiveRecord @@ -81,7 +81,7 @@ module Labels def update_priorities(new_label, label_ids) LabelPriority .where(label: label_ids) - .update_all(label_id: new_label) + .update_all(label_id: new_label.id) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/members/base_service.rb b/app/services/members/base_service.rb index d734571f835..e78affff797 100644 --- a/app/services/members/base_service.rb +++ b/app/services/members/base_service.rb @@ -47,5 +47,11 @@ module Members raise "Unknown action '#{action}' on #{member}!" end end + + def enqueue_delete_todos(member) + type = member.is_a?(GroupMember) ? 'Group' : 'Project' + # don't enqueue immediately to prevent todos removal in case of a mistake + TodosDestroyer::EntityLeaveWorker.perform_in(Todo::WAIT_FOR_DELETE, member.user_id, member.source_id, type) + end end end diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index c186a5971dc..ae0c644e6c0 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -15,7 +15,7 @@ module Members notification_service.decline_access_request(member) end - enqeue_delete_todos(member) + enqueue_delete_todos(member) after_execute(member: member) @@ -24,12 +24,6 @@ module Members private - def enqeue_delete_todos(member) - type = member.is_a?(GroupMember) ? 'Group' : 'Project' - # don't enqueue immediately to prevent todos removal in case of a mistake - TodosDestroyer::EntityLeaveWorker.perform_in(1.hour, member.user_id, member.source_id, type) - end - def can_destroy_member?(member) can?(current_user, destroy_member_permission(member), member) end diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index 1f5618dae53..ff8d5c1d8c9 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -10,9 +10,18 @@ module Members if member.update(params) after_execute(action: permission, old_access_level: old_access_level, member: member) + + # Deletes only confidential issues todos for guests + enqueue_delete_todos(member) if downgrading_to_guest? end member end + + private + + def downgrading_to_guest? + params[:access_level] == Gitlab::Access::GUEST + end end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 36767621d74..48419da98ad 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -18,7 +18,7 @@ module MergeRequests merge_request.source_project = find_source_project merge_request.target_project = find_target_project merge_request.target_branch = find_target_branch - merge_request.can_be_created = branches_valid? + merge_request.can_be_created = projects_and_branches_valid? # compare branches only if branches are valid, otherwise # compare_branches may raise an error @@ -49,15 +49,19 @@ module MergeRequests to: :merge_request def find_source_project - return source_project if source_project.present? && can?(current_user, :read_project, source_project) + return source_project if source_project.present? && can?(current_user, :create_merge_request_from, source_project) project end def find_target_project - return target_project if target_project.present? && can?(current_user, :read_project, target_project) + return target_project if target_project.present? && can?(current_user, :create_merge_request_in, target_project) - project.default_merge_request_target + target_project = project.default_merge_request_target + + return target_project if target_project.present? && can?(current_user, :create_merge_request_in, target_project) + + project end def find_target_branch @@ -72,10 +76,11 @@ module MergeRequests params[:target_branch].present? end - def branches_valid? + def projects_and_branches_valid? + return false if source_project.nil? || target_project.nil? return false unless source_branch_specified? || target_branch_specified? - validate_branches + validate_projects_and_branches errors.blank? end @@ -94,7 +99,12 @@ module MergeRequests end end - def validate_branches + def validate_projects_and_branches + merge_request.validate_target_project + merge_request.validate_fork + + return if errors.any? + add_error('You must select source and target branch') unless branches_present? add_error('You must select different branches') if same_source_and_target? add_error("Source branch \"#{source_branch}\" does not exist") unless source_branch_exists? diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index aacaf10d09c..86a04587f79 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -5,14 +5,15 @@ module MergeRequests def execute(merge_request) # We don't allow change of source/target projects and source branch # after merge request was created - params.except!(:source_project_id) - params.except!(:target_project_id) - params.except!(:source_branch) + params.delete(:source_project_id) + params.delete(:target_project_id) + params.delete(:source_branch) merge_from_quick_action(merge_request) if params[:merge] if merge_request.closed_without_fork? - params.except!(:target_branch, :force_remove_source_branch) + params.delete(:target_branch) + params.delete(:force_remove_source_branch) end if params[:force_remove_source_branch].present? @@ -45,11 +46,13 @@ module MergeRequests end if merge_request.previous_changes.include?('assignee_id') + reassigned_merge_request_args = [merge_request, current_user] + old_assignee_id = merge_request.previous_changes['assignee_id'].first - old_assignee = User.find(old_assignee_id) if old_assignee_id + reassigned_merge_request_args << User.find(old_assignee_id) if old_assignee_id create_assignee_note(merge_request) - notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignee) + notification_service.async.reassigned_merge_request(*reassigned_merge_request_args) todo_service.reassigned_merge_request(merge_request, current_user) end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index e03789e3ca9..c4546f30235 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -36,6 +36,7 @@ module Notes if !only_commands && note.save todo_service.new_note(note, current_user) clear_noteable_diffs_cache(note) + Suggestions::CreateService.new(note).execute end if command_params.present? diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 35db409eb27..d2052bed646 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -14,6 +14,17 @@ module Notes TodoService.new.update_note(note, current_user, old_mentioned_users) end + if note.supports_suggestion? + Suggestion.transaction do + note.suggestions.delete_all + Suggestions::CreateService.new(note).execute + end + + # We need to refresh the previous suggestions call cache + # in order to get the new records. + note.reload + end + note end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ff035fea216..e1cf327209b 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -188,7 +188,7 @@ class NotificationService # * merge_request assignee if their notification level is not Disabled # * users with custom level checked with "reassign merge request" # - def reassigned_merge_request(merge_request, current_user, previous_assignee) + def reassigned_merge_request(merge_request, current_user, previous_assignee = nil) recipients = NotificationRecipientService.build_recipients( merge_request, current_user, diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index de8757006f1..a449a5dc3e9 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -4,10 +4,12 @@ class PreviewMarkdownService < BaseService def execute text, commands = explain_quick_actions(params[:text]) users = find_user_references(text) + suggestions = find_suggestions(text) success( text: text, users: users, + suggestions: suggestions, commands: commands.join(' '), markdown_engine: markdown_engine ) @@ -28,6 +30,12 @@ class PreviewMarkdownService < BaseService extractor.users.map(&:username) end + def find_suggestions(text) + return [] unless params[:preview_suggestions] + + Banzai::SuggestionsParser.parse(text) + end + def find_commands_target QuickActions::TargetService .new(project, current_user) diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb index 4131da44f5a..aa9b253eb20 100644 --- a/app/services/projects/after_rename_service.rb +++ b/app/services/projects/after_rename_service.rb @@ -81,6 +81,7 @@ module Projects def update_repository_configuration project.reload_repository! project.write_repository_config + project.track_project_repository end def rename_transferred_documents diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 210571b6b4e..336d029d330 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -137,6 +137,8 @@ module Projects raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.') end + project.leave_pool_repository + Project.transaction do log_destroy_event trash_repositories! diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb index 1c4a8d05be6..b5128443435 100644 --- a/app/services/projects/lfs_pointers/lfs_download_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -4,33 +4,51 @@ module Projects module LfsPointers class LfsDownloadService < BaseService + VALID_PROTOCOLS = %w[http https].freeze + # rubocop: disable CodeReuse/ActiveRecord def execute(oid, url) return unless project&.lfs_enabled? && oid.present? && url.present? return if LfsObject.exists?(oid: oid) - sanitized_uri = Gitlab::UrlSanitizer.new(url) + sanitized_uri = sanitize_url!(url) with_tmp_file(oid) do |file| - size = download_and_save_file(file, sanitized_uri) - lfs_object = LfsObject.new(oid: oid, size: size, file: file) + download_and_save_file(file, sanitized_uri) + lfs_object = LfsObject.new(oid: oid, size: file.size, file: file) project.all_lfs_objects << lfs_object end + rescue Gitlab::UrlBlocker::BlockedUrlError => e + Rails.logger.error("LFS file with oid #{oid} couldn't be downloaded: #{e.message}") rescue StandardError => e - Rails.logger.error("LFS file with oid #{oid} could't be downloaded from #{sanitized_uri.sanitized_url}: #{e.message}") + Rails.logger.error("LFS file with oid #{oid} couldn't be downloaded from #{sanitized_uri.sanitized_url}: #{e.message}") end # rubocop: enable CodeReuse/ActiveRecord private + def sanitize_url!(url) + Gitlab::UrlSanitizer.new(url).tap do |sanitized_uri| + # Just validate that HTTP/HTTPS protocols are used. The + # subsequent Gitlab::HTTP.get call will do network checks + # based on the settings. + Gitlab::UrlBlocker.validate!(sanitized_uri.sanitized_url, + protocols: VALID_PROTOCOLS) + end + end + def download_and_save_file(file, sanitized_uri) - IO.copy_stream(open(sanitized_uri.sanitized_url, headers(sanitized_uri)), file) # rubocop:disable Security/Open + response = Gitlab::HTTP.get(sanitized_uri.sanitized_url, headers(sanitized_uri)) do |fragment| + file.write(fragment) + end + + raise StandardError, "Received error code #{response.code}" unless response.success? end def headers(sanitized_uri) - {}.tap do |headers| + query_options.tap do |headers| credentials = sanitized_uri.credentials if credentials[:user].present? || credentials[:password].present? @@ -40,10 +58,14 @@ module Projects end end + def query_options + { stream_body: true } + end + def with_tmp_file(oid) create_tmp_storage_dir - File.open(File.join(tmp_storage_dir, oid), 'w') { |file| yield file } + File.open(File.join(tmp_storage_dir, oid), 'wb') { |file| yield file } end def create_tmp_storage_dir diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 9db3fd9cf17..5da1e39a1fb 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -81,7 +81,7 @@ module Projects project.old_path_with_namespace = @old_path - write_repository_config(@new_path) + update_repository_configuration(@new_path) execute_system_hooks end @@ -106,8 +106,9 @@ module Projects project.save! end - def write_repository_config(full_path) + def update_repository_configuration(full_path) project.write_repository_config(gl_full_path: full_path) + project.track_project_repository end def refresh_permissions @@ -123,7 +124,7 @@ module Projects rollback_folder_move project.reload update_namespace_and_visibility(@old_namespace) - write_repository_config(@old_path) + update_repository_configuration(@old_path) end def rollback_folder_move diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 93e48fc0199..dd1b9680ece 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -61,9 +61,9 @@ module Projects if project.previous_changes.include?(:visibility_level) && project.private? # don't enqueue immediately to prevent todos removal in case of a mistake - TodosDestroyer::ProjectPrivateWorker.perform_in(1.hour, project.id) + TodosDestroyer::ProjectPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id) elsif (project_changed_feature_keys & todos_features_changes).present? - TodosDestroyer::PrivateFeaturesWorker.perform_in(1.hour, project.id) + TodosDestroyer::PrivateFeaturesWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id) end if project.previous_changes.include?('path') diff --git a/app/services/releases/concerns.rb b/app/services/releases/concerns.rb new file mode 100644 index 00000000000..a04bb8f9e14 --- /dev/null +++ b/app/services/releases/concerns.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Releases + module Concerns + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + included do + def tag_name + params[:tag] + end + + def ref + params[:ref] + end + + def name + params[:name] + end + + def description + params[:description] + end + + def release + strong_memoize(:release) do + project.releases.find_by_tag(tag_name) + end + end + + def existing_tag + strong_memoize(:existing_tag) do + repository.find_tag(tag_name) + end + end + + def tag_exist? + existing_tag.present? + end + + def repository + strong_memoize(:repository) do + project.repository + end + end + end + end +end diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb new file mode 100644 index 00000000000..73fcebf79af --- /dev/null +++ b/app/services/releases/create_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Releases + class CreateService < BaseService + include Releases::Concerns + + def execute + return error('Access Denied', 403) unless allowed? + return error('Release already exists', 409) if release + + tag = ensure_tag + + return tag unless tag.is_a?(Gitlab::Git::Tag) + + create_release(tag) + end + + private + + def ensure_tag + existing_tag || create_tag + end + + def create_tag + return error('Ref is not specified', 422) unless ref + + result = Tags::CreateService + .new(project, current_user) + .execute(tag_name, ref, nil) + + return result unless result[:status] == :success + + result[:tag] + end + + def allowed? + Ability.allowed?(current_user, :create_release, project) + end + + def create_release(tag) + release = project.releases.create!( + name: name, + description: description, + author: current_user, + tag: tag.name, + sha: tag.dereferenced_target.sha + ) + + success(tag: tag, release: release) + rescue ActiveRecord::RecordInvalid => e + error(e.message, 400) + end + end +end diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb new file mode 100644 index 00000000000..8c2bc3b4e6e --- /dev/null +++ b/app/services/releases/destroy_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Releases + class DestroyService < BaseService + include Releases::Concerns + + def execute + return error('Tag does not exist', 404) unless existing_tag + return error('Release does not exist', 404) unless release + return error('Access Denied', 403) unless allowed? + + if release.destroy + success(tag: existing_tag, release: release) + else + error(release.errors.messages || '400 Bad request', 400) + end + end + + private + + def allowed? + Ability.allowed?(current_user, :destroy_release, release) + end + end +end diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb new file mode 100644 index 00000000000..fabfa398c59 --- /dev/null +++ b/app/services/releases/update_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Releases + class UpdateService < BaseService + include Releases::Concerns + + def execute + return error('Tag does not exist', 404) unless existing_tag + return error('Release does not exist', 404) unless release + return error('Access Denied', 403) unless allowed? + return error('params is empty', 400) if empty_params? + + if release.update(params) + success(tag: existing_tag, release: release) + else + error(release.errors.messages || '400 Bad request', 400) + end + end + + private + + def allowed? + Ability.allowed?(current_user, :update_release, release) + end + + # rubocop: disable CodeReuse/ActiveRecord + def empty_params? + params.except(:tag).empty? + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb new file mode 100644 index 00000000000..d931d528c86 --- /dev/null +++ b/app/services/suggestions/apply_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Suggestions + class ApplyService < ::BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(suggestion) + unless suggestion.appliable? + return error('Suggestion is not appliable') + end + + params = file_update_params(suggestion) + result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute + + if result[:status] == :success + suggestion.update(commit_id: result[:result], applied: true) + end + + result + end + + private + + def file_update_params(suggestion) + diff_file = suggestion.diff_file + + file_path = diff_file.file_path + branch_name = suggestion.noteable.source_branch + file_content = new_file_content(suggestion) + commit_message = "Apply suggestion to #{file_path}" + + { + file_path: file_path, + branch_name: branch_name, + start_branch: branch_name, + commit_message: commit_message, + file_content: file_content + } + end + + def new_file_content(suggestion) + range = suggestion.from_line_index..suggestion.to_line_index + blob = suggestion.diff_file.new_blob + + blob.load_all_data! + content = blob.data.lines + content[range] = suggestion.to_content + + content.join + end + end +end diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb new file mode 100644 index 00000000000..77e958cbe0c --- /dev/null +++ b/app/services/suggestions/create_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Suggestions + class CreateService + def initialize(note) + @note = note + end + + def execute + return unless @note.supports_suggestion? + + suggestions = Banzai::SuggestionsParser.parse(@note.note) + + # For single line suggestion we're only looking forward to + # change the line receiving the comment. Though, in + # https://gitlab.com/gitlab-org/gitlab-ce/issues/53310 + # we'll introduce a ```suggestion:L<x>-<y>, so this will + # slightly change. + comment_line = @note.position.new_line + + rows = + suggestions.map.with_index do |suggestion, index| + from_content = changing_lines(comment_line, comment_line) + + # The parsed suggestion doesn't have information about the correct + # ending characters (we may have a line break, or not), so we take + # this information from the last line being changed (last + # characters). + endline_chars = line_break_chars(from_content.lines.last) + to_content = "#{suggestion}#{endline_chars}" + + { + note_id: @note.id, + from_content: from_content, + to_content: to_content, + relative_order: index + } + end + + rows.in_groups_of(100, false) do |rows| + Gitlab::Database.bulk_insert('suggestions', rows) + end + end + + private + + def changing_lines(from_line, to_line) + @note.diff_file.new_blob_lines_between(from_line, to_line).join + end + + def line_break_chars(line) + match = /\r\n|\r|\n/.match(line) + match[0] if match + end + end +end diff --git a/app/services/tags/create_service.rb b/app/services/tags/create_service.rb index 35390f5082c..4de6b2d2774 100644 --- a/app/services/tags/create_service.rb +++ b/app/services/tags/create_service.rb @@ -2,7 +2,7 @@ module Tags class CreateService < BaseService - def execute(tag_name, target, message, release_description = nil) + def execute(tag_name, target, message) valid_tag = Gitlab::GitRefValidator.validate(tag_name) return error('Tag name invalid') unless valid_tag @@ -20,10 +20,7 @@ module Tags end if new_tag - if release_description - CreateReleaseService.new(@project, @current_user) - .execute(tag_name, release_description) - end + repository.expire_tags_cache success.merge(tag: new_tag) else diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb index 6bfef09ac54..cab507946b4 100644 --- a/app/services/tags/destroy_service.rb +++ b/app/services/tags/destroy_service.rb @@ -2,7 +2,6 @@ module Tags class DestroyService < BaseService - # rubocop: disable CodeReuse/ActiveRecord def execute(tag_name) repository = project.repository tag = repository.find_tag(tag_name) @@ -12,8 +11,12 @@ module Tags end if repository.rm_tag(current_user, tag_name) - release = project.releases.find_by(tag: tag_name) - release&.destroy + ## + # When a tag in a repository is destroyed, + # release assets will be destroyed too. + Releases::DestroyService + .new(project, current_user, tag: tag_name) + .execute push_data = build_push_data(tag) EventCreateService.new.push(project, current_user, push_data) @@ -27,7 +30,6 @@ module Tags rescue Gitlab::Git::PreReceiveError => ex error(ex.message) end - # rubocop: enable CodeReuse/ActiveRecord def error(message, return_code = 400) super(message).merge(return_code: return_code) diff --git a/app/services/update_release_service.rb b/app/services/update_release_service.rb deleted file mode 100644 index e2228ca026c..00000000000 --- a/app/services/update_release_service.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class UpdateReleaseService < BaseService - # rubocop: disable CodeReuse/ActiveRecord - def execute(tag_name, release_description) - repository = project.repository - existing_tag = repository.find_tag(tag_name) - - if existing_tag - release = project.releases.find_by(tag: tag_name) - - if release - release.update(description: release_description) - - success(release) - else - error('Release does not exist', 404) - end - else - error('Tag does not exist', 404) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - def success(release) - super().merge(release: release) - end -end diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 23b63aaabdf..fe5a82e23fa 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -102,7 +102,7 @@ module Users end def fresh_authorizations - klass = if Group.supports_nested_groups? + klass = if Group.supports_nested_objects? Gitlab::ProjectAuthorizations::WithNestedGroups else Gitlab::ProjectAuthorizations::WithoutNestedGroups diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index a897e4bd56a..af4fe1aebb9 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -16,7 +16,7 @@ module Users user_exists = @user.persisted? - assign_attributes(&block) + assign_attributes if @user.save(validate: validate) && update_status notify_success(user_exists) @@ -48,9 +48,11 @@ module Users success end - def assign_attributes(&block) - if @user.user_synced_attributes_metadata - params.except!(*@user.user_synced_attributes_metadata.read_only_attributes) + def assign_attributes + if (metadata = @user.user_synced_attributes_metadata) + read_only = metadata.read_only_attributes + + params.reject! { |key, _| read_only.include?(key.to_sym) } end @user.assign_attributes(params) if params.any? diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index cb67079853e..544f09048f5 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -8,7 +8,7 @@ = f.label :header_logo, 'Header logo', class: 'col-sm-2 col-form-label pt-0' .col-sm-10 - if @appearance.header_logo? - = image_tag @appearance.header_logo_url, class: 'appearance-light-logo-preview' + = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' - if @appearance.persisted? %br = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" @@ -25,7 +25,7 @@ = f.label :favicon, 'Favicon', class: 'col-sm-2 col-form-label pt-0' .col-sm-10 - if @appearance.favicon? - = image_tag @appearance.favicon_url, class: 'appearance-light-logo-preview' + = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' - if @appearance.persisted? %br = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" @@ -54,7 +54,7 @@ = f.label :logo, class: 'col-sm-2 col-form-label pt-0' .col-sm-10 - if @appearance.logo? - = image_tag @appearance.logo_url, class: 'appearance-logo-preview' + = image_tag @appearance.logo_path, class: 'appearance-logo-preview' - if @appearance.persisted? %br = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo" diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 0d42094fc89..fdaad1cf181 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -49,5 +49,12 @@ Once that time passes, the jobs will be archived and no longer able to be retried. Make it empty to never expire jobs. It has to be no less than 1 day, for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>. + .form-group + .form-check + = f.check_box :protected_ci_variables, class: 'form-check-input' + = f.label :protected_ci_variables, class: 'form-check-label' do + = s_('AdminSettings|Environment variables are protected by default') + .form-text.text-muted + = s_('AdminSettings|When creating a new environment variable it will be protected by default.') = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml index 19c2a50ebd9..4f4f0a543e0 100644 --- a/app/views/admin/runners/_sort_dropdown.html.haml +++ b/app/views/admin/runners/_sort_dropdown.html.haml @@ -6,6 +6,6 @@ = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %li - = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by) - = sortable_item(sort_title_contacted_date, page_filter_path(sort: sort_value_contacted_date, label: true), sorted_by) + = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sorted_by) + = sortable_item(sort_title_contacted_date, page_filter_path(sort: sort_value_contacted_date), sorted_by) diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index d355e7799df..90c59bec975 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1 +1,3 @@ -= _('Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want.') += _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use environment variables for passwords, secret keys, or whatever you want.') += _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe += link_to _('More information'), help_page_path('ci/variables/README', anchor: 'variables') diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml new file mode 100644 index 00000000000..cb7779e2175 --- /dev/null +++ b/app/views/ci/variables/_header.html.haml @@ -0,0 +1,11 @@ +- expanded = local_assigns.fetch(:expanded) + +%h4 + = _('Environment variables') + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' + +%button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + +%p.append-bottom-0 + = render "ci/variables/content" diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index f34305e94fa..dc9ccb6cc39 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -1,5 +1,10 @@ - save_endpoint = local_assigns.fetch(:save_endpoint, nil) +- if ci_variable_protected_by_default? + %p.settings-message.text-center + - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protected-variables') } + = s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + .row .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } } .hide.alert.alert-danger.js-ci-variable-error-box diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 6ee55836dd2..16a7527c8ce 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -5,7 +5,8 @@ - id = variable&.id - key = variable&.key - value = variable&.value -- is_protected = variable && !only_key_value ? variable.protected : false +- is_protected_default = ci_variable_protected_by_default? +- is_protected = ci_variable_protected?(variable, only_key_value) - id_input_name = "#{form_field}[variables_attributes][][id]" - destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" @@ -39,7 +40,8 @@ %input{ type: "hidden", class: 'js-ci-variable-input-protected js-project-feature-toggle-input', name: protected_input_name, - value: is_protected } + value: is_protected, + data: { default: is_protected_default.to_s } } %span.toggle-icon = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') diff --git a/app/views/clusters/clusters/_buttons.html.haml b/app/views/clusters/clusters/_buttons.html.haml index db2e247e341..c81d1d5b05a 100644 --- a/app/views/clusters/clusters/_buttons.html.haml +++ b/app/views/clusters/clusters/_buttons.html.haml @@ -1,4 +1,6 @@ --# This partial is overridden in EE .nav-controls - %span.btn.btn-add-cluster.disabled.js-add-cluster - = s_("ClusterIntegration|Add Kubernetes cluster") + - if clusterable.can_add_cluster? + = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success js-add-cluster' + - else + %span.btn.btn-add-cluster.disabled.js-add-cluster + = s_("ClusterIntegration|Add Kubernetes cluster") diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml index adeca013749..b89789e9915 100644 --- a/app/views/clusters/clusters/_cluster.html.haml +++ b/app/views/clusters/clusters/_cluster.html.haml @@ -3,7 +3,7 @@ .table-section.section-60 .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster") .table-mobile-content - = link_to cluster.name, cluster.show_path + = cluster.item_link(clusterable) - unless cluster.enabled? %span.badge.badge-danger Connection disabled .table-section.section-25 @@ -13,4 +13,4 @@ .table-mobile-header{ role: "rowheader" } .table-mobile-content %span.badge.badge-light - = cluster.project_type? ? s_("ClusterIntegration|Project cluster") : s_("ClusterIntegration|Group cluster") + = cluster.cluster_type_description diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml index c926ec258f0..cfdbfe2dea1 100644 --- a/app/views/clusters/clusters/_empty_state.html.haml +++ b/app/views/clusters/clusters/_empty_state.html.haml @@ -9,6 +9,6 @@ = clusterable.empty_state_help_text = clusterable.learn_more_link - - if clusterable.can_create_cluster? + - if clusterable.can_add_cluster? .text-center = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success' 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 73b11d509d3..85d1002243b 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,6 +1,6 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert' } - %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } × +.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } + %button.close.js-close{ type: "button" } × .gcp-signup-offer--content .gcp-signup-offer--icon.append-right-8 = sprite_icon("information", size: 16) diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index ad6d1d856d6..58d0a304363 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -11,6 +11,13 @@ .nav-text = 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.") + %strong + = link_to _('More information'), help_page_path('user/group/clusters/', anchor: 'cluster-precedence') + .clusters-table.js-clusters-list .gl-responsive-table-row.table-row-header{ role: "row" } .table-section.section-60{ role: "rowheader" } diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index 9c246e19faa..4359a2c3c2b 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -1,7 +1,7 @@ .nav-block.activities = render 'shared/event_filter' .controls - = link_to dashboard_projects_path(rss_url_options), class: 'btn rss-btn has-tooltip', title: 'Subscribe' do + = link_to dashboard_projects_path(rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip', title: 'Subscribe' do %i.fa.fa-rss .content_list diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index 31d4b3da4f1..4dbda5c754b 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -4,6 +4,9 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") + += render_if_exists "shared/gold_trial_callout" + - page_title "Activity" - header_title "Activity", activity_dashboard_path diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 50f39f93283..2f7add600e4 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -1,6 +1,8 @@ - @hide_top_links = true - page_title "Groups" - header_title "Groups", dashboard_groups_path + += render_if_exists "shared/gold_trial_callout" = render 'dashboard/groups_head' - if params[:filter].blank? && @groups.empty? diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index fdd5c19d562..afd46412fab 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -4,6 +4,8 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues") += render_if_exists "shared/gold_trial_callout" + .page-title-holder %h1.page-title= _('Issues') diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 77cfa1271df..3e5f13b92e3 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -2,6 +2,8 @@ - page_title _("Merge Requests") - @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username) += render_if_exists "shared/gold_trial_callout" + .page-title-holder %h1.page-title= _('Merge Requests') diff --git a/app/views/dashboard/milestones/_milestone.html.haml b/app/views/dashboard/milestones/_milestone.html.haml index b876d6fd1f3..89212eb6bf9 100644 --- a/app/views/dashboard/milestones/_milestone.html.haml +++ b/app/views/dashboard/milestones/_milestone.html.haml @@ -1,5 +1,5 @@ = render 'shared/milestones/milestone', - milestone_path: group_or_dashboard_milestone_path(milestone), + milestone_path: group_or_project_milestone_path(milestone), issues_path: issues_dashboard_path(milestone_title: milestone.title), merge_requests_path: merge_requests_dashboard_path(milestone_title: milestone.title), milestone: milestone, diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index deed774a4a5..446b4715b2d 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -4,6 +4,8 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") += render_if_exists "shared/gold_trial_callout" + - page_title "Projects" - header_title "Projects", dashboard_projects_path diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index 8933d9e31ff..ad08409c8fe 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -4,6 +4,8 @@ - page_title "Starred Projects" - header_title "Projects", dashboard_projects_path += render_if_exists "shared/gold_trial_callout" + %div{ class: container_class } = render "projects/last_push" = render 'dashboard/projects_head' diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index d2593179f17..47729321961 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -2,6 +2,8 @@ - page_title "Todos" - header_title "Todos", dashboard_todos_path += render_if_exists "shared/gold_trial_callout" + .page-title-holder %h1.page-title= _('Todos') diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml index 68c19df092d..6ae4c334f7f 100644 --- a/app/views/events/_events.html.haml +++ b/app/views/events/_events.html.haml @@ -1 +1,4 @@ -= render partial: 'events/event', collection: @events +- if @events.present? + = render partial: 'events/event', collection: @events +- else + .nothing-here-block= _("No activities found") diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index a3eafc61d0a..869be4e8581 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -2,6 +2,8 @@ - page_title _("Groups") - header_title _("Groups"), dashboard_groups_path += render_if_exists "shared/gold_trial_callout" + - if current_user = render 'dashboard/groups_head' - else diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml index 452f390695c..d18dec7bd8e 100644 --- a/app/views/explore/projects/index.html.haml +++ b/app/views/explore/projects/index.html.haml @@ -2,6 +2,8 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path += render_if_exists "shared/gold_trial_callout" + - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml index 452f390695c..d18dec7bd8e 100644 --- a/app/views/explore/projects/starred.html.haml +++ b/app/views/explore/projects/starred.html.haml @@ -2,6 +2,8 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path += render_if_exists "shared/gold_trial_callout" + - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml index 452f390695c..d18dec7bd8e 100644 --- a/app/views/explore/projects/trending.html.haml +++ b/app/views/explore/projects/trending.html.haml @@ -2,6 +2,8 @@ - page_title _("Projects") - header_title _("Projects"), dashboard_projects_path += render_if_exists "shared/gold_trial_callout" + - if current_user = render 'dashboard/projects_head' - else diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index 82a497289f3..13df1e57125 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -1,7 +1,7 @@ .nav-block.activities = render 'shared/event_filter' .controls - = link_to group_path(@group, rss_url_options), class: 'btn rss-btn has-tooltip' , title: 'Subscribe' do + = link_to group_path(@group, rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip' , title: 'Subscribe' do %i.fa.fa-rss .content_list diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 6219da2c715..88e401081f4 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -12,6 +12,6 @@ = markdown_field(@group, :description) - if current_user - .group-buttons + .group-buttons.d-none.d-sm-block = render 'shared/members/access_request_buttons', source: @group = render 'shared/notifications/button', notification_setting: @notification_setting diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index a5e6abdba52..d9332e36ef5 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -5,13 +5,7 @@ %section.settings#ci-variables.no-animate{ class: ('expanded' if expanded) } .settings-header - %h4 - = _('Variables') - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' - %button.btn.btn-default.js-settings-toggle{ type: "button" } - = expanded ? _('Collapse') : _('Expand') - %p.append-bottom-0 - = render "ci/variables/content" + = render 'ci/variables/header', expanded: expanded .settings-content = render 'ci/variables/index', save_endpoint: group_variables_path diff --git a/app/views/issues/_issues_calendar.ics.ruby b/app/views/issues/_issues_calendar.ics.ruby index 73ab8489e0c..94c3099ace2 100644 --- a/app/views/issues/_issues_calendar.ics.ruby +++ b/app/views/issues/_issues_calendar.ics.ruby @@ -3,7 +3,7 @@ cal.prodid = '-//GitLab//NONSGML GitLab//EN' cal.x_wr_calname = 'GitLab Issues' # rubocop: disable CodeReuse/ActiveRecord -@issues.includes(project: :namespace).each do |issue| +@issues.preload(project: :namespace).each do |issue| cal.event do |event| event.dtstart = Icalendar::Values::Date.new(issue.due_date) event.summary = "#{issue.title} (in #{issue.project.full_path})" diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index a86972d8cf3..a6023a1cbb9 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -2,7 +2,7 @@ - group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) } - if @project && @project.persisted? - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project), issues_disabled: !@project.issues_enabled? } -.search.search-form +.search.search-form{ data: { track_label: "navbar_search", track_event: "activate_form_input" } } = form_tag search_path, method: :get, class: 'form-inline' do |f| .search-input-container .search-input-wrap diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 4f8db74382f..6003d973c88 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,7 +1,7 @@ !!! 5 %html.devise-layout-html{ class: system_message_class } = render "layouts/head" - %body.ui-indigo.login-page.application.navless{ data: { page: body_data_page } } + %body.ui-indigo.login-page.application.navless.qa-login-page{ data: { page: body_data_page } } .page-wrap = render "layouts/header/empty" .login-page-broadcast diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index e8d0d809181..a9b85889846 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -60,7 +60,7 @@ .dropdown-menu.dropdown-menu-right = render 'layouts/header/help_dropdown' - if header_link?(:user_dropdown) - %li.nav-item.header-user.dropdown + %li.nav-item.header-user.dropdown{ data: { track_label: "profile_dropdown", track_event: "click_dropdown" } } = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar" = sprite_icon('angle-down', css_class: 'caret-down') diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 953c0e7f46c..04409408ce0 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -2,5 +2,8 @@ - if current_user_menu?(:help) %li = link_to _("Help"), help_path + %li.divider + %li + = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) = render 'shared/user_dropdown_contributing_link' diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index 5cb8aebadb3..e42251f9ec8 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -1,4 +1,4 @@ -%li.header-new.dropdown +%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown" } } = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do = sprite_icon('plus-square', size: 16) = sprite_icon('angle-down', css_class: 'caret-down') diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 7057a5a142f..ddd30efe062 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -2,7 +2,7 @@ -# https://gitlab.com/gitlab-org/gitlab-ce/issues/49713 for more information. %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) - = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do + = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown" } }) do %button{ type: 'button', data: { toggle: "dropdown" } } = _('Projects') = sprite_icon('angle-down', css_class: 'caret-down') @@ -10,7 +10,7 @@ = render "layouts/nav/projects_dropdown/show" - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown" }) do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown" } }) do %button{ type: 'button', data: { toggle: "dropdown" } } = _('Groups') = sprite_icon('angle-down', css_class: 'caret-down') diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 477030a20c1..bf475c07711 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -103,19 +103,6 @@ = _('Merge Requests') %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(merge_requests_count) - - if group_sidebar_link?(:group_members) - = nav_link(path: 'group_members#index') do - = link_to group_group_members_path(@group) do - .nav-icon-container - = sprite_icon('users') - %span.nav-item-name.qa-group-members-item - = _('Members') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do - = link_to group_group_members_path(@group) do - %strong.fly-out-top-item-name - = _('Members') - - if group_sidebar_link?(:kubernetes) = nav_link(controller: [:clusters]) do = link_to group_clusters_path(@group) do @@ -129,6 +116,19 @@ %strong.fly-out-top-item-name = _('Kubernetes') + - if group_sidebar_link?(:group_members) + = nav_link(path: 'group_members#index') do + = link_to group_group_members_path(@group) do + .nav-icon-container + = sprite_icon('users') + %span.nav-item-name.qa-group-members-item + = _('Members') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do + = link_to group_group_members_path(@group) do + %strong.fly-out-top-item-name + = _('Members') + - if group_sidebar_link?(:settings) = nav_link(path: group_nav_link_paths) do = link_to edit_group_path(@group) do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index bdd0108db0d..d8017742c90 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -29,6 +29,11 @@ = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do %span= _('Activity') + - if project_nav_tab?(:releases) && Feature.enabled?(:releases_page, @project) + = nav_link(controller: :releases) do + = link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do + %span= _('Releases') + = render_if_exists 'projects/sidebar/security_dashboard' - if can?(current_user, :read_cycle_analytics, @project) @@ -62,7 +67,7 @@ = link_to project_branches_path(@project) do = _('Branches') - = nav_link(controller: [:tags, :releases]) do + = nav_link(controller: [:tags]) do = link_to project_tags_path(@project) do = _('Tags') diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml index 1fbae2f64ed..83c7f548975 100644 --- a/app/views/notify/_note_email.html.haml +++ b/app/views/notify/_note_email.html.haml @@ -4,17 +4,13 @@ - note_style = local_assigns.fetch(:note_style, "") - discussion = note.discussion if note.part_of_discussion? -- diff_discussion = discussion&.diff_discussion? -- on_image = discussion.on_image? if diff_discussion - if discussion - - phrase_end_char = on_image ? "." : ":" - %p{ style: "color: #777777;" } - = succeed phrase_end_char do + = succeed ':' do = link_to note.author_name, user_url(note.author) - - if diff_discussion + - if discussion&.diff_discussion? - if discussion.new_discussion? started a new discussion - else @@ -31,7 +27,7 @@ %p.details #{link_to note.author_name, user_url(note.author)} commented: -- if diff_discussion && !on_image +- if discussion&.diff_discussion? && discussion.on_text? = content_for :head do = stylesheet_link_tag 'mailers/highlighted_diff_email' diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb index 4bf252b6ce1..50209c46ed1 100644 --- a/app/views/notify/_note_email.text.erb +++ b/app/views/notify/_note_email.text.erb @@ -20,7 +20,7 @@ <% end -%> -<% if discussion&.diff_discussion? -%> +<% if discussion&.diff_discussion? && discussion.on_text? -%> <% discussion.truncated_diff_lines(highlight: false, diff_limit: diff_limit).each do |line| -%> <%= "> #{line.text}\n" -%> <% end -%> diff --git a/app/views/notify/changed_milestone_issue_email.html.haml b/app/views/notify/changed_milestone_email.html.haml index 7d5425fc72d..01d27cac36b 100644 --- a/app/views/notify/changed_milestone_issue_email.html.haml +++ b/app/views/notify/changed_milestone_email.html.haml @@ -1,3 +1,5 @@ %p Milestone changed to %strong= link_to(@milestone.name, @milestone_url) + - if date_range = milestone_date_range(@milestone) + = "(#{date_range})" diff --git a/app/views/notify/changed_milestone_email.text.erb b/app/views/notify/changed_milestone_email.text.erb new file mode 100644 index 00000000000..a466da4eb19 --- /dev/null +++ b/app/views/notify/changed_milestone_email.text.erb @@ -0,0 +1 @@ +Milestone changed to <%= @milestone.name %><% if date_range = milestone_date_range(@milestone) %> (<%= date_range %>)<% end %> ( <%= @milestone_url %> ) diff --git a/app/views/notify/changed_milestone_issue_email.text.erb b/app/views/notify/changed_milestone_issue_email.text.erb deleted file mode 100644 index c5fc0b61518..00000000000 --- a/app/views/notify/changed_milestone_issue_email.text.erb +++ /dev/null @@ -1 +0,0 @@ -Milestone changed to <%= @milestone.name %> ( <%= @milestone_url %> ) diff --git a/app/views/notify/changed_milestone_merge_request_email.html.haml b/app/views/notify/changed_milestone_merge_request_email.html.haml deleted file mode 100644 index 7d5425fc72d..00000000000 --- a/app/views/notify/changed_milestone_merge_request_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - Milestone changed to - %strong= link_to(@milestone.name, @milestone_url) diff --git a/app/views/notify/changed_milestone_merge_request_email.text.erb b/app/views/notify/changed_milestone_merge_request_email.text.erb deleted file mode 100644 index c5fc0b61518..00000000000 --- a/app/views/notify/changed_milestone_merge_request_email.text.erb +++ /dev/null @@ -1 +0,0 @@ -Milestone changed to <%= @milestone.name %> ( <%= @milestone_url %> ) diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 2603c558c0f..2629b374e7c 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -71,43 +71,43 @@ %h4.prepend-top-0 = s_("Profiles|Main settings") %p - = s_("Profiles|This information will appear on your profile.") + = s_("Profiles|This information will appear on your profile") - if current_user.ldap_user? = s_("Profiles|Some options are unavailable for LDAP accounts") .col-lg-8 .row - if @user.read_only_attribute?(:name) = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' }, - help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) } + help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } - else - = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: "Enter your name, so people you know can recognize you." + = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: s_("Profiles|Enter your name, so people you know can recognize you") = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' } - if @user.read_only_attribute?(:email) - = f.text_field :email, required: true, readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:email) } + = f.text_field :email, required: true, class: 'input-lg', readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } - else - = f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?), + = f.text_field :email, required: true, class: 'input-lg', value: (@user.email unless @user.temp_oauth_email?), help: user_email_help_text(@user) = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), - { help: s_("Profiles|This email will be displayed on your public profile."), include_blank: s_("Profiles|Do not show on profile") }, - control_class: 'select2' + { help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") }, + control_class: 'select2 input-lg' - commit_email_docs_link = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'commit-email', target: '_blank') = f.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)), { help: s_("Profiles|This email will be used for web based operations, such as edits and merges. %{learn_more}").html_safe % { learn_more: commit_email_docs_link } }, - control_class: 'select2' + control_class: 'select2 input-lg' = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, - { help: s_("Profiles|This feature is experimental and translations are not complete yet.") }, - control_class: 'select2' - = f.text_field :skype - = f.text_field :linkedin - = f.text_field :twitter - = f.text_field :website_url, label: s_("Profiles|Website") + { help: s_("Profiles|This feature is experimental and translations are not complete yet") }, + control_class: 'select2 input-lg' + = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") + = f.text_field :linkedin, class: 'input-md', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename") + = f.text_field :twitter, class: 'input-md', placeholder: s_("Profiles|@username") + = f.text_field :website_url, class: 'input-lg', placeholder: s_("Profiles|website.com") - if @user.read_only_attribute?(:location) - = f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:location) } + = f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) } - else - = f.text_field :location - = f.text_field :organization - = f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters.") + = f.text_field :location, class: 'input-lg', placeholder: s_("Profiles|City, country") + = f.text_field :organization, class: 'input-md', help: s_("Profiles|Who you represent or work for") + = f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters") %hr %h5= ("Private profile") .checkbox-icon-inline-wrapper @@ -118,7 +118,7 @@ %h5= s_("Profiles|Private contributions") = f.check_box :include_private_contributions, label: 'Include private contributions on my profile' .help-block - = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.") + = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information") .prepend-top-default.append-bottom-default = f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success' = link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel' diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 94ec0cc5db8..d986c566928 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -25,7 +25,8 @@ - else %p - Download the Google Authenticator application from App Store or Google Play Store and scan this code. + Install a soft token authenticator like <a href="https://freeotp.github.io/">FreeOTP</a> + or Google Authenticator from your application repository and scan this QR code. More information is available in the #{link_to('documentation', help_page_path('user/profile/account/two_factor_authentication'))}. .row.append-bottom-10 .col-md-4 diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index 6bf21570d41..31f1cf560e2 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -1,8 +1,8 @@ %div{ class: container_class } - .nav-block.activity-filter-block.activities + .nav-block.d-none.d-sm-block.activities = render 'shared/event_filter' .controls - = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn rss-btn has-tooltip' do + = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn d-none d-sm-inline-block has-tooltip' do = icon('rss') .content_list.project-activity{ :"data-href" => activity_project_path(@project) } diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index e191b009db2..82b2ab64a5d 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -2,7 +2,7 @@ - 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-7.d-flex + .project-title-row.col-md-12.col-lg-6.d-flex .avatar-container.project-avatar.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 @@ -25,7 +25,7 @@ - if @project.has_extra_tags? = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown } - .project-repo-buttons.col-md-12.col-lg-5.d-inline-flex.flex-wrap.justify-content-lg-end + .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' diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 0f709c65d0e..03ba1104507 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -18,17 +18,7 @@ Preview %li.md-header-toolbar.active - = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") }) - = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") }) - = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") }) - = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") }) - = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") }) - = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") }) - = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") }) - = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") }) - = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a table") }) - %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } } - = sprite_icon("screen-full") + = render 'projects/blob/markdown_buttons', show_fullscreen_button: true .md-write-holder = yield diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 3c1f33ea95e..a54460f1196 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -1,4 +1,6 @@ - action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create' +- file_name = params[:id].split("/").last ||= "" +- is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name) .file-holder-bottom-radius.file-holder.file.append-bottom-default .js-file-title.file-title.clearfix{ data: { current_action: action } } @@ -17,6 +19,8 @@ required: true, class: 'form-control new-file-name js-file-path-name-input' .file-buttons + - if is_markdown + = render 'projects/blob/markdown_buttons', show_fullscreen_button: false = button_tag class: 'soft-wrap-toggle btn', type: 'button', tabindex: '-1' do %span.no-wrap = custom_icon('icon_no_wrap') diff --git a/app/views/projects/blob/_markdown_buttons.html.haml b/app/views/projects/blob/_markdown_buttons.html.haml new file mode 100644 index 00000000000..1d6acd86108 --- /dev/null +++ b/app/views/projects/blob/_markdown_buttons.html.haml @@ -0,0 +1,13 @@ +.md-header-toolbar.active + = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") }) + = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") }) + = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") }) + = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") }) + = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") }) + = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") }) + = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") }) + = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") }) + = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a table") }) + - if show_fullscreen_button + %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } } + = sprite_icon("screen-full") diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index d453a3a9dac..159d9e44e17 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -1,16 +1,12 @@ - project = project || @project .git-clone-holder.js-git-clone-holder.input-group - - if allowed_protocols_present? - .input-group-text.clone-dropdown-btn.btn - %span.js-clone-dropdown-label - = enabled_project_button(project, enabled_protocol) - - else - %a#clone-dropdown.input-group-text.btn.btn-primary.btn-xs.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } - %span.append-right-4.js-clone-dropdown-label - = _('Clone') - = sprite_icon("arrow-down", css_class: "icon") - %form.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options + %a#clone-dropdown.input-group-text.btn.btn-primary.btn-xs.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } + %span.append-right-4.js-clone-dropdown-label + = _('Clone') + = sprite_icon("arrow-down", css_class: "icon") + %ul.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options + - if ssh_enabled? %li.pb-2 %label.label-bold = _('Clone with SSH') @@ -19,6 +15,7 @@ .input-group-append = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' + - if http_enabled? %li %label.label-bold = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index 778d27fc61d..cecc139b183 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -1,5 +1,3 @@ -- return unless Feature.enabled?(:project_cleanup, @project) - - expanded = Rails.env.test? %section.settings.no-animate#cleanup{ class: ('expanded' if expanded) } diff --git a/app/views/projects/diffs/_render_error.html.haml b/app/views/projects/diffs/_render_error.html.haml index c3dc47a56a7..7dbd9897e83 100644 --- a/app/views/projects/diffs/_render_error.html.haml +++ b/app/views/projects/diffs/_render_error.html.haml @@ -1,6 +1,2 @@ .nothing-here-block - = _("This %{viewer} could not be displayed because %{reason}.") % { viewer: viewer.switcher_title, reason: diff_render_error_reason(viewer) } - - You can - = diff_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe - instead. + = viewer.render_error_message.html_safe diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index b50b3ca207b..f048fb91304 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -55,7 +55,7 @@ - if can_report_spam = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'd-none d-sm-none d-md-block btn btn-grouped btn-spam', title: 'Submit as spam' - if can_create_issue - = link_to new_project_issue_path(@project), class: 'd-none d-sm-none d-md-block btn btn-grouped new-issue-link btn-success btn-inverted', title: 'New issue', id: 'new_issue_link' do + = link_to new_project_issue_path(@project), class: 'd-none d-sm-none d-md-block btn btn-grouped btn-success btn-inverted', title: 'New issue', id: 'new_issue_link' do New issue .issue-details.issuable-details @@ -88,4 +88,4 @@ %section.issuable-discussion = render 'projects/issues/discussion' -= render 'shared/issuable/sidebar', issuable: @issue += render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml deleted file mode 100644 index a6e2565a485..00000000000 --- a/app/views/projects/merge_requests/conflicts.html.haml +++ /dev/null @@ -1,36 +0,0 @@ -- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" -- content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/ace.js') -= render "projects/merge_requests/mr_title" - -.merge-request-details.issuable-details - = render "projects/merge_requests/mr_box" - -= render 'shared/issuable/sidebar', issuable: @merge_request - -#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json), - resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } } - .loading{ "v-if" => "isLoading" } - %i.fa.fa-spinner.fa-spin - - .nothing-here-block{ "v-if" => "hasError" } - {{conflictsData.errorMessage}} - - = render partial: "projects/merge_requests/conflicts/commit_stats" - - .files-wrapper{ "v-if" => "!isLoading && !hasError" } - .files - .diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" } - .js-file-title.file-title - %i.fa.fa-fw{ ":class" => "file.iconClass" } - %strong {{file.filePath}} - = render partial: 'projects/merge_requests/conflicts/file_actions' - .diff-content.diff-wrap-lines - .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } - = render partial: "projects/merge_requests/conflicts/components/inline_conflict_lines" - .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } - %parallel-conflict-lines{ ":file" => "file" } - %div{ "v-show" => "file.resolveMode === 'edit' || file.type === 'text-editor'" } - = render partial: "projects/merge_requests/conflicts/components/diff_file_editor" - - = render partial: "projects/merge_requests/conflicts/submit_form" diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index a6e2565a485..09aeb81671a 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -6,7 +6,7 @@ .merge-request-details.issuable-details = render "projects/merge_requests/mr_box" -= render 'shared/issuable/sidebar', issuable: @merge_request += render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees #conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json), resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } } diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index c178206dda4..d6f340d0ee2 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -5,6 +5,7 @@ - page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes +- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes') .merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } } = render "projects/merge_requests/mr_title" @@ -67,6 +68,7 @@ noteable_data: serialize_issuable(@merge_request), noteable_type: 'MergeRequest', target_type: 'merge_request', + help_page_path: suggest_changes_help_path, current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} } #commits.commits.tab-pane @@ -76,6 +78,7 @@ = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) #js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?, endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters), + help_page_path: suggest_changes_help_path, current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json, project_path: project_path(@merge_request.project), changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg') } } @@ -83,7 +86,8 @@ .mr-loading-status = spinner -= render 'shared/issuable/sidebar', issuable: @merge_request += render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees + - if @merge_request.can_be_reverted?(current_user) = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title - if @merge_request.can_be_cherry_picked? diff --git a/app/views/projects/releases/index.html.haml b/app/views/projects/releases/index.html.haml new file mode 100644 index 00000000000..f01d4e826b9 --- /dev/null +++ b/app/views/projects/releases/index.html.haml @@ -0,0 +1,5 @@ +- @no_container = true +- page_title _('Releases') + +%div{ class: container_class } + #js-releases-page{ data: { project_id: @project.id, illustration_path: image_path('illustrations/releases.svg'), documentation_path: help_page_path('user/project/releases') } } diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml index 597c029f755..a1d74b91002 100644 --- a/app/views/projects/services/prometheus/_metrics.html.haml +++ b/app/views/projects/services/prometheus/_metrics.html.haml @@ -1,6 +1,6 @@ - project = local_assigns.fetch(:project) -.card.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/metrics') } } +.card.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/index') } } .card-header = s_('PrometheusService|Common metrics') %span.badge.badge-pill.js-monitored-count 0 diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml index 1d0b0265bb7..9d4574c4590 100644 --- a/app/views/projects/services/prometheus/_show.html.haml +++ b/app/views/projects/services/prometheus/_show.html.haml @@ -4,7 +4,7 @@ = s_('PrometheusService|Metrics') %p = s_('PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters.') - = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus_library/metrics'), target: '_blank', rel: "noopener noreferrer" + = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus_library/index'), target: '_blank', rel: "noopener noreferrer" .col-lg-9 = render 'projects/services/prometheus/metrics', project: @project diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 98e2829ba43..6966bf96724 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -43,13 +43,7 @@ %section.qa-variables-settings.settings.no-animate{ class: ('expanded' if expanded) } .settings-header - %h4 - = _('Variables') - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' - %button.btn.js-settings-toggle{ type: 'button' } - = expanded ? _('Collapse') : _('Expand') - %p.append-bottom-0 - = render "ci/variables/content" + = render 'ci/variables/header', expanded: expanded .settings-content = render 'ci/variables/index', save_endpoint: project_variables_path(@project) diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 026bc44a05f..458096f9dd6 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -27,7 +27,7 @@ - if can?(current_user, :push_code, @project) = link_to new_project_tag_path(@project), class: 'btn btn-success new-tag-btn' do = s_('TagsPage|New tag') - = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn rss-btn has-tooltip' do + = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn d-none d-sm-inline-block has-tooltip' do = icon("rss") = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml index 52c6c7ec424..52c6c7ec424 100644 --- a/app/views/projects/releases/edit.html.haml +++ b/app/views/projects/tags/releases/edit.html.haml diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index ab56f48ba4d..c4d52431d6e 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,4 +1,4 @@ -- if @search_objects.empty? +- if @search_objects.to_a.empty? = render partial: "search/results/empty" - else .row-content-block diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml index bd68a3e4c84..9a1db831ad3 100644 --- a/app/views/shared/_milestones_sort_dropdown.html.haml +++ b/app/views/shared/_milestones_sort_dropdown.html.haml @@ -8,15 +8,15 @@ = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort %li - = link_to page_filter_path(sort: sort_value_due_date_soon, label: true) do + = link_to page_filter_path(sort: sort_value_due_date_soon) do = sort_title_due_date_soon - = link_to page_filter_path(sort: sort_value_due_date_later, label: true) do + = link_to page_filter_path(sort: sort_value_due_date_later) do = sort_title_due_date_later - = link_to page_filter_path(sort: sort_value_start_date_soon, label: true) do + = link_to page_filter_path(sort: sort_value_start_date_soon) do = sort_title_start_date_soon - = link_to page_filter_path(sort: sort_value_start_date_later, label: true) do + = link_to page_filter_path(sort: sort_value_start_date_later) do = sort_title_start_date_later - = link_to page_filter_path(sort: sort_value_name, label: true) do + = link_to page_filter_path(sort: sort_value_name) do = sort_title_name_asc - = link_to page_filter_path(sort: sort_value_name_desc, label: true) do + = link_to page_filter_path(sort: sort_value_name_desc) do = sort_title_name_desc diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index b43662947a8..6e2527bd1a1 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -7,7 +7,9 @@ %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } } = sprite_icon("arrow-down", css_class: "dropdown-btn-icon icon") %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } } - %li - = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }, default: true) - %li - = dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' }) + - if ssh_enabled? + %li + = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }, default: true) + - if http_enabled? + %li + = dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' }) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 1618655182c..c6a391ae563 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -17,7 +17,7 @@ = render 'shared/issuable/form/template_selector', issuable: issuable = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) -- if Feature.enabled?(:issue_suggestions) && Feature.enabled?(:graphql) +- if Gitlab::Graphql.enabled? #js-suggestions{ data: { project_path: @project.full_path } } = render 'shared/form_elements/description', model: issuable, form: form, project: project diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml index 157637dbd11..71123740ee4 100644 --- a/app/views/shared/issuable/_nav.html.haml +++ b/app/views/shared/issuable/_nav.html.haml @@ -4,20 +4,20 @@ %ul.nav-links.issues-state-filters.mobile-separator.nav.nav-tabs %li{ class: active_when(params[:state] == 'opened') }> - = link_to page_filter_path(state: 'opened', label: true), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do + = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do #{issuables_state_counter_text(type, :opened, display_count)} - if type == :merge_requests %li{ class: active_when(params[:state] == 'merged') }> - = link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do + = link_to page_filter_path(state: 'merged'), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do #{issuables_state_counter_text(type, :merged, display_count)} %li{ class: active_when(params[:state] == 'closed') }> - = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do + = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do #{issuables_state_counter_text(type, :closed, display_count)} - else %li{ class: active_when(params[:state] == 'closed') }> - = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed' } do + = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed' } do #{issuables_state_counter_text(type, :closed, display_count)} = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all, display_count) diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 46634693067..20847378495 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -11,7 +11,7 @@ - if params[:search].present? = hidden_field_tag :search, params[:search] - if @can_bulk_update - .check-all-holder.hidden + .check-all-holder.d-none.d-sm-block.hidden = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left" .issues-other-filters.filtered-search-wrapper .filtered-search-box diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 9eecfa39390..0520eda37a4 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,32 +1,37 @@ -- todo = issuable_todo(issuable) +-# `assignees` is being passed in for populating selected assignee values in the select box and rendering the assignee link + This should be removed when this sidebar is converted to Vue since assignee data is also available in the `issuable_sidebar` hash -%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } - .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } - - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) +- issuable_type = issuable_sidebar[:type] +- signed_in = !!issuable_sidebar.dig(:current_user, :id) +- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit) + +%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } + .issuable-sidebar .block.issuable-sidebar-header - - if current_user + - if signed_in %span.issuable-header-text.hide-collapsed.float-left = _('Todo') %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } } = sidebar_gutter_toggle_icon - - if current_user - = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable + - if signed_in + = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar - = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f| - - if current_user + = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f| + - if signed_in .block.todo.hide-expanded - = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true + = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true .block.assignee.qa-assignee-block - = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present? + = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees - = render_if_exists 'shared/issuable/sidebar_item_epic', issuable: issuable + = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar + - milestone = issuable_sidebar[:milestone] || {} .block.milestone - .sidebar-collapsed-icon.has-tooltip{ title: milestone_tooltip_title(issuable.milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } + .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } = icon('clock-o', 'aria-hidden': 'true') %span.milestone-title.collapse-truncated-title - - if issuable.milestone - = issuable.milestone.title + - if milestone.present? + = milestone[:title] - else = _('None') .title.hide-collapsed @@ -35,49 +40,50 @@ - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' .value.hide-collapsed - - if issuable.milestone - = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_due_date(issuable.milestone), data: { container: "body", html: 'true', boundary: 'viewport' } + - if milestone.present? + = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport' } - else %span.no-value = _('None') .selectbox.hide-collapsed - = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil - = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: project_milestones_path(@project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true, display: 'static' }}) - - if issuable.has_attribute?(:time_estimate) - #issuable-time-tracker.block - // Fallback while content is loading - .title.hide-collapsed - = _('Time tracking') - = icon('spinner spin', 'aria-hidden': 'true') - - if issuable.has_attribute?(:due_date) + = f.hidden_field 'milestone_id', value: milestone[:id], id: nil + = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], milestones: issuable_sidebar[:project_milestones_path], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }}) + + #issuable-time-tracker.block + // Fallback while content is loading + .title.hide-collapsed + = _('Time tracking') + = icon('spinner spin', 'aria-hidden': 'true') + + - if issuable_sidebar.has_key?(:due_date) .block.due_date - .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable) } + .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) } = icon('calendar', 'aria-hidden': 'true') %span.js-due-date-sidebar-value - = issuable.due_date.try(:to_s, :medium) || 'None' + = issuable_sidebar[:due_date].try(:to_s, :medium) || 'None' .title.hide-collapsed = _('Due date') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) + - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' .value.hide-collapsed %span.value-content - - if issuable.due_date - %span.bold= issuable.due_date.to_s(:medium) + - if issuable_sidebar[:due_date] + %span.bold= issuable_sidebar[:due_date].to_s(:medium) - else %span.no-value = _('No due date') - - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) } + - if can_edit_issuable + %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) } \- %a.js-remove-due-date{ href: "#", role: "button" } = _('remove due date') - - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) + - if can_edit_issuable .selectbox.hide-collapsed - = f.hidden_field :due_date, value: issuable.due_date.try(:strftime, 'yy-mm-dd') + = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd') .dropdown - %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable.to_ability_name}[due_date]", ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), display: 'static' } } + %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } } %span.dropdown-toggle-text = _('Due date') = icon('chevron-down', 'aria-hidden': 'true') @@ -86,56 +92,56 @@ = dropdown_content do .js-due-date-calendar - - if @labels - - selected_labels = issuable.labels - .block.labels - .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body", boundary: 'viewport' } } - = icon('tags', 'aria-hidden': 'true') - %span - = selected_labels.size - .title.hide-collapsed - = _('Labels') - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' - .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } - - if selected_labels.any? - - selected_labels.each do |label| - = link_to_label(label, subject: issuable.project, type: issuable.to_ability_name) - - else - %span.no-value - = _('None') - .selectbox.hide-collapsed + - selected_labels = issuable_sidebar[:labels] + .block.labels + .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } } + = icon('tags', 'aria-hidden': 'true') + %span + = selected_labels.size + .title.hide-collapsed + = _('Labels') + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' + .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } + - if selected_labels.any? - selected_labels.each do |label| - = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil - .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (labels_filter_path_with_defaults if @project), display: 'static' } } - %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } - = multi_label_name(selected_labels, "Labels") - = icon('chevron-down', 'aria-hidden': 'true') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable - = render partial: "shared/issuable/label_page_default" - - if can? current_user, :admin_label, @project and @project - = render partial: "shared/issuable/label_page_create" - - = render_if_exists 'shared/issuable/sidebar_weight', issuable: issuable - - - if issuable.has_attribute?(:confidential) + = link_to sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label[:title]) do + %span.badge.color-label.has-tooltip{ style: "background-color: #{label[:color]}; color: #{label[:text_color]}", title: label[:description], data: { container: "body" } } + = label[:title] + - else + %span.no-value + = _('None') + .selectbox.hide-collapsed + - selected_labels.each do |label| + = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil + .dropdown + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable_type}[label_names][]", ability_name: issuable_type, show_no: "true", show_any: "true", namespace_path: issuable_sidebar[:namespace_path], project_path: issuable_sidebar[:project_path], issue_update: issuable_sidebar[:issuable_json_path], labels: issuable_sidebar[:project_labels_path], display: 'static' } } + %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } + = multi_label_name(selected_labels, "Labels") + = icon('chevron-down', 'aria-hidden': 'true') + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + = render partial: "shared/issuable/label_page_default" + - if issuable_sidebar.dig(:current_user, :can_admin_label) + = render partial: "shared/issuable/label_page_create" + + = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar + + - if issuable_sidebar.has_key?(:confidential) -# haml-lint:disable InlineJavaScript - %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: @issue.confidential, is_editable: can_edit_issuable }.to_json.html_safe + %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe #js-confidential-entry-point - - if issuable.has_attribute?(:discussion_locked) - -# haml-lint:disable InlineJavaScript - %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe - #js-lock-entry-point + -# haml-lint:disable InlineJavaScript + %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe + #js-lock-entry-point .js-sidebar-participants-entry-point - - if current_user + - if signed_in .js-sidebar-subscriptions-entry-point - - project_ref = cross_project_reference(@project, issuable) + - project_ref = issuable_sidebar[:reference] .block.project-reference .sidebar-collapsed-icon.dont-change-state = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport') @@ -145,7 +151,8 @@ %cite{ title: project_ref } = project_ref = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport') - - if current_user && issuable.can_move?(current_user) + + - if issuable_sidebar.dig(:current_user, :can_move) .block.js-sidebar-move-issue-block .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') } = custom_icon('icon_arrow_right') @@ -164,4 +171,4 @@ = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon') -# haml-lint:disable InlineJavaScript - %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe + %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 8a13c7a3b83..c5cce1823f0 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,12 +1,17 @@ -- if issuable.is_a?(Issue) - #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]", signed_in: signed_in } } +- issuable_type = issuable_sidebar[:type] +- signed_in = !!issuable_sidebar.dig(:current_user, :id) +- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit) + +- if issuable_type == "issue" + #js-vue-sidebar-assignees{ data: { field: "#{issuable_type}[assignee_ids]", signed_in: signed_in } } .title.hide-collapsed = _('Assignee') = icon('spinner spin') - else - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body", boundary: 'viewport' }, title: sidebar_assignee_tooltip_label(issuable) } - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 24) + - assignee = assignees.first + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body", boundary: 'viewport' }, title: (issuable_sidebar.dig(:assignee, :name) || _('Assignee')) } + - if issuable_sidebar[:assignee] + = link_to_member(@project, assignee, size: 24) - else = icon('user', 'aria-hidden': 'true') .title.hide-collapsed @@ -18,13 +23,13 @@ %a.gutter-toggle.float-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => _('Toggle sidebar') } = sidebar_gutter_toggle_icon .value.hide-collapsed - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - - if !issuable.can_be_merged_by?(issuable.assignee) + - if issuable_sidebar[:assignee] + = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do + - if issuable_sidebar[:assignee][:can_merge] %span.float-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: _('Not allowed to merge') } = icon('exclamation-triangle', 'aria-hidden': 'true') %span.username - = issuable.assignee.to_reference + @#{issuable_sidebar[:assignee][:username]} - else %span.assign-yourself.no-value = _('No assignee') @@ -34,19 +39,33 @@ = _('assign yourself') .selectbox.hide-collapsed - - issuable.assignees.each do |assignee| - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } + - if assignees.none? + = hidden_field_tag "#{issuable_type}[assignee_ids][]", 0, id: nil + - else + - assignees.each do |assignee| + = hidden_field_tag "#{issuable_type}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } - - options = { toggle_class: 'js-user-search js-author-search', title: _('Assign to'), filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: _('Search users'), data: { first_user: current_user&.username, current_user: true, project_id: @project&.id, author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, display: 'static' } } + - options = { toggle_class: 'js-user-search js-author-search', + title: _('Assign to'), + filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', + placeholder: _('Search users'), + data: { first_user: issuable_sidebar.dig(:current_user, :username), + current_user: true, + project_id: issuable_sidebar[:project_id], + author_id: issuable_sidebar[:author_id], + field_name: "#{issuable_type}[assignee_ids][]", + issue_update: issuable_sidebar[:issuable_json_path], + ability_name: issuable_type, + null_user: true, + display: 'static' } } - title = _('Select assignee') - - if issuable.is_a?(Issue) - - unless issuable.assignees.any? - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil + - if issuable_type == "issue" - dropdown_options = issue_assignees_dropdown_options - title = dropdown_options[:title] - options[:toggle_class] += ' js-multiselect js-save-user-data' - - data = { field_name: "#{issuable.to_ability_name}[assignee_ids][]" } + - data = { field_name: "#{issuable_type}[assignee_ids][]" } - data[:multi_select] = true - data['dropdown-title'] = title - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml index 660ee6d5777..de4df016cfb 100644 --- a/app/views/shared/issuable/_sidebar_todo.html.haml +++ b/app/views/shared/issuable/_sidebar_todo.html.haml @@ -1,15 +1,15 @@ - is_collapsed = local_assigns.fetch(:is_collapsed, false) -- mark_content = is_collapsed ? sprite_icon('todo-done', css_class: 'todo-undone') : _('Mark todo as done') -- todo_content = is_collapsed ? sprite_icon('todo-add') : _('Add todo') +- has_todo = !!issuable_sidebar.dig(:current_user, :todo, :id) + +- todo_button_data = issuable_todo_button_data(issuable_sidebar, is_collapsed) +- button_title = has_todo ? todo_button_data[:mark_text] : todo_button_data[:todo_text] +- button_icon = has_todo ? todo_button_data[:mark_icon] : todo_button_data[:todo_icon] %button.issuable-todo-btn.js-issuable-todo{ type: 'button', class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn float-right'), - title: (todo.nil? ? _('Add todo') : _('Mark todo as done')), - 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark todo as done')), - data: issuable_todo_button_data(issuable, todo, is_collapsed) } + title: button_title, + 'aria-label' => button_title, + data: todo_button_data } %span.issuable-todo-inner.js-issuable-todo-inner< - - if todo - = mark_content - - else - = todo_content + = is_collapsed ? button_icon : button_title = icon('spin spinner', 'aria-hidden': 'true') diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml index c211b9fcaa2..b6ea9185b10 100644 --- a/app/views/shared/issuable/_sort_dropdown.html.haml +++ b/app/views/shared/issuable/_sort_dropdown.html.haml @@ -10,11 +10,12 @@ = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %li - = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority, label: true), sort_title) - = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sort_title) - = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sort_title) - = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sort_title) - = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sort_title) if viewing_issues - = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sort_title) - = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sort_title) + = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority), sort_title) + = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sort_title) + = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sort_title) + = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone), sort_title) + = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues + = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title) + = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title) + = render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title) = issuable_sort_direction_button(sort_value) diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index ac8d58c0bfe..e370dff9526 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -19,10 +19,9 @@ .issuable-form-select-holder = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" .form-group.row - - has_labels = @labels && @labels.any? = form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" = form.hidden_field :label_ids, multiple: true, value: '' - .col-sm-10{ class: "#{"col-md-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" } + .col-sm-10{ class: "#{"col-md-8" if has_due_date}" } .issuable-form-select-holder = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label" diff --git a/app/views/shared/issuable/nav_links/_all.html.haml b/app/views/shared/issuable/nav_links/_all.html.haml index d7ad7090a45..c92a50bcb70 100644 --- a/app/views/shared/issuable/nav_links/_all.html.haml +++ b/app/views/shared/issuable/nav_links/_all.html.haml @@ -2,5 +2,5 @@ - counter = local_assigns.fetch(:counter) %li{ class: active_when(params[:state] == 'all') }> - = link_to page_filter_path(state: 'all', label: true), id: 'state-all', title: "Show all #{page_context_word}.", data: { state: 'all' } do + = link_to page_filter_path(state: 'all'), id: 'state-all', title: "Show all #{page_context_word}.", data: { state: 'all' } do #{counter} diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml index d664ef1cc2f..07e96eea062 100644 --- a/app/views/shared/labels/_sort_dropdown.html.haml +++ b/app/views/shared/labels/_sort_dropdown.html.haml @@ -6,4 +6,4 @@ %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort %li - label_sort_options_hash.each do |value, title| - = sortable_item(title, page_filter_path(sort: value, label: true, subscribed: params[:subscribed]), sort_title) + = sortable_item(title, page_filter_path(sort: value), sort_title) diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index ed7fefba56d..40b8374848e 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -1,5 +1,5 @@ - dashboard = local_assigns[:dashboard] -- custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone) +- custom_dom_id = dom_id(milestone.try(:milestone) ? milestone.milestone : milestone) - milestone_type = milestone.group_milestone? ? 'Group Milestone' : 'Project Milestone' %li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id } @@ -21,10 +21,12 @@ = milestone.group.full_name - if milestone.legacy_group_milestone? .projects - - milestone.milestones.each do |milestone| - = link_to milestone_path(milestone) do - %span.label-badge.label-badge-blue.d-inline-block.append-bottom-5 - = dashboard ? milestone.project.full_name : milestone.project.name + - link_to milestone_path(milestone.milestone) do + %span.label-badge.label-badge-blue.d-inline-block.append-bottom-5 + = dashboard ? milestone.project.full_name : milestone.project.name + - if milestone.project + .label-badge.label-badge-gray.d-inline-block + = milestone.project.full_name .col-sm-4.milestone-progress = milestone_progress_bar(milestone) @@ -58,5 +60,5 @@ - else = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close" - if dashboard - .status-box.status-box-milestone + .label-badge.label-badge-gray = milestone_type diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index becd1c4884e..b24075c7849 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -65,7 +65,7 @@ %span.bold= milestone.due_date.to_s(:medium) - else %span.no-value No due date - - remaining_days = remaining_days_in_words(milestone) + - remaining_days = remaining_days_in_words(milestone.due_date, milestone.start_date) - if remaining_days.present? = surround '(', ')' do %span.remaining-days= remaining_days diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 0499b04a482..55b1c14022f 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -62,20 +62,19 @@ %th Open issues %th State %th Due date - - milestone.milestones.each do |ms| %tr %td - - project_name = group ? ms.project.name : ms.project.full_name - = link_to project_name, project_milestone_path(ms.project, ms) + - project_name = group ? milestone.project.name : milestone.project.full_name + = link_to project_name, milestone_path(milestone.milestone) %td - = ms.issues_visible_to_user(current_user).opened.count + = milestone.milestone.issues_visible_to_user(current_user).opened.count %td - - if ms.closed? + - if milestone.closed? Closed - else Open %td - = ms.expires_at + = milestone.expires_at - elsif milestone.group_milestone? %br View diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 9dde77fccef..fea7e17be3d 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -72,13 +72,13 @@ title: _('Forks'), data: { container: 'body', placement: 'top' } do = sprite_icon('fork', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.forks_count) - - if show_merge_request_count?(merge_requests, compact_mode) + - if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode) = link_to project_merge_requests_path(project), class: "d-none d-lg-flex align-items-center icon-wrapper merge-requests has-tooltip", title: _('Merge Requests'), data: { container: 'body', placement: 'top' } do = sprite_icon('git-merge', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.open_merge_requests_count) - - if show_issue_count?(issues, compact_mode) + - if show_issue_count?(disabled: !issues, compact_mode: compact_mode) = link_to project_issues_path(project), class: "d-none d-lg-flex align-items-center icon-wrapper issues has-tooltip", title: _('Issues'), data: { container: 'body', placement: 'top' } do diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 10bfc30492a..a43296aa806 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -30,7 +30,7 @@ - if @snippet.updated_at != @snippet.created_at = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) - - if public_snippet? + - if @snippet.embeddable? .embed-snippet .input-group .input-group-prepend diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index b5bc1180290..d22905ecc93 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -9,22 +9,24 @@ .col-md-12.col-lg-6 - if can?(current_user, :read_cross_project) .activities-block + .append-right-5 + .prepend-top-16 + .d-flex.align-items-center.border-bottom + %h4.flex-grow + = s_('UserProfile|Activity') + = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" + .overview-content-list{ data: { href: user_path } } + .center.light.loading + = spinner nil, true + + .col-md-12.col-lg-6 + .projects-block + .prepend-left-5 .prepend-top-16 .d-flex.align-items-center.border-bottom %h4.flex-grow - = s_('UserProfile|Activity') - = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" - .overview-content-list{ data: { href: user_path } } + = s_('UserProfile|Personal projects') + = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all" + .overview-content-list{ data: { href: user_projects_path } } .center.light.loading = spinner nil, true - - .col-md-12.col-lg-6 - .projects-block - .prepend-top-16 - .d-flex.align-items-center.border-bottom - %h4.flex-grow - = s_('UserProfile|Personal projects') - = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all" - .overview-content-list{ data: { href: user_projects_path } } - .center.light.loading - = spinner nil, true diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index dd2cd36eac2..8da63a29ca6 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -71,7 +71,7 @@ = icon('twitter-square') - unless @user.website_url.blank? .profile-link-holder.middle-dot-divider - = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'noopener noreferrer nofollow' + = 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') diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index bc26b3f8ef2..d3cf21db335 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -27,7 +27,7 @@ - gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:wait_for_cluster_creation - gcp_cluster:cluster_wait_for_ingress_ip_address -- gcp_cluster:cluster_platform_configure +- gcp_cluster:cluster_configure - gcp_cluster:cluster_project_configure - github_import_advance_stage @@ -88,6 +88,7 @@ - object_pool:object_pool_create - object_pool:object_pool_schedule_join - object_pool:object_pool_join +- object_pool:object_pool_destroy - default - mailers # ActionMailer::DeliveryJob.queue_name diff --git a/app/workers/cluster_platform_configure_worker.rb b/app/workers/cluster_configure_worker.rb index aa7570caa79..63e6cc147be 100644 --- a/app/workers/cluster_platform_configure_worker.rb +++ b/app/workers/cluster_configure_worker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ClusterPlatformConfigureWorker +class ClusterConfigureWorker include ApplicationWorker include ClusterQueue diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb index 3d5894b73ec..926ae2b7286 100644 --- a/app/workers/cluster_provision_worker.rb +++ b/app/workers/cluster_provision_worker.rb @@ -10,7 +10,7 @@ class ClusterProvisionWorker Clusters::Gcp::ProvisionService.new.execute(provider) if cluster.gcp? end - ClusterPlatformConfigureWorker.perform_async(cluster.id) if cluster.user? + ClusterConfigureWorker.perform_async(cluster.id) if cluster.user? end end end diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 4726e416182..c8ccaf0c487 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -8,14 +8,35 @@ module MailScheduler include MailSchedulerQueue def perform(meth, *args) - deserialized_args = ActiveJob::Arguments.deserialize(args) + check_arguments!(args) + deserialized_args = ActiveJob::Arguments.deserialize(args) notification_service.public_send(meth, *deserialized_args) # rubocop:disable GitlabSecurity/PublicSend rescue ActiveJob::DeserializationError + # No-op. + # This exception gets raised when an argument + # is correct (deserializeable), but it still cannot be deserialized. + # This can happen when an object has been deleted after + # rails passes this job to sidekiq, but before + # sidekiq gets it for execution. + # In this case just do nothing. end def self.perform_async(*args) super(*ActiveJob::Arguments.serialize(args)) end + + private + + # If an argument is in the ActiveJob::Arguments::TYPE_WHITELIST list, + # it means the argument cannot be deserialized. + # Which means there's something wrong with our code. + def check_arguments!(args) + args.each do |arg| + if arg.class.in?(ActiveJob::Arguments::TYPE_WHITELIST) + raise(ArgumentError, "Argument `#{arg}` cannot be deserialized because of its type") + end + end + end end end diff --git a/app/workers/object_pool/destroy_worker.rb b/app/workers/object_pool/destroy_worker.rb new file mode 100644 index 00000000000..ca00d467d9b --- /dev/null +++ b/app/workers/object_pool/destroy_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ObjectPool + class DestroyWorker + include ApplicationWorker + include ObjectPoolQueue + + def perform(pool_repository_id) + pool = PoolRepository.find_by_id(pool_repository_id) + return unless pool&.obsolete? + + pool.delete_object_pool + pool.destroy + end + end +end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 72a1733a2a8..bbd4ab159e4 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -3,7 +3,7 @@ class PostReceive include ApplicationWorker - def perform(gl_repository, identifier, changes) + def perform(gl_repository, identifier, changes, push_options = []) project, is_wiki = Gitlab::GlRepository.parse(gl_repository) if project.nil? @@ -15,7 +15,7 @@ class PostReceive # Use Sidekiq.logger so arguments can be correlated with execution # time and thread ID's. Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] - post_received = Gitlab::GitPostReceive.new(project, identifier, changes) + post_received = Gitlab::GitPostReceive.new(project, identifier, changes, push_options) if is_wiki process_wiki_changes(post_received) @@ -38,9 +38,21 @@ class PostReceive post_received.changes_refs do |oldrev, newrev, ref| if Gitlab::Git.tag_ref?(ref) - GitTagPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute + GitTagPushService.new( + post_received.project, + @user, + oldrev: oldrev, + newrev: newrev, + ref: ref, + push_options: post_received.push_options).execute elsif Gitlab::Git.branch_ref?(ref) - GitPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute + GitPushService.new( + post_received.project, + @user, + oldrev: oldrev, + newrev: newrev, + ref: ref, + push_options: post_received.push_options).execute end changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref) diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb index 98c81956cba..f34ed6c4844 100644 --- a/app/workers/stuck_merge_jobs_worker.rb +++ b/app/workers/stuck_merge_jobs_worker.rb @@ -4,6 +4,10 @@ class StuckMergeJobsWorker include ApplicationWorker include CronjobQueue + def self.logger + Rails.logger + end + # rubocop: disable CodeReuse/ActiveRecord def perform stuck_merge_requests.find_in_batches(batch_size: 100) do |group| @@ -35,7 +39,7 @@ class StuckMergeJobsWorker # We rely on state machine callbacks to update head_pipeline_id merge_requests_to_reopen.each(&:unlock_mr) - Rails.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}") + self.class.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}") end # rubocop: enable CodeReuse/ActiveRecord |