diff options
author | Mike Greiling <mike@pixelcog.com> | 2018-02-16 16:00:03 -0600 |
---|---|---|
committer | Mike Greiling <mike@pixelcog.com> | 2018-02-16 16:00:03 -0600 |
commit | 8e65c13a586031928c681c4926d059df23ad5753 (patch) | |
tree | df99f6a592a2d3f7f5fabb4c85c6b90f0343ca68 /app | |
parent | fa260ac8400b16bc19acc5740b47c596c1c903c0 (diff) | |
parent | b236348388c46c0550ec6844df35ec2689c4060b (diff) | |
download | gitlab-ce-8e65c13a586031928c681c4926d059df23ad5753.tar.gz |
Merge branch 'master' into chart.html.haml-refactorchart.html.haml-refactor
* master: (484 commits)
migrate admin:users:* to static bundle
correct for missing break statement in dispatcher.js
alias create and update actions to new and edit
migrate projects:merge_requests:edit to static bundle
migrate projects:merge_requests:creations:diffs to static bundle
migrate projects:merge_requests:creations:new to static bundle
migrate projects:issues:new and projects:issues:edit to static bundle
migrate projects:branches:index to static bundle
migrate projects:branches:new to static bundle
migrate projects:compare:show to static bundle
migrate projects:environments:metrics to static bundle
migrate projects:milestones:* and groups:milestones:* to static bundle
migrate explore:groups:index to static bundle
migrate explore:projects:* to static bundle
migrate dashboard:projects:* to static bundle
migrate admin:jobs:index to static bundle
migrate dashboard:todos:index to static bundle
migrate groups:merge_requests to static bundle
migrate groups:issues to static bundle
migrate dashboard:merge_requests to static bundle
...
Diffstat (limited to 'app')
318 files changed, 3311 insertions, 1758 deletions
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 87109a802e5..3283ce5ec36 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -50,10 +50,8 @@ class AwardsHandler { this.registerEventListener('on', $('html'), 'click', (e) => { const $target = $(e.target); - if (!$target.closest('.emoji-menu-content').length) { - $('.js-awards-block.current').removeClass('current'); - } if (!$target.closest('.emoji-menu').length) { + $('.js-awards-block.current').removeClass('current'); if ($('.emoji-menu').is(':visible')) { $('.js-add-award.is-active').removeClass('is-active'); this.hideMenuElement($('.emoji-menu')); diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index a8dafd31f12..9c4cc2338c8 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -2,7 +2,7 @@ import Sortable from 'vendor/Sortable'; import Vue from 'vue'; import AccessorUtilities from '../../lib/utils/accessor'; -import boardList from './board_list'; +import boardList from './board_list.vue'; import boardBlankState from './board_blank_state'; import './board_delete'; diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.vue index 591f1dc8313..9a0442e2afe 100644 --- a/app/assets/javascripts/boards/components/board_list.js +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,3 +1,4 @@ +<script> import Sortable from 'vendor/Sortable'; import boardNewIssue from './board_new_issue'; import boardCard from './board_card.vue'; @@ -8,6 +9,11 @@ const Store = gl.issueBoards.BoardsStore; export default { name: 'BoardList', + components: { + boardCard, + boardNewIssue, + loadingIcon, + }, props: { disabled: { type: Boolean, @@ -42,46 +48,6 @@ export default { showIssueForm: false, }; }, - components: { - boardCard, - boardNewIssue, - loadingIcon, - }, - methods: { - listHeight() { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight() { - return this.$refs.list.scrollHeight; - }, - scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); - }, - scrollToTop() { - this.$refs.list.scrollTop = 0; - }, - loadNextPage() { - const getIssues = this.list.nextPage(); - const loadingDone = () => { - this.list.loadingMore = false; - }; - - if (getIssues) { - this.list.loadingMore = true; - getIssues - .then(loadingDone) - .catch(loadingDone); - } - }, - toggleForm() { - this.showIssueForm = !this.showIssueForm; - }, - onScroll() { - if (!this.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) { - this.loadNextPage(); - } - }, - }, watch: { filters: { handler() { @@ -157,51 +123,90 @@ export default { eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); this.$refs.list.removeEventListener('scroll', this.onScroll); }, - template: ` - <div class="board-list-component"> - <div - class="board-list-loading text-center" - aria-label="Loading issues" - v-if="loading"> - <loading-icon /> - </div> - <board-new-issue - :list="list" - v-if="list.type !== 'closed' && showIssueForm"/> - <ul - class="board-list" - v-show="!loading" - ref="list" - :data-board="list.id" - :class="{ 'is-smaller': showIssueForm }"> - <board-card - v-for="(issue, index) in issues" - ref="issue" - :index="index" - :list="list" - :issue="issue" - :issue-link-base="issueLinkBase" - :root-path="rootPath" - :disabled="disabled" - :key="issue.id" /> - <li - class="board-list-count text-center" - v-if="showCount" - data-issue-id="-1"> + methods: { + listHeight() { + return this.$refs.list.getBoundingClientRect().height; + }, + scrollHeight() { + return this.$refs.list.scrollHeight; + }, + scrollTop() { + return this.$refs.list.scrollTop + this.listHeight(); + }, + scrollToTop() { + this.$refs.list.scrollTop = 0; + }, + loadNextPage() { + const getIssues = this.list.nextPage(); + const loadingDone = () => { + this.list.loadingMore = false; + }; - <loading-icon - v-show="list.loadingMore" - label="Loading more issues" - /> + if (getIssues) { + this.list.loadingMore = true; + getIssues + .then(loadingDone) + .catch(loadingDone); + } + }, + toggleForm() { + this.showIssueForm = !this.showIssueForm; + }, + onScroll() { + if (!this.loadingMore && (this.scrollTop() > this.scrollHeight() - this.scrollOffset)) { + this.loadNextPage(); + } + }, + }, +}; +</script> - <span v-if="list.issues.length === list.issuesSize"> - Showing all issues - </span> - <span v-else> - Showing {{ list.issues.length }} of {{ list.issuesSize }} issues - </span> - </li> - </ul> +<template> + <div class="board-list-component"> + <div + class="board-list-loading text-center" + aria-label="Loading issues" + v-if="loading"> + <loading-icon /> </div> - `, -}; + <board-new-issue + :list="list" + v-if="list.type !== 'closed' && showIssueForm"/> + <ul + class="board-list" + v-show="!loading" + ref="list" + :data-board="list.id" + :class="{ 'is-smaller': showIssueForm }"> + <board-card + v-for="(issue, index) in issues" + ref="issue" + :index="index" + :list="list" + :issue="issue" + :issue-link-base="issueLinkBase" + :root-path="rootPath" + :disabled="disabled" + :key="issue.id" /> + <li + class="board-list-count text-center" + v-if="showCount" + data-issue-id="-1"> + <loading-icon + v-show="list.loadingMore" + label="Loading more issues" + /> + <span + v-if="list.issues.length === list.issuesSize" + > + Showing all issues + </span> + <span + v-else + > + Showing {{ list.issues.length }} of {{ list.issuesSize }} issues + </span> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 983429550f0..add24303e7b 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Flash from '../../flash'; +import { __ } from '../../locale'; import Sidebar from '../../right_sidebar'; import eventHub from '../../sidebar/event_hub'; import assigneeTitle from '../../sidebar/components/assignees/assignee_title'; @@ -95,7 +96,7 @@ gl.issueBoards.BoardSidebar = Vue.extend({ }) .catch(() => { this.loadingAssignees = false; - return new Flash('An error occurred while saving assignees'); + Flash(__('An error occurred while saving assignees')); }); }, }, diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js index 182957113a2..03cd7ef65cb 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -1,7 +1,6 @@ -/* eslint-disable no-new */ - import Vue from 'vue'; import Flash from '../../../flash'; +import { __ } from '../../../locale'; import './lists_dropdown'; import { pluralize } from '../../../lib/utils/text_utility'; @@ -36,7 +35,7 @@ gl.issueBoards.ModalFooter = Vue.extend({ gl.boardService.bulkUpdate(issueIds, { add_label_ids: [list.label.id], }).catch(() => { - new Flash('Failed to update issues, please try again.', 'alert'); + Flash(__('Failed to update issues, please try again.')); selectedIssues.forEach((issue) => { list.removeIssue(issue); diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index c19c989680d..cf0bb5f5376 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, no-new, space-before-function-paren, one-var, promise/catch-or-return */ +import axios from '~/lib/utils/axios_utils'; import _ from 'underscore'; import CreateLabelDropdown from '../../create_label'; @@ -28,9 +29,9 @@ gl.issueBoards.newListDropdownInit = () => { $this.glDropdown({ data(term, callback) { - $.get($this.attr('data-list-labels-path')) - .then((resp) => { - callback(resp); + axios.get($this.attr('data-list-labels-path')) + .then(({ data }) => { + callback(data); }); }, renderRow (label) { diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js index 1ad97211934..0ae32bb4d0a 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js @@ -1,7 +1,6 @@ -/* eslint-disable no-new */ - import Vue from 'vue'; import Flash from '../../../flash'; +import { __ } from '../../../locale'; const Store = gl.issueBoards.BoardsStore; @@ -45,7 +44,7 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ }, }; Vue.http.patch(this.updateUrl, data).catch(() => { - new Flash('Failed to remove issue from board, please try again.', 'alert'); + Flash(__('Failed to remove issue from board, please try again.')); lists.forEach((list) => { list.addIssue(issue); diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js new file mode 100644 index 00000000000..b33adff609f --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js @@ -0,0 +1,117 @@ +import _ from 'underscore'; +import axios from '../lib/utils/axios_utils'; +import { s__ } from '../locale'; +import Flash from '../flash'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; +import statusCodes from '../lib/utils/http_status'; +import VariableList from './ci_variable_list'; + +function generateErrorBoxContent(errors) { + const errorList = [].concat(errors).map(errorString => ` + <li> + ${_.escape(errorString)} + </li> + `); + + return ` + <p> + ${s__('CiVariable|Validation failed')} + </p> + <ul> + ${errorList.join('')} + </ul> + `; +} + +// Used for the variable list on CI/CD projects/groups settings page +export default class AjaxVariableList { + constructor({ + container, + saveButton, + errorBox, + formField = 'variables', + saveEndpoint, + }) { + this.container = container; + this.saveButton = saveButton; + this.errorBox = errorBox; + this.saveEndpoint = saveEndpoint; + + this.variableList = new VariableList({ + container: this.container, + formField, + }); + + this.bindEvents(); + this.variableList.init(); + } + + bindEvents() { + this.saveButton.addEventListener('click', this.onSaveClicked.bind(this)); + } + + onSaveClicked() { + const loadingIcon = this.saveButton.querySelector('.js-secret-variables-save-loading-icon'); + loadingIcon.classList.toggle('hide', false); + this.errorBox.classList.toggle('hide', true); + // We use this to prevent a user from changing a key before we have a chance + // to match it up in `updateRowsWithPersistedVariables` + this.variableList.toggleEnableRow(false); + + return axios.patch(this.saveEndpoint, { + variables_attributes: this.variableList.getAllData(), + }, { + // We want to be able to process the `res.data` from a 400 error response + // and print the validation messages such as duplicate variable keys + validateStatus: status => ( + status >= statusCodes.OK && + status < statusCodes.MULTIPLE_CHOICES + ) || + status === statusCodes.BAD_REQUEST, + }) + .then((res) => { + loadingIcon.classList.toggle('hide', true); + this.variableList.toggleEnableRow(true); + + if (res.status === statusCodes.OK && res.data) { + this.updateRowsWithPersistedVariables(res.data.variables); + this.variableList.hideValues(); + } else if (res.status === statusCodes.BAD_REQUEST) { + // Validation failed + this.errorBox.innerHTML = generateErrorBoxContent(res.data); + this.errorBox.classList.toggle('hide', false); + } + }) + .catch(() => { + loadingIcon.classList.toggle('hide', true); + this.variableList.toggleEnableRow(true); + Flash(s__('CiVariable|Error occured while saving variables')); + }); + } + + updateRowsWithPersistedVariables(persistedVariables = []) { + const persistedVariableMap = [].concat(persistedVariables).reduce((variableMap, variable) => ({ + ...variableMap, + [variable.key]: variable, + }), {}); + + this.container.querySelectorAll('.js-row').forEach((row) => { + // If we submitted a row that was destroyed, remove it so we don't try + // to destroy it again which would cause a BE error + const destroyInput = row.querySelector('.js-ci-variable-input-destroy'); + if (convertPermissionToBoolean(destroyInput.value)) { + row.remove(); + // Update the ID input so any future edits and `_destroy` will apply on the BE + } else { + const key = row.querySelector('.js-ci-variable-input-key').value; + const persistedVariable = persistedVariableMap[key]; + + if (persistedVariable) { + // eslint-disable-next-line no-param-reassign + row.querySelector('.js-ci-variable-input-id').value = persistedVariable.id; + row.setAttribute('data-is-persisted', 'true'); + } + } + }); + } +} 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 e46478ddb98..745f3404295 100644 --- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js @@ -11,7 +11,7 @@ function createEnvironmentItem(value) { return { title: value === '*' ? ALL_ENVIRONMENTS_STRING : value, id: value, - text: value, + text: value === '*' ? s__('CiVariable|* (All environments)') : value, }; } @@ -39,13 +39,13 @@ export default class VariableList { }, protected: { selector: '.js-ci-variable-input-protected', - default: 'true', + default: 'false', }, - environment: { + environment_scope: { // We can't use a `.js-` class here because // gl_dropdown replaces the <input> and doesn't copy over the class // See https://gitlab.com/gitlab-org/gitlab-ce/issues/42458 - selector: `input[name="${this.formField}[variables_attributes][][environment]"]`, + selector: `input[name="${this.formField}[variables_attributes][][environment_scope]"]`, default: '*', }, _destroy: { @@ -104,12 +104,15 @@ export default class VariableList { setupToggleButtons($row[0]); + // Reset the resizable textarea + $row.find(this.inputMap.value.selector).css('height', ''); + const $environmentSelect = $row.find('.js-variable-environment-toggle'); if ($environmentSelect.length) { const createItemDropdown = new CreateItemDropdown({ $dropdown: $environmentSelect, defaultToggleLabel: ALL_ENVIRONMENTS_STRING, - fieldName: `${this.formField}[variables_attributes][][environment]`, + fieldName: `${this.formField}[variables_attributes][][environment_scope]`, getData: (term, callback) => callback(this.getEnvironmentValues()), createNewItemFromValue: createEnvironmentItem, onSelect: () => { @@ -117,7 +120,7 @@ export default class VariableList { // so they have the new value we just picked this.refreshDropdownData(); - $row.find(this.inputMap.environment.selector).trigger('trigger-change'); + $row.find(this.inputMap.environment_scope.selector).trigger('trigger-change'); }, }); @@ -143,7 +146,8 @@ export default class VariableList { $row.after($rowClone); } - removeRow($row) { + removeRow(row) { + const $row = $(row); const isPersisted = convertPermissionToBoolean($row.attr('data-is-persisted')); if (isPersisted) { @@ -155,6 +159,10 @@ export default class VariableList { } else { $row.remove(); } + + // Refresh the other dropdowns in the variable list + // so any value with the variable deleted is gone + this.refreshDropdownData(); } checkIfRowTouched($row) { @@ -165,6 +173,15 @@ export default class VariableList { }); } + toggleEnableRow(isEnabled = true) { + this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled); + this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); + } + + hideValues() { + this.secretValues.updateDom(false); + } + getAllData() { // Ignore the last empty row because we don't want to try persist // a blank variable and run into validation problems. @@ -185,7 +202,7 @@ export default class VariableList { } getEnvironmentValues() { - const valueMap = this.$container.find(this.inputMap.environment.selector).toArray() + const valueMap = this.$container.find(this.inputMap.environment_scope.selector).toArray() .reduce((prevValueMap, envInput) => ({ ...prevValueMap, [envInput.value]: envInput.value, diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 3d6ec37e6dd..b070a59cf15 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -32,6 +32,7 @@ export default class Clusters { installIngressPath, installRunnerPath, installPrometheusPath, + managePrometheusPath, clusterStatus, clusterStatusReason, helpPath, @@ -40,6 +41,7 @@ export default class Clusters { this.store = new ClustersStore(); this.store.setHelpPaths(helpPath, ingressHelpPath); + this.store.setManagePrometheusPath(managePrometheusPath); this.store.updateStatus(clusterStatus); this.store.updateStatusReason(clusterStatusReason); this.service = new ClustersService({ @@ -95,6 +97,7 @@ export default class Clusters { applications: this.state.applications, helpPath: this.state.helpPath, ingressHelpPath: this.state.ingressHelpPath, + managePrometheusPath: this.state.managePrometheusPath, }, }); }, diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index c13bbcee863..50e35bbbba5 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -32,6 +32,10 @@ type: String, required: false, }, + manageLink: { + type: String, + required: false, + }, description: { type: String, required: true, @@ -89,6 +93,12 @@ return label; }, + showManageButton() { + return this.manageLink && this.status === APPLICATION_INSTALLED; + }, + manageButtonLabel() { + return s__('ClusterIntegration|Manage'); + }, hasError() { return this.status === APPLICATION_ERROR || this.requestStatus === REQUEST_FAILURE; @@ -141,9 +151,21 @@ <div v-html="description"></div> </div> <div - class="table-section table-button-footer section-15 section-align-top" + class="table-section table-button-footer section-align-top" + :class="{ 'section-20': showManageButton, 'section-15': !showManageButton }" role="gridcell" > + <div + v-if="showManageButton" + class="btn-group table-action-buttons" + > + <a + class="btn" + :href="manageLink" + > + {{ manageButtonLabel }} + </a> + </div> <div class="btn-group table-action-buttons"> <loading-button class="js-cluster-application-install-button" diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index f4259700370..978881a4831 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -23,13 +23,19 @@ required: false, default: '', }, + managePrometheusPath: { + type: String, + required: false, + default: '', + }, }, computed: { generalApplicationDescription() { return sprintf( - _.escape(s__(`ClusterIntegration|Install applications on your Kubernetes cluster. - Read more about %{helpLink}`)), - { + _.escape(s__( + `ClusterIntegration|Install applications on your Kubernetes cluster. + Read more about %{helpLink}`, + )), { helpLink: `<a href="${this.helpPath}"> ${_.escape(s__('ClusterIntegration|installing applications'))} </a>`, @@ -96,11 +102,12 @@ }, prometheusDescription() { return sprintf( - _.escape(s__(`ClusterIntegration|Prometheus is an open-source monitoring system - with %{gitlabIntegrationLink} to monitor deployed applications.`)), - { + _.escape(s__( + `ClusterIntegration|Prometheus is an open-source monitoring system + with %{gitlabIntegrationLink} to monitor deployed applications.`, + )), { gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" -target="_blank" rel="noopener noreferrer"> + target="_blank" rel="noopener noreferrer"> ${_.escape(s__('ClusterIntegration|GitLab Integration'))}</a>`, }, false, @@ -149,6 +156,7 @@ target="_blank" rel="noopener noreferrer"> id="prometheus" :title="applications.prometheus.title" title-link="https://prometheus.io/docs/introduction/overview/" + :manage-link="managePrometheusPath" :description="prometheusDescription" :status="applications.prometheus.status" :status-reason="applications.prometheus.statusReason" diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 49c3d184ef9..904ee5fd475 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -45,6 +45,10 @@ export default class ClusterStore { this.state.ingressHelpPath = ingressHelpPath; } + setManagePrometheusPath(managePrometheusPath) { + this.state.managePrometheusPath = managePrometheusPath; + } + updateStatus(status) { this.state.status = status; } diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 525fbf9dac9..6504a0bbbfc 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,5 +1,4 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-use-before-define, prefer-arrow-callback, no-else-return, consistent-return, prefer-template, quotes, one-var, one-var-declaration-per-line, no-unused-vars, no-return-assign, comma-dangle, quote-props, no-unused-expressions, no-sequences, object-shorthand, max-len */ -import 'vendor/jquery.waitforimages'; // Width where images must fits in, for 2-up this gets divided by 2 const availWidth = 900; diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index 4b2f75fffde..2be63bd8c76 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -1,52 +1,36 @@ -/* eslint-disable func-names, wrap-iife, consistent-return, - no-return-assign, no-param-reassign, one-var-declaration-per-line, no-unused-vars, - prefer-template, object-shorthand, prefer-arrow-callback */ - import { pluralize } from './lib/utils/text_utility'; import { localTimeAgo } from './lib/utils/datetime_utility'; import Pager from './pager'; import axios from './lib/utils/axios_utils'; -export default (function () { - const CommitsList = {}; - - CommitsList.timer = null; +export default class CommitsList { + constructor(limit = 0) { + this.timer = null; - CommitsList.init = function (limit) { this.$contentList = $('.content_list'); - $('body').on('click', '.day-commits-table li.commit', function (e) { - if (e.target.nodeName !== 'A') { - location.href = $(this).attr('url'); - e.stopPropagation(); - return false; - } - }); - - Pager.init(parseInt(limit, 10), false, false, this.processCommits); + Pager.init(parseInt(limit, 10), false, false, this.processCommits.bind(this)); this.content = $('#commits-list'); this.searchField = $('#commits-search'); this.lastSearch = this.searchField.val(); - return this.initSearch(); - }; + this.initSearch(); + } - CommitsList.initSearch = function () { + initSearch() { this.timer = null; - return this.searchField.keyup((function (_this) { - return function () { - clearTimeout(_this.timer); - return _this.timer = setTimeout(_this.filterResults, 500); - }; - })(this)); - }; + this.searchField.on('keyup', () => { + clearTimeout(this.timer); + this.timer = setTimeout(this.filterResults.bind(this), 500); + }); + } - CommitsList.filterResults = function () { + filterResults() { const form = $('.commits-search-form'); - const search = CommitsList.searchField.val(); - if (search === CommitsList.lastSearch) return Promise.resolve(); - const commitsUrl = form.attr('action') + '?' + form.serialize(); - CommitsList.content.fadeTo('fast', 0.5); + const search = this.searchField.val(); + if (search === this.lastSearch) return Promise.resolve(); + const commitsUrl = `${form.attr('action')}?${form.serialize()}`; + this.content.fadeTo('fast', 0.5); const params = form.serializeArray().reduce((acc, obj) => Object.assign(acc, { [obj.name]: obj.value, }), {}); @@ -55,9 +39,9 @@ export default (function () { params, }) .then(({ data }) => { - CommitsList.lastSearch = search; - CommitsList.content.html(data.html); - CommitsList.content.fadeTo('fast', 1.0); + this.lastSearch = search; + this.content.html(data.html); + this.content.fadeTo('fast', 1.0); // Change url so if user reload a page - search results are saved history.replaceState({ @@ -65,16 +49,16 @@ export default (function () { }, document.title, commitsUrl); }) .catch(() => { - CommitsList.content.fadeTo('fast', 1.0); - CommitsList.lastSearch = null; + this.content.fadeTo('fast', 1.0); + this.lastSearch = null; }); - }; + } // Prepare loaded data. - CommitsList.processCommits = (data) => { + processCommits(data) { let processedData = data; const $processedData = $(processedData); - const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last(); + const $commitsHeadersLast = this.$contentList.find('li.js-commit-header').last(); const lastShownDay = $commitsHeadersLast.data('day'); const $loadedCommitsHeadersFirst = $processedData.filter('li.js-commit-header').first(); const loadedShownDayFirst = $loadedCommitsHeadersFirst.data('day'); @@ -97,7 +81,5 @@ export default (function () { localTimeAgo($processedData.find('.js-timeago')); return processedData; - }; - - return CommitsList; -})(); + } +} diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js index b93e94a3c97..a7ed175f7a4 100644 --- a/app/assets/javascripts/commons/jquery.js +++ b/app/assets/javascripts/commons/jquery.js @@ -6,5 +6,5 @@ import 'vendor/jquery.endless-scroll'; import 'vendor/jquery.caret'; import 'vendor/jquery.atwho'; import 'vendor/jquery.scrollTo'; -import 'vendor/jquery.waitforimages'; +import 'jquery.waitforimages'; import 'select2/select2'; diff --git a/app/assets/javascripts/commons/polyfills/element.js b/app/assets/javascripts/commons/polyfills/element.js index 9a1f73bf2ac..b593bde6aa2 100644 --- a/app/assets/javascripts/commons/polyfills/element.js +++ b/app/assets/javascripts/commons/polyfills/element.js @@ -18,3 +18,22 @@ Element.prototype.matches = Element.prototype.matches || while (i >= 0 && elms.item(i) !== this) { i -= 1; } return i > -1; }; + +// From the polyfill on MDN, https://developer.mozilla.org/en-US/docs/Web/API/ChildNode/remove#Polyfill +((arr) => { + arr.forEach((item) => { + if (Object.prototype.hasOwnProperty.call(item, 'remove')) { + return; + } + Object.defineProperty(item, 'remove', { + configurable: true, + enumerable: true, + writable: true, + value: function remove() { + if (this.parentNode !== null) { + this.parentNode.removeChild(this); + } + }, + }); + }); +})([Element.prototype, CharacterData.prototype, DocumentType.prototype]); diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 482d83621e2..fb1fc9cd32e 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -180,6 +180,7 @@ export default class CreateMergeRequestDropdown { valueAttribute: 'data-text', }, ], + hideOnClick: false, }; } diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index a162424b3cf..3ab8f3ab7ad 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -1,3 +1,6 @@ +import axios from '~/lib/utils/axios_utils'; +import flash from '~/flash'; +import { __ } from '~/locale'; import { getLocationHash } from './lib/utils/url_utility'; import FilesCommentButton from './files_comment_button'; import SingleFileDiff from './single_file_diff'; @@ -69,7 +72,9 @@ export default class Diff { const view = file.data('view'); const params = { since, to, bottom, offset, unfold, view }; - $.get(link, params, response => $target.parent().replaceWith(response)); + axios.get(link, { params }) + .then(({ data }) => $target.parent().replaceWith(data)) + .catch(() => flash(__('An error occurred while loading diff'))); } openAnchoredDiff(cb) { diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index ab28b7d8d44..7e750d15d3d 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,15 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ -import MergeRequest from './merge_request'; import Flash from './flash'; import GfmAutoComplete from './gfm_auto_complete'; -import ZenMode from './zen_mode'; -import initNotes from './init_notes'; -import initIssuableSidebar from './init_issuable_sidebar'; import { convertPermissionToBoolean } from './lib/utils/common_utils'; import GlFieldErrors from './gl_field_errors'; import Shortcuts from './shortcuts'; -import ShortcutsIssuable from './shortcuts_issuable'; -import Diff from './diff'; import SearchAutocomplete from './search_autocomplete'; var Dispatcher; @@ -49,154 +43,14 @@ var Dispatcher; }); switch (page) { - case 'projects:environments:metrics': - import('./pages/projects/environments/metrics') - .then(callDefault) - .catch(fail); - break; case 'projects:merge_requests:index': case 'projects:issues:index': case 'projects:issues:show': - shortcut_handler = true; - break; - case 'projects:milestones:index': - import('./pages/projects/milestones/index') - .then(callDefault) - .catch(fail); - break; - case 'projects:milestones:show': - import('./pages/projects/milestones/show') - .then(callDefault) - .catch(fail); - break; - case 'groups:milestones:show': - import('./pages/groups/milestones/show') - .then(callDefault) - .catch(fail); - break; - case 'dashboard:milestones:show': - import('./pages/dashboard/milestones/show') - .then(callDefault) - .catch(fail); - break; - case 'dashboard:issues': - import('./pages/dashboard/issues') - .then(callDefault) - .catch(fail); - break; - case 'dashboard:merge_requests': - import('./pages/dashboard/merge_requests') - .then(callDefault) - .catch(fail); - break; - case 'groups:issues': - import('./pages/groups/issues') - .then(callDefault) - .catch(fail); - break; - case 'groups:merge_requests': - import('./pages/groups/merge_requests') - .then(callDefault) - .catch(fail); - break; - case 'dashboard:todos:index': - import('./pages/dashboard/todos/index') - .then(callDefault) - .catch(fail); - break; - case 'admin:jobs:index': - import('./pages/admin/jobs/index') - .then(callDefault) - .catch(fail); - break; - case 'dashboard:projects:index': - case 'dashboard:projects:starred': - import('./pages/dashboard/projects') - .then(callDefault) - .catch(fail); - break; - case 'explore:projects:index': - case 'explore:projects:trending': - case 'explore:projects:starred': - import('./pages/explore/projects') - .then(callDefault) - .catch(fail); - break; - case 'explore:groups:index': - import('./pages/explore/groups') - .then(callDefault) - .catch(fail); - break; - case 'projects:milestones:new': - case 'projects:milestones:create': - import('./pages/projects/milestones/new') - .then(callDefault) - .catch(fail); - break; - case 'projects:milestones:edit': - case 'projects:milestones:update': - import('./pages/projects/milestones/edit') - .then(callDefault) - .catch(fail); - break; - case 'groups:milestones:new': - case 'groups:milestones:create': - import('./pages/groups/milestones/new') - .then(callDefault) - .catch(fail); - break; - case 'groups:milestones:edit': - case 'groups:milestones:update': - import('./pages/groups/milestones/edit') - .then(callDefault) - .catch(fail); - break; - case 'projects:compare:show': - import('./pages/projects/compare/show') - .then(callDefault) - .catch(fail); - break; - case 'projects:branches:new': - import('./pages/projects/branches/new') - .then(callDefault) - .catch(fail); - break; - case 'projects:branches:create': - import('./pages/projects/branches/new') - .then(callDefault) - .catch(fail); - break; - case 'projects:branches:index': - import('./pages/projects/branches/index') - .then(callDefault) - .catch(fail); - break; case 'projects:issues:new': - import('./pages/projects/issues/new') - .then(callDefault) - .catch(fail); - shortcut_handler = true; - break; case 'projects:issues:edit': - import('./pages/projects/issues/edit') - .then(callDefault) - .catch(fail); - shortcut_handler = true; - break; case 'projects:merge_requests:creations:new': - import('./pages/projects/merge_requests/creations/new') - .then(callDefault) - .catch(fail); case 'projects:merge_requests:creations:diffs': - import('./pages/projects/merge_requests/creations/diffs') - .then(callDefault) - .catch(fail); - shortcut_handler = true; - break; case 'projects:merge_requests:edit': - import('./pages/projects/merge_requests/edit') - .then(callDefault) - .catch(fail); shortcut_handler = true; break; case 'projects:tags:new': @@ -215,6 +69,11 @@ var Dispatcher; .then(callDefault) .catch(fail); break; + case 'projects:services:edit': + import('./pages/projects/services/edit') + .then(callDefault) + .catch(fail); + break; case 'projects:snippets:edit': case 'projects:snippets:update': import('./pages/projects/snippets/edit') @@ -247,17 +106,10 @@ var Dispatcher; .catch(fail); break; case 'projects:merge_requests:show': - new Diff(); - new ZenMode(); - - initIssuableSidebar(); - initNotes(); - - const mrShowNode = document.querySelector('.merge-request'); - window.mergeRequest = new MergeRequest({ - action: mrShowNode.dataset.mrAction, - }); - shortcut_handler = new ShortcutsIssuable(true); + import('./pages/projects/merge_requests/show') + .then(callDefault) + .catch(fail); + shortcut_handler = true; break; case 'dashboard:activity': import('./pages/dashboard/activity') @@ -460,11 +312,6 @@ var Dispatcher; .then(callDefault) .catch(fail); break; - case 'users:show': - import('./pages/users/show') - .then(callDefault) - .catch(fail); - break; case 'admin:conversational_development_index:show': import('./pages/admin/conversational_development_index/show') .then(callDefault) diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js index 673e9bb4c0f..868d47e91b3 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/droplab/constants.js @@ -3,7 +3,6 @@ const DATA_DROPDOWN = 'data-dropdown'; const SELECTED_CLASS = 'droplab-item-selected'; const ACTIVE_CLASS = 'droplab-item-active'; const IGNORE_CLASS = 'droplab-item-ignore'; -const IGNORE_HIDING_CLASS = 'droplab-item-ignore-hiding'; // Matches `{{anything}}` and `{{ everything }}`. const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; @@ -14,5 +13,4 @@ export { ACTIVE_CLASS, TEMPLATE_REGEX, IGNORE_CLASS, - IGNORE_HIDING_CLASS, }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 5eb0a339a1c..3cc316c3f3e 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -1,13 +1,14 @@ import utils from './utils'; -import { SELECTED_CLASS, IGNORE_CLASS, IGNORE_HIDING_CLASS } from './constants'; +import { SELECTED_CLASS, IGNORE_CLASS } from './constants'; class DropDown { - constructor(list, config = {}) { + constructor(list, config = { }) { this.currentIndex = 0; this.hidden = true; this.list = typeof list === 'string' ? document.querySelector(list) : list; this.items = []; this.eventWrapper = {}; + this.hideOnClick = config.hideOnClick !== false; if (config.addActiveClassToDropdownButton) { this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle'); @@ -37,15 +38,17 @@ class DropDown { clickEvent(e) { if (e.target.tagName === 'UL') return; - if (e.target.classList.contains(IGNORE_CLASS)) return; + if (e.target.closest(`.${IGNORE_CLASS}`)) return; - const selected = utils.closest(e.target, 'LI'); + const selected = e.target.closest('li'); if (!selected) return; this.addSelectedClass(selected); e.preventDefault(); - if (!e.target.classList.contains(IGNORE_HIDING_CLASS)) this.hide(); + if (this.hideOnClick) { + this.hide(); + } const listEvent = new CustomEvent('click.dl', { detail: { diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index a9d554e549e..79326ca3487 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,8 +1,9 @@ <script> import Timeago from 'timeago.js'; import _ from 'underscore'; - import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - import { humanize } from '../../lib/utils/text_utility'; + import tooltip from '~/vue_shared/directives/tooltip'; + import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + import { humanize } from '~/lib/utils/text_utility'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; import StopComponent from './environment_stop.vue'; @@ -21,14 +22,18 @@ export default { components: { - userAvatarLink, - 'commit-component': CommitComponent, - 'actions-component': ActionsComponent, - 'external-url-component': ExternalUrlComponent, - 'stop-component': StopComponent, - 'rollback-component': RollbackComponent, - 'terminal-button-component': TerminalButtonComponent, - 'monitoring-button-component': MonitoringButtonComponent, + UserAvatarLink, + CommitComponent, + ActionsComponent, + ExternalUrlComponent, + StopComponent, + RollbackComponent, + TerminalButtonComponent, + MonitoringButtonComponent, + }, + + directives: { + tooltip, }, props: { @@ -443,7 +448,11 @@ v-if="!model.isFolder" class="environment-name flex-truncate-parent table-mobile-content" :href="environmentPath"> - <span class="flex-truncate-child">{{ model.name }}</span> + <span + class="flex-truncate-child" + v-tooltip + :title="model.name" + >{{ model.name }}</span> </a> <span v-else diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js index 6d5dd747224..293154917fa 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js +++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js @@ -3,7 +3,6 @@ import './dropdown_hint'; import './dropdown_non_user'; import './dropdown_user'; import './dropdown_utils'; -import './filtered_search_token_keys'; import './filtered_search_dropdown_manager'; import './filtered_search_dropdown'; import './filtered_search_manager'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index ff046aa286a..b2add862051 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -3,11 +3,11 @@ import DropLab from '~/droplab/drop_lab'; import FilteredSearchContainer from './container'; class FilteredSearchDropdownManager { - constructor(baseEndpoint = '', tokenizer, page) { + constructor(baseEndpoint = '', tokenizer, page, isGroup, filteredSearchTokenKeys) { this.container = FilteredSearchContainer.container; this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); this.tokenizer = tokenizer; - this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + this.filteredSearchTokenKeys = filteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.page = page; @@ -29,7 +29,15 @@ class FilteredSearchDropdownManager { } setupMapping() { - this.mapping = { + const supportedTokens = this.filteredSearchTokenKeys.getKeys(); + const allowedMappings = { + hint: { + reference: null, + gl: 'DropdownHint', + element: this.container.querySelector('#js-dropdown-hint'), + }, + }; + const availableMappings = { author: { reference: null, gl: 'DropdownUser', @@ -64,12 +72,15 @@ class FilteredSearchDropdownManager { gl: 'DropdownEmoji', element: this.container.querySelector('#js-dropdown-my-reaction'), }, - hint: { - reference: null, - gl: 'DropdownHint', - element: this.container.querySelector('#js-dropdown-hint'), - }, }; + + supportedTokens.forEach((type) => { + if (availableMappings[type]) { + allowedMappings[type] = availableMappings[type]; + } + }); + + this.mapping = allowedMappings; } static addWordToInput(tokenName, tokenValue = '', clicked = false) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 58ed0012f01..532a5fe1090 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -3,20 +3,33 @@ import { visitUrl } from '../lib/utils/url_utility'; import Flash from '../flash'; import FilteredSearchContainer from './container'; import RecentSearchesRoot from './recent_searches_root'; +import FilteredSearchTokenKeys from './filtered_search_token_keys'; import RecentSearchesStore from './stores/recent_searches_store'; import RecentSearchesService from './services/recent_searches_service'; import eventHub from './event_hub'; import { addClassIfElementExists } from '../lib/utils/dom_utils'; class FilteredSearchManager { - constructor(page) { + constructor({ + page, + filteredSearchTokenKeys = FilteredSearchTokenKeys, + stateFiltersSelector = '.issues-state-filters', + }) { + this.isGroup = false; + this.states = ['opened', 'closed', 'merged', 'all']; + this.page = page; this.container = FilteredSearchContainer.container; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInputForm = this.filteredSearchInput.form; this.clearSearchButton = this.container.querySelector('.clear-search'); this.tokensContainer = this.container.querySelector('.tokens-container'); - this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + this.filteredSearchTokenKeys = filteredSearchTokenKeys; + this.stateFiltersSelector = stateFiltersSelector; + this.recentsStorageKeyNames = { + issues: 'issue-recent-searches', + merge_requests: 'merge-request-recent-searches', + }; this.recentSearchesStore = new RecentSearchesStore({ isLocalStorageAvailable: RecentSearchesService.isAvailable(), @@ -25,11 +38,7 @@ class FilteredSearchManager { this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); const fullPath = this.searchHistoryDropdownElement ? this.searchHistoryDropdownElement.dataset.fullPath : 'project'; - let recentSearchesPagePrefix = 'issue-recent-searches'; - if (this.page === 'merge_requests') { - recentSearchesPagePrefix = 'merge-request-recent-searches'; - } - const recentSearchesKey = `${fullPath}-${recentSearchesPagePrefix}`; + const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`; this.recentSearchesService = new RecentSearchesService(recentSearchesKey); } @@ -58,7 +67,13 @@ class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; - this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page); + this.dropdownManager = new gl.FilteredSearchDropdownManager( + this.filteredSearchInput.getAttribute('data-base-endpoint') || '', + this.tokenizer, + this.page, + this.isGroup, + this.filteredSearchTokenKeys, + ); this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesStore, @@ -86,40 +101,33 @@ class FilteredSearchManager { } bindStateEvents() { - this.stateFilters = document.querySelector('.container-fluid .issues-state-filters'); + this.stateFilters = document.querySelector(`.container-fluid ${this.stateFiltersSelector}`); if (this.stateFilters) { this.searchStateWrapper = this.searchState.bind(this); - this.stateFilters.querySelector('[data-state="opened"]') - .addEventListener('click', this.searchStateWrapper); - this.stateFilters.querySelector('[data-state="closed"]') - .addEventListener('click', this.searchStateWrapper); - this.stateFilters.querySelector('[data-state="all"]') - .addEventListener('click', this.searchStateWrapper); - - this.mergedState = this.stateFilters.querySelector('[data-state="merged"]'); - if (this.mergedState) { - this.mergedState.addEventListener('click', this.searchStateWrapper); - } + this.applyToStateFilters((filterEl) => { + filterEl.addEventListener('click', this.searchStateWrapper); + }); } } unbindStateEvents() { if (this.stateFilters) { - this.stateFilters.querySelector('[data-state="opened"]') - .removeEventListener('click', this.searchStateWrapper); - this.stateFilters.querySelector('[data-state="closed"]') - .removeEventListener('click', this.searchStateWrapper); - this.stateFilters.querySelector('[data-state="all"]') - .removeEventListener('click', this.searchStateWrapper); - - if (this.mergedState) { - this.mergedState.removeEventListener('click', this.searchStateWrapper); - } + this.applyToStateFilters((filterEl) => { + filterEl.removeEventListener('click', this.searchStateWrapper); + }); } } + applyToStateFilters(callback) { + this.stateFilters.querySelectorAll('a[data-state]').forEach((filterEl) => { + if (this.states.indexOf(filterEl.dataset.state) > -1) { + callback(filterEl); + } + }); + } + bindEvents() { this.handleFormSubmit = this.handleFormSubmit.bind(this); this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager); diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index be595d7df1a..087ef5cd6f2 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -71,7 +71,7 @@ const conditions = [{ value: 'none', }]; -class FilteredSearchTokenKeys { +export default class FilteredSearchTokenKeys { static get() { return tokenKeys; } @@ -121,6 +121,3 @@ class FilteredSearchTokenKeys { .find(condition => condition.tokenKey === key && condition.value === value) || null; } } - -window.gl = window.gl || {}; -gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index d0f9e6af0f8..d200044b79f 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -1,5 +1,4 @@ -/* global autosize */ - +import autosize from 'autosize'; import GfmAutoComplete from './gfm_auto_complete'; import dropzoneInput from './dropzone_input'; import textUtils from './lib/utils/text_markdown'; diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index 7ac9dcd1112..b33165f9402 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -1,3 +1,8 @@ +import { parseQueryStringIntoObject } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; +import flash from '~/flash'; +import { __ } from '~/locale'; + export default class GpgBadges { static fetch() { const badges = $('.js-loading-gpg-badge'); @@ -5,13 +10,13 @@ export default class GpgBadges { badges.html('<i class="fa fa-spinner fa-spin"></i>'); - $.get({ - url: form.data('signatures-path'), - data: form.serialize(), - }).done((response) => { - response.signatures.forEach((signature) => { + const params = parseQueryStringIntoObject(form.serialize()); + return axios.get(form.data('signatures-path'), { params }) + .then(({ data }) => { + data.signatures.forEach((signature) => { badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html); }); - }); + }) + .catch(() => flash(__('An error occurred while loading commits'))); } } diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index e035ba462db..b8f0566f48c 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -152,14 +152,14 @@ export default { showLeaveGroupModal(group, parentGroup) { this.targetGroup = group; this.targetParentGroup = parentGroup; - this.showModal = true; + this.updateModal = true; this.groupLeaveConfirmationMessage = s__(`GroupsTree|Are you sure you want to leave the "${group.fullName}" group?`); }, hideLeaveGroupModal() { - this.showModal = false; + this.updateModal = false; }, leaveGroup() { - this.showModal = false; + this.updateModal = false; this.targetGroup.isBeingRemoved = true; this.service.leaveGroup(this.targetGroup.leavePath) .then(res => res.json()) diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index a69a0bde17b..65a2395fe29 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -1,5 +1,6 @@ +import axios from './lib/utils/axios_utils'; import Api from './api'; -import { normalizeCRLFHeaders } from './lib/utils/common_utils'; +import { normalizeHeaders } from './lib/utils/common_utils'; export default function groupsSelect() { // Needs to be accessible in rspec @@ -17,24 +18,23 @@ export default function groupsSelect() { dataType: 'json', quietMillis: 250, transport(params) { - return $.ajax(params) - .then((data, status, xhr) => { - const results = data || []; - - const headers = normalizeCRLFHeaders(xhr.getAllResponseHeaders()); + axios[params.type.toLowerCase()](params.url, { + params: params.data, + }) + .then((res) => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); const currentPage = parseInt(headers['X-PAGE'], 10) || 0; const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; const more = currentPage < totalPages; - return { + params.success({ results, pagination: { more, }, - }; - }) - .then(params.success) - .fail(params.error); + }); + }).catch(params.error); }, data(search, page) { return { diff --git a/app/assets/javascripts/ide/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js index af83a1ec0b4..142a220097b 100644 --- a/app/assets/javascripts/ide/monaco_loader.js +++ b/app/assets/javascripts/ide/monaco_loader.js @@ -6,6 +6,11 @@ monacoContext.require.config({ }, }); +// ignore CDN config and use local assets path for service worker which cannot be cross-domain +const relativeRootPath = (gon && gon.relative_url_root) || ''; +const monacoPath = `${relativeRootPath}/assets/webpack/monaco-editor/vs`; +window.MonacoEnvironment = { getWorkerUrl: () => `${monacoPath}/base/worker/workerMain.js` }; + // eslint-disable-next-line no-underscore-dangle window.__monaco_context__ = monacoContext; export default monacoContext.require; diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 1dc70872d92..35094f8e73b 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -1,3 +1,7 @@ +import { __ } from './locale'; +import axios from './lib/utils/axios_utils'; +import flash from './flash'; + class ImporterStatus { constructor(jobsUrl, importUrl) { this.jobsUrl = jobsUrl; @@ -9,29 +13,7 @@ class ImporterStatus { initStatusPage() { $('.js-add-to-import') .off('click') - .on('click', (event) => { - const $btn = $(event.currentTarget); - const $tr = $btn.closest('tr'); - const $targetField = $tr.find('.import-target'); - const $namespaceInput = $targetField.find('.js-select-namespace option:selected'); - const id = $tr.attr('id').replace('repo_', ''); - let targetNamespace; - let newName; - if ($namespaceInput.length > 0) { - targetNamespace = $namespaceInput[0].innerHTML; - newName = $targetField.find('#path').prop('value'); - $targetField.empty().append(`${targetNamespace}/${newName}`); - } - $btn.disable().addClass('is-loading'); - - return $.post(this.importUrl, { - repo_id: id, - target_namespace: targetNamespace, - new_name: newName, - }, { - dataType: 'script', - }); - }); + .on('click', this.addToImport.bind(this)); $('.js-import-all') .off('click') @@ -44,34 +26,74 @@ class ImporterStatus { }); } - setAutoUpdate() { - return setInterval(() => $.get(this.jobsUrl, data => $.each(data, (i, job) => { - const jobItem = $(`#project_${job.id}`); - const statusField = jobItem.find('.job-status'); + addToImport(event) { + const $btn = $(event.currentTarget); + const $tr = $btn.closest('tr'); + const $targetField = $tr.find('.import-target'); + const $namespaceInput = $targetField.find('.js-select-namespace option:selected'); + const id = $tr.attr('id').replace('repo_', ''); + let targetNamespace; + let newName; + if ($namespaceInput.length > 0) { + targetNamespace = $namespaceInput[0].innerHTML; + newName = $targetField.find('#path').prop('value'); + $targetField.empty().append(`${targetNamespace}/${newName}`); + } + $btn.disable().addClass('is-loading'); + + return axios.post(this.importUrl, { + repo_id: id, + target_namespace: targetNamespace, + new_name: newName, + }) + .then(({ data }) => { + const job = $(`tr#repo_${id}`); + job.attr('id', `project_${data.id}`); - const spinner = '<i class="fa fa-spinner fa-spin"></i>'; + job.find('.import-target').html(`<a href="${data.full_path}">${data.full_path}</a>`); + $('table.import-jobs tbody').prepend(job); - switch (job.import_status) { - case 'finished': - jobItem.removeClass('active').addClass('success'); - statusField.html('<span><i class="fa fa-check"></i> done</span>'); - break; - case 'scheduled': - statusField.html(`${spinner} scheduled`); - break; - case 'started': - statusField.html(`${spinner} started`); - break; - default: - statusField.html(job.import_status); - break; - } - })), 4000); + job.addClass('active'); + job.find('.import-actions').html('<i class="fa fa-spinner fa-spin" aria-label="importing"></i> started'); + }) + .catch(() => flash(__('An error occurred while importing project'))); + } + + autoUpdate() { + return axios.get(this.jobsUrl) + .then(({ data = [] }) => { + data.forEach((job) => { + const jobItem = $(`#project_${job.id}`); + const statusField = jobItem.find('.job-status'); + + const spinner = '<i class="fa fa-spinner fa-spin"></i>'; + + switch (job.import_status) { + case 'finished': + jobItem.removeClass('active').addClass('success'); + statusField.html('<span><i class="fa fa-check"></i> done</span>'); + break; + case 'scheduled': + statusField.html(`${spinner} scheduled`); + break; + case 'started': + statusField.html(`${spinner} started`); + break; + default: + statusField.html(job.import_status); + break; + } + }); + }); + } + + setAutoUpdate() { + setInterval(this.autoUpdate.bind(this), 4000); } } // eslint-disable-next-line consistent-return -export default function initImporterStatus() { +function initImporterStatus() { const importerStatus = document.querySelector('.js-importer-status'); if (importerStatus) { @@ -79,3 +101,8 @@ export default function initImporterStatus() { return new ImporterStatus(data.jobsImportPath, data.importPath); } } + +export { + initImporterStatus as default, + ImporterStatus, +}; diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js deleted file mode 100644 index 10fe6bac0e8..00000000000 --- a/app/assets/javascripts/integrations/index.js +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-disable no-new */ -import IntegrationSettingsForm from './integration_settings_form'; - -$(() => { - const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); -}); diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index ff65ea99e9a..333bbd9e0ba 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,5 +1,4 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */ -import 'vendor/jquery.waitforimages'; import axios from './lib/utils/axios_utils'; import { addDelimiter } from './lib/utils/text_utility'; import flash from './flash'; @@ -25,6 +24,51 @@ export default class Issue { if (Issue.createMrDropdownWrap) { this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap); } + + // Listen to state changes in the Vue app + document.addEventListener('issuable_vue_app:change', (event) => { + this.updateTopState(event.detail.isClosed, event.detail.data); + }); + } + + /** + * This method updates the top area of the issue. + * + * Once the issue state changes, either through a click on the top area (jquery) + * or a click on the bottom area (Vue) we need to update the top area. + * + * @param {Boolean} isClosed + * @param {Array} data + * @param {String} issueFailMessage + */ + updateTopState(isClosed, data, issueFailMessage = 'Unable to update this issue at this time.') { + if ('id' in data) { + const isClosedBadge = $('div.status-box-issue-closed'); + const isOpenBadge = $('div.status-box-open'); + const projectIssuesCounter = $('.issue_counter'); + + isClosedBadge.toggleClass('hidden', !isClosed); + isOpenBadge.toggleClass('hidden', isClosed); + + $(document).trigger('issuable:change', isClosed); + this.toggleCloseReopenButton(isClosed); + + let numProjectIssues = Number(projectIssuesCounter.first().text().trim().replace(/[^\d]/, '')); + numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; + projectIssuesCounter.text(addDelimiter(numProjectIssues)); + + if (this.createMergeRequestDropdown) { + if (isClosed) { + this.createMergeRequestDropdown.unavailable(); + this.createMergeRequestDropdown.disable(); + } else { + // We should check in case a branch was created in another tab + this.createMergeRequestDropdown.checkAbilityToCreateBranch(); + } + } + } else { + flash(issueFailMessage); + } } initIssueBtnEventListeners() { @@ -45,34 +89,8 @@ export default class Issue { url = $button.attr('href'); return axios.put(url) .then(({ data }) => { - const isClosedBadge = $('div.status-box-issue-closed'); - const isOpenBadge = $('div.status-box-open'); - const projectIssuesCounter = $('.issue_counter'); - - if ('id' in data) { - const isClosed = $button.hasClass('btn-close'); - isClosedBadge.toggleClass('hidden', !isClosed); - isOpenBadge.toggleClass('hidden', isClosed); - - $(document).trigger('issuable:change', isClosed); - this.toggleCloseReopenButton(isClosed); - - let numProjectIssues = Number(projectIssuesCounter.first().text().trim().replace(/[^\d]/, '')); - numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; - projectIssuesCounter.text(addDelimiter(numProjectIssues)); - - if (this.createMergeRequestDropdown) { - if (isClosed) { - this.createMergeRequestDropdown.unavailable(); - this.createMergeRequestDropdown.disable(); - } else { - // We should check in case a branch was created in another tab - this.createMergeRequestDropdown.checkAbilityToCreateBranch(); - } - } - } else { - flash(issueFailMessage); - } + const isClosed = $button.hasClass('btn-close'); + this.updateTopState(isClosed, data); }) .catch(() => flash(issueFailMessage)) .then(() => { diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js index d0b7ea75082..f39ae764d3c 100644 --- a/app/assets/javascripts/job.js +++ b/app/assets/javascripts/job.js @@ -217,7 +217,7 @@ export default class Job { } this.isLogComplete = log.complete; - if (!log.complete) { + if (log.complete === false) { this.timeout = setTimeout(() => { this.getBuildTrace(); }, 4000); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 5811d059e0b..7d2cf4b634f 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -1,5 +1,6 @@ import axios from './axios_utils'; import { getLocationHash } from './url_utility'; +import { convertToCamelCase } from './text_utility'; export const getPagePath = (index = 0) => $('body').attr('data-page').split(':')[index]; @@ -395,6 +396,26 @@ export const spriteIcon = (icon, className = '') => { return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`; }; +/** + * This method takes in object with snake_case property names + * and returns new object with camelCase property names + * + * Reasoning for this method is to ensure consistent property + * naming conventions across JS code. + */ +export const convertObjectPropsToCamelCase = (obj = {}) => { + if (obj === null) { + return {}; + } + + return Object.keys(obj).reduce((acc, prop) => { + const result = acc; + + result[convertToCamelCase(prop)] = obj[prop]; + return acc; + }, {}); +}; + export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; window.gl = window.gl || {}; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 1fa6715180e..d6cccbef42b 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -10,6 +10,20 @@ window.timeago = timeago; window.dateFormat = dateFormat; /** + * Returns i18n month names array. + * If `abbreviated` is provided, returns abbreviated + * name. + * + * @param {Boolean} abbreviated + */ +const getMonthNames = (abbreviated) => { + if (abbreviated) { + return [s__('Jan'), s__('Feb'), s__('Mar'), s__('Apr'), s__('May'), s__('Jun'), s__('Jul'), s__('Aug'), s__('Sep'), s__('Oct'), s__('Nov'), s__('Dec')]; + } + return [s__('January'), s__('February'), s__('March'), s__('April'), s__('May'), s__('June'), s__('July'), s__('August'), s__('September'), s__('October'), s__('November'), s__('December')]; +}; + +/** * Given a date object returns the day of the week in English * @param {date} date * @returns {String} @@ -143,7 +157,6 @@ export const getDayDifference = (a, b) => { * @param {Number} seconds * @return {String} */ -// eslint-disable-next-line import/prefer-default-export export function timeIntervalInWords(intervalInSeconds) { const secondsInteger = parseInt(intervalInSeconds, 10); const minutes = Math.floor(secondsInteger / 60); @@ -158,7 +171,7 @@ export function timeIntervalInWords(intervalInSeconds) { return text; } -export function dateInWords(date, abbreviated = false) { +export function dateInWords(date, abbreviated = false, hideYear = false) { if (!date) return date; const month = date.getMonth(); @@ -169,9 +182,115 @@ export function dateInWords(date, abbreviated = false) { const monthName = abbreviated ? monthNamesAbbr[month] : monthNames[month]; + if (hideYear) { + return `${monthName} ${date.getDate()}`; + } + return `${monthName} ${date.getDate()}, ${year}`; } +/** + * Returns month name based on provided date. + * + * @param {Date} date + * @param {Boolean} abbreviated + */ +export const monthInWords = (date, abbreviated = false) => { + if (!date) { + return ''; + } + + return getMonthNames(abbreviated)[date.getMonth()]; +}; + +/** + * Returns number of days in a month for provided date. + * courtesy: https://stacko(verflow.com/a/1185804/414749 + * + * @param {Date} date + */ +export const totalDaysInMonth = (date) => { + if (!date) { + return 0; + } + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); +}; + +/** + * Returns list of Dates referring to Sundays of the month + * based on provided date + * + * @param {Date} date + */ +export const getSundays = (date) => { + if (!date) { + return []; + } + + const daysToSunday = ['Saturday', 'Friday', 'Thursday', 'Wednesday', 'Tuesday', 'Monday', 'Sunday']; + + const month = date.getMonth(); + const year = date.getFullYear(); + const sundays = []; + const dateOfMonth = new Date(year, month, 1); + + while (dateOfMonth.getMonth() === month) { + const dayName = getDayName(dateOfMonth); + if (dayName === 'Sunday') { + sundays.push(new Date(dateOfMonth.getTime())); + } + + const daysUntilNextSunday = daysToSunday.indexOf(dayName) + 1; + dateOfMonth.setDate(dateOfMonth.getDate() + daysUntilNextSunday); + } + + return sundays; +}; + +/** + * Returns list of Dates representing a timeframe of Months from month of provided date (inclusive) + * up to provided length + * + * For eg; + * If current month is January 2018 and `length` provided is `6` + * Then this method will return list of Date objects as follows; + * + * [ October 2017, November 2017, December 2017, January 2018, February 2018, March 2018 ] + * + * If current month is March 2018 and `length` provided is `3` + * Then this method will return list of Date objects as follows; + * + * [ February 2018, March 2018, April 2018 ] + * + * @param {Number} length + * @param {Date} date + */ +export const getTimeframeWindow = (length, date) => { + if (!length) { + return []; + } + + const currentDate = date instanceof Date ? date : new Date(); + const currentMonthIndex = Math.floor(length / 2); + const timeframe = []; + + // Move date object backward to the first month of timeframe + currentDate.setDate(1); + currentDate.setMonth(currentDate.getMonth() - currentMonthIndex); + + // Iterate and update date for the size of length + // and push date reference to timeframe list + for (let i = 0; i < length; i += 1) { + timeframe.push(new Date(currentDate.getTime())); + currentDate.setMonth(currentDate.getMonth() + 1); + } + + // Change date of last timeframe item to last date of the month + timeframe[length - 1].setDate(totalDaysInMonth(timeframe[length - 1])); + + return timeframe; +}; + window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 625e53ee9de..bb151929431 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -6,4 +6,6 @@ export default { ABORTED: 0, NO_CONTENT: 204, OK: 200, + MULTIPLE_CHOICES: 300, + BAD_REQUEST: 400, }; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 62d80c4a649..94d03621bff 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -73,3 +73,10 @@ export function capitalizeFirstCharacter(text) { * @returns {String} */ export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, replace); + +/** + * Converts snake_case string to camelCase + * + * @param {*} string + */ +export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js index 93f8f6ee926..2cb238529aa 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js @@ -2,7 +2,9 @@ /* global ace */ import Vue from 'vue'; -import Flash from '../../flash'; +import axios from '~/lib/utils/axios_utils'; +import flash from '~/flash'; +import { __ } from '~/locale'; ((global) => { global.mergeConflicts = global.mergeConflicts || {}; @@ -49,27 +51,26 @@ import Flash from '../../flash'; loadEditor() { this.loading = true; - $.get(this.file.content_path) - .done((file) => { + axios.get(this.file.content_path) + .then(({ data }) => { const content = this.$el.querySelector('pre'); - const fileContent = document.createTextNode(file.content); + const fileContent = document.createTextNode(data.content); content.textContent = fileContent.textContent; - this.originalContent = file.content; + this.originalContent = data.content; this.fileLoaded = true; this.editor = ace.edit(content); this.editor.$blockScrolling = Infinity; // Turn off annoying warning - this.editor.getSession().setMode(`ace/mode/${file.blob_ace_mode}`); + this.editor.getSession().setMode(`ace/mode/${data.blob_ace_mode}`); this.editor.on('change', () => { this.saveDiffResolution(); }); this.saveDiffResolution(); + this.loading = false; }) - .fail(() => { - new Flash('Failed to load the file, please try again.'); - }) - .always(() => { + .catch(() => { + flash(__('An error occurred while loading the file')); this.loading = false; }); }, diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index bedd50de1bb..a64093afcf4 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,6 +1,4 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, max-len, prefer-arrow-callback */ - -import 'vendor/jquery.waitforimages'; import { __ } from '~/locale'; import TaskList from './task_list'; import MergeRequestTabs from './merge_request_tabs'; diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 5afae93724b..031badc7026 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -27,6 +27,7 @@ hasMetrics: convertPermissionToBoolean(metricsData.hasMetrics), documentationPath: metricsData.documentationPath, settingsPath: metricsData.settingsPath, + clustersPath: metricsData.clustersPath, tagsPath: metricsData.tagsPath, projectPath: metricsData.projectPath, metricsEndpoint: metricsData.additionalMetrics, @@ -132,6 +133,7 @@ :selected-state="state" :documentation-path="documentationPath" :settings-path="settingsPath" + :clusters-path="clustersPath" :empty-getting-started-svg-path="emptyGettingStartedSvgPath" :empty-loading-svg-path="emptyLoadingSvgPath" :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index 56cd60c583b..9517b8ccb67 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -10,6 +10,11 @@ required: false, default: '', }, + clustersPath: { + type: String, + required: false, + default: '', + }, selectedState: { type: String, required: true, @@ -35,7 +40,10 @@ title: 'Get started with performance monitoring', description: `Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.`, - buttonText: 'Configure Prometheus', + buttonText: 'Install Prometheus on clusters', + buttonPath: this.clustersPath, + secondaryButtonText: 'Configure existing Prometheus', + secondaryButtonPath: this.settingsPath, }, loading: { svgUrl: this.emptyLoadingSvgPath, @@ -43,6 +51,7 @@ description: `Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.`, buttonText: 'View documentation', + buttonPath: this.documentationPath, }, noData: { svgUrl: this.emptyUnableToConnectSvgPath, @@ -50,12 +59,14 @@ description: `You are connected to the Prometheus server, but there is currently no data to display.`, buttonText: 'Configure Prometheus', + buttonPath: this.settingsPath, }, unableToConnect: { svgUrl: this.emptyUnableToConnectSvgPath, title: 'Unable to connect to Prometheus server', description: 'Ensure connectivity is available from the GitLab server to the ', buttonText: 'View documentation', + buttonPath: this.documentationPath, }, }, }; @@ -65,13 +76,6 @@ return this.states[this.selectedState]; }, - buttonPath() { - if (this.selectedState === 'gettingStarted') { - return this.settingsPath; - } - return this.documentationPath; - }, - showButtonDescription() { if (this.selectedState === 'unableToConnect') return true; return false; @@ -99,11 +103,21 @@ </p> <div class="state-button"> <a + v-if="currentState.buttonPath" class="btn btn-success" - :href="buttonPath" + :href="currentState.buttonPath" > {{ currentState.buttonText }} </a> </div> + <div class="state-button"> + <a + v-if="currentState.secondaryButtonPath" + class="btn" + :href="currentState.secondaryButtonPath" + > + {{ currentState.secondaryButtonText }} + </a> + </div> </div> </template> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 3c8452ac808..df796050e0d 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -2,16 +2,18 @@ import { mapActions, mapGetters } from 'vuex'; import _ from 'underscore'; import Autosize from 'autosize'; + import { __ } from '~/locale'; import Flash from '../../flash'; import Autosave from '../../autosave'; import TaskList from '../../task_list'; import * as constants from '../constants'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; - import noteSignedOutWidget from './note_signed_out_widget.vue'; - import discussionLockedWidget from './discussion_locked_widget.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + import loadingButton from '../../vue_shared/components/loading_button.vue'; + import noteSignedOutWidget from './note_signed_out_widget.vue'; + import discussionLockedWidget from './discussion_locked_widget.vue'; import issuableStateMixin from '../mixins/issuable_state'; export default { @@ -22,6 +24,7 @@ discussionLockedWidget, markdownField, userAvatarLink, + loadingButton, }, mixins: [ issuableStateMixin, @@ -30,9 +33,6 @@ return { note: '', noteType: constants.COMMENT, - // Can't use mapGetters, - // this needs to be in the data object because it belongs to the state - issueState: this.$store.getters.getNoteableData.state, isSubmitting: false, isSubmitButtonDisabled: true, }; @@ -43,6 +43,7 @@ 'getUserData', 'getNoteableData', 'getNotesData', + 'issueState', ]), isLoggedIn() { return this.getUserData.id; @@ -105,7 +106,7 @@ mounted() { // jQuery is needed here because it is a custom event being dispatched with jQuery. $(document).on('issuable:change', (e, isClosed) => { - this.issueState = isClosed ? constants.CLOSED : constants.REOPENED; + this.toggleIssueLocalState(isClosed ? constants.CLOSED : constants.REOPENED); }); this.initAutoSave(); @@ -117,6 +118,9 @@ 'stopPolling', 'restartPolling', 'removePlaceholderNotes', + 'closeIssue', + 'reopenIssue', + 'toggleIssueLocalState', ]), setIsSubmitButtonDisabled(note, isSubmitting) { if (!_.isEmpty(note) && !isSubmitting) { @@ -126,6 +130,8 @@ } }, handleSave(withIssueAction) { + this.isSubmitting = true; + if (this.note.length) { const noteData = { endpoint: this.endpoint, @@ -142,7 +148,6 @@ if (this.noteType === constants.DISCUSSION) { noteData.data.note.type = constants.DISCUSSION_NOTE; } - this.isSubmitting = true; this.note = ''; // Empty textarea while being requested. Repopulate in catch this.resizeTextarea(); this.stopPolling(); @@ -184,13 +189,25 @@ Please check your network connection and try again.`; this.toggleIssueState(); } }, + enableButton() { + this.isSubmitting = false; + }, toggleIssueState() { - this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED; - - // This is out of scope for the Notes Vue component. - // It was the shortest path to update the issue state and relevant places. - const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close'; - $(`.js-btn-issue-action.${btnClass}:visible`).trigger('click'); + if (this.isIssueOpen) { + this.closeIssue() + .then(() => this.enableButton()) + .catch(() => { + this.enableButton(); + Flash(__('Something went wrong while closing the issue. Please try again later')); + }); + } else { + this.reopenIssue() + .then(() => this.enableButton()) + .catch(() => { + this.enableButton(); + Flash(__('Something went wrong while reopening the issue. Please try again later')); + }); + } }, discard(shouldClear = true) { // `blur` is needed to clear slash commands autocomplete cache if event fired. @@ -367,15 +384,19 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" </li> </ul> </div> - <button - type="button" - @click="handleSave(true)" + + <loading-button v-if="canUpdateIssue" - :class="actionButtonClassNames" + :loading="isSubmitting" + @click="handleSave(true)" + :container-class="[ + actionButtonClassNames, + 'btn btn-comment btn-comment-and-close js-action-button' + ]" :disabled="isSubmitting" - class="btn btn-comment btn-comment-and-close js-action-button"> - {{ issueActionButtonTitle }} - </button> + :label="issueActionButtonTitle" + /> + <button type="button" v-if="note.length" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 30e7ccc8229..045077de383 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -102,6 +102,7 @@ .then(() => { this.isEditing = false; this.isRequesting = false; + this.oldContent = null; $(this.$refs.noteBody.$el).renderGFM(); this.$refs.noteBody.resetAutoSave(); callback(); diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index d250dd8d25b..48e7cfddb63 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -28,6 +28,8 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ notesPath: notesDataset.notesPath, markdownDocsPath: notesDataset.markdownDocsPath, quickActionsDocsPath: notesDataset.quickActionsDocsPath, + closeIssuePath: notesDataset.closeIssuePath, + reopenIssuePath: notesDataset.reopenIssuePath, }, }; }, diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index b51b0cb2013..b8e7ffc8c46 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -32,4 +32,7 @@ export default { toggleAward(endpoint, data) { return Vue.http.post(endpoint, data, { emulateJSON: true }); }, + toggleIssueState(endpoint, data) { + return Vue.http.put(endpoint, data); + }, }; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 085b18642ba..4c846d69b86 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -61,6 +61,39 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES); +export const closeIssue = ({ commit, dispatch, state }) => service + .toggleIssueState(state.notesData.closeIssuePath) + .then(res => res.json()) + .then((data) => { + commit(types.CLOSE_ISSUE); + dispatch('emitStateChangedEvent', data); + }); + +export const reopenIssue = ({ commit, dispatch, state }) => service + .toggleIssueState(state.notesData.reopenIssuePath) + .then(res => res.json()) + .then((data) => { + commit(types.REOPEN_ISSUE); + dispatch('emitStateChangedEvent', data); + }); + +export const emitStateChangedEvent = ({ commit, getters }, data) => { + const event = new CustomEvent('issuable_vue_app:change', { detail: { + data, + isClosed: getters.issueState === constants.CLOSED, + } }); + + document.dispatchEvent(event); +}; + +export const toggleIssueLocalState = ({ commit }, newState) => { + if (newState === constants.CLOSED) { + commit(types.CLOSE_ISSUE); + } else if (newState === constants.REOPENED) { + commit(types.REOPEN_ISSUE); + } +}; + export const saveNote = ({ commit, dispatch }, noteData) => { const { note } = noteData.data.note; let placeholderText = note; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index e18b277119e..82024104d73 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -8,6 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop]; export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; +export const issueState = state => state.noteableData.state; export const getUserData = state => state.userData || {}; export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index d520c197407..6d7c3bbae0f 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -12,3 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; export const TOGGLE_AWARD = 'TOGGLE_AWARD'; export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; export const UPDATE_NOTE = 'UPDATE_NOTE'; + +// Issue +export const CLOSE_ISSUE = 'CLOSE_ISSUE'; +export const REOPEN_ISSUE = 'REOPEN_ISSUE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 20f81a430c2..b3f66578c9a 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -152,4 +152,12 @@ export default { noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); } }, + + [types.CLOSE_ISSUE](state) { + Object.assign(state.noteableData, { state: constants.CLOSED }); + }, + + [types.REOPEN_ISSUE](state) { + Object.assign(state.noteableData, { state: constants.REOPENED }); + }, }; diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index 555725cbe12..ba1d8e4d8db 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -1,13 +1,13 @@ <script> import axios from '~/lib/utils/axios_utils'; - import Flash from '~/flash'; - import modal from '~/vue_shared/components/modal.vue'; - import { s__ } from '~/locale'; + import createFlash from '~/flash'; + import GlModal from '~/vue_shared/components/gl_modal.vue'; import { redirectTo } from '~/lib/utils/url_utility'; + import { s__ } from '~/locale'; export default { components: { - modal, + GlModal, }, props: { url: { @@ -17,7 +17,7 @@ }, computed: { text() { - return s__('AdminArea|You’re about to stop all jobs. This will halt all current jobs that are running.'); + return s__('AdminArea|You’re about to stop all jobs.This will halt all current jobs that are running.'); }, }, methods: { @@ -28,7 +28,7 @@ redirectTo(response.request.responseURL); }) .catch((error) => { - Flash(s__('AdminArea|Stopping jobs failed')); + createFlash(s__('AdminArea|Stopping jobs failed')); throw error; }); }, @@ -37,11 +37,13 @@ </script> <template> - <modal + <gl-modal id="stop-jobs-modal" - :title="s__('AdminArea|Stop all jobs?')" - :text="text" - kind="danger" - :primary-button-label="s__('AdminArea|Stop jobs')" - @submit="onSubmit" /> + :header-title-text="s__('AdminArea|Stop all jobs?')" + footer-primary-button-variant="danger" + :footer-primary-button-text="s__('AdminArea|Stop jobs')" + @submit="onSubmit" + > + {{ text }} + </gl-modal> </template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 0e004bd9174..5a4f8c6e745 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -1,29 +1,28 @@ import Vue from 'vue'; - import Translate from '~/vue_shared/translate'; - import stopJobsModal from './components/stop_jobs_modal.vue'; Vue.use(Translate); -export default () => { +document.addEventListener('DOMContentLoaded', () => { const stopJobsButton = document.getElementById('stop-jobs-button'); - - // eslint-disable-next-line no-new - new Vue({ - el: '#stop-jobs-modal', - components: { - stopJobsModal, - }, - mounted() { - stopJobsButton.classList.remove('disabled'); - }, - render(createElement) { - return createElement('stop-jobs-modal', { - props: { - url: stopJobsButton.dataset.url, - }, - }); - }, - }); -}; + if (stopJobsButton) { + // eslint-disable-next-line no-new + new Vue({ + el: '#stop-jobs-modal', + components: { + stopJobsModal, + }, + mounted() { + stopJobsButton.classList.remove('disabled'); + }, + render(createElement) { + return createElement('stop-jobs-modal', { + props: { + url: stopJobsButton.dataset.url, + }, + }); + }, + }); + } +}); diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue new file mode 100644 index 00000000000..14315d5492e --- /dev/null +++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue @@ -0,0 +1,125 @@ +<script> + import _ from 'underscore'; + import modal from '~/vue_shared/components/modal.vue'; + import { s__, sprintf } from '~/locale'; + + export default { + components: { + modal, + }, + props: { + deleteProjectUrl: { + type: String, + required: false, + default: '', + }, + projectName: { + type: String, + required: false, + default: '', + }, + csrfToken: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + enteredProjectName: '', + }; + }, + computed: { + title() { + return sprintf(s__('AdminProjects|Delete Project %{projectName}?'), + { + projectName: `'${_.escape(this.projectName)}'`, + }, + false, + ); + }, + text() { + return sprintf(s__(`AdminProjects| + You’re about to permanently delete the project %{projectName}, its repository, + and all related resources including issues, merge requests, etc.. Once you confirm and press + %{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`), + { + projectName: `<strong>${_.escape(this.projectName)}</strong>`, + strong_start: '<strong>', + strong_end: '</strong>', + }, + false, + ); + }, + confirmationTextLabel() { + return sprintf(s__('AdminUsers|To confirm, type %{projectName}'), + { + projectName: `<code>${_.escape(this.projectName)}</code>`, + }, + false, + ); + }, + primaryButtonLabel() { + return s__('AdminProjects|Delete project'); + }, + canSubmit() { + return this.enteredProjectName === this.projectName; + }, + }, + methods: { + onCancel() { + this.enteredProjectName = ''; + }, + onSubmit() { + this.$refs.form.submit(); + this.enteredProjectName = ''; + }, + }, + }; +</script> + +<template> + <modal + id="delete-project-modal" + :title="title" + :text="text" + kind="danger" + :primary-button-label="primaryButtonLabel" + :submit-disabled="!canSubmit" + @submit="onSubmit" + @cancel="onCancel" + > + <template + slot="body" + slot-scope="props" + > + <p v-html="props.text"></p> + <p v-html="confirmationTextLabel"></p> + <form + ref="form" + :action="deleteProjectUrl" + method="post" + > + <input + ref="method" + type="hidden" + name="_method" + value="delete" + /> + <input + type="hidden" + name="authenticity_token" + :value="csrfToken" + /> + <input + name="projectName" + class="form-control" + type="text" + v-model="enteredProjectName" + aria-labelledby="input-label" + autocomplete="off" + /> + </form> + </template> + </modal> +</template> diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js new file mode 100644 index 00000000000..3c597a1093e --- /dev/null +++ b/app/assets/javascripts/pages/admin/projects/index/index.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; + +import Translate from '~/vue_shared/translate'; +import csrf from '~/lib/utils/csrf'; + +import deleteProjectModal from './components/delete_project_modal.vue'; + +document.addEventListener('DOMContentLoaded', () => { + Vue.use(Translate); + + const deleteProjectModalEl = document.getElementById('delete-project-modal'); + + const deleteModal = new Vue({ + el: deleteProjectModalEl, + data: { + deleteProjectUrl: '', + projectName: '', + }, + render(createElement) { + return createElement(deleteProjectModal, { + props: { + deleteProjectUrl: this.deleteProjectUrl, + projectName: this.projectName, + csrfToken: csrf.token, + }, + }); + }, + }); + + $(document).on('shown.bs.modal', (event) => { + if (event.relatedTarget.classList.contains('delete-project-button')) { + const buttonProps = event.relatedTarget.dataset; + deleteModal.deleteProjectUrl = buttonProps.deleteProjectUrl; + deleteModal.projectName = buttonProps.projectName; + } + }); +}); diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue new file mode 100644 index 00000000000..7b5e333011e --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -0,0 +1,174 @@ +<script> + import _ from 'underscore'; + import modal from '~/vue_shared/components/modal.vue'; + import { s__, sprintf } from '~/locale'; + + export default { + components: { + modal, + }, + props: { + deleteUserUrl: { + type: String, + required: false, + default: '', + }, + blockUserUrl: { + type: String, + required: false, + default: '', + }, + deleteContributions: { + type: Boolean, + required: false, + default: false, + }, + username: { + type: String, + required: false, + default: '', + }, + csrfToken: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + enteredUsername: '', + }; + }, + computed: { + title() { + const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?'); + const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?'); + + return sprintf( + this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle, { + username: `'${_.escape(this.username)}'`, + }, false); + }, + text() { + const keepContributionsText = s__(`AdminArea| + You are about to permanently delete the user %{username}. + This will delete all of the issues, merge requests, and groups linked to them. + To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. + Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`); + + const deleteContributionsText = s__(`AdminArea| + You are about to permanently delete the user %{username}. + Issues, merge requests, and groups linked to them will be transferred to a system-wide "Ghost-user". + To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. + Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`); + + return sprintf(this.deleteContributions ? deleteContributionsText : keepContributionsText, + { + username: `<strong>${_.escape(this.username)}</strong>`, + strong_start: '<strong>', + strong_end: '</strong>', + }, + false, + ); + }, + confirmationTextLabel() { + return sprintf(s__('AdminUsers|To confirm, type %{username}'), + { + username: `<code>${_.escape(this.username)}</code>`, + }, + false, + ); + }, + primaryButtonLabel() { + const keepContributionsLabel = s__('AdminUsers|Delete user'); + const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions'); + + return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel; + }, + secondaryButtonLabel() { + return s__('AdminUsers|Block user'); + }, + canSubmit() { + return this.enteredUsername === this.username; + }, + }, + methods: { + onCancel() { + this.enteredUsername = ''; + }, + onSecondaryAction() { + const form = this.$refs.form; + + form.action = this.blockUserUrl; + this.$refs.method.value = 'put'; + + form.submit(); + }, + onSubmit() { + this.$refs.form.submit(); + this.enteredUsername = ''; + }, + }, + }; +</script> + +<template> + <modal + id="delete-user-modal" + :title="title" + :text="text" + kind="danger" + :primary-button-label="primaryButtonLabel" + :secondary-button-label="secondaryButtonLabel" + :submit-disabled="!canSubmit" + @submit="onSubmit" + @cancel="onCancel" + > + <template + slot="body" + slot-scope="props" + > + <p v-html="props.text"></p> + <p v-html="confirmationTextLabel"></p> + <form + ref="form" + :action="deleteUserUrl" + method="post" + > + <input + ref="method" + type="hidden" + name="_method" + value="delete" + /> + <input + type="hidden" + name="authenticity_token" + :value="csrfToken" + /> + <input + type="text" + name="username" + class="form-control" + v-model="enteredUsername" + aria-labelledby="input-label" + autocomplete="off" + /> + </form> + </template> + <template + slot="secondary-button" + slot-scope="props" + > + <button + type="button" + class="btn js-secondary-button btn-warning" + :disabled="!canSubmit" + @click="onSecondaryAction" + data-dismiss="modal" + > + {{ secondaryButtonLabel }} + </button> + </template> + </modal> +</template> diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js new file mode 100644 index 00000000000..4f5d6b55031 --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; + +import Translate from '~/vue_shared/translate'; +import csrf from '~/lib/utils/csrf'; + +import deleteUserModal from './components/delete_user_modal.vue'; + +document.addEventListener('DOMContentLoaded', () => { + Vue.use(Translate); + + const deleteUserModalEl = document.getElementById('delete-user-modal'); + + const deleteModal = new Vue({ + el: deleteUserModalEl, + data: { + deleteUserUrl: '', + blockUserUrl: '', + deleteContributions: '', + username: '', + }, + render(createElement) { + return createElement(deleteUserModal, { + props: { + deleteUserUrl: this.deleteUserUrl, + blockUserUrl: this.blockUserUrl, + deleteContributions: this.deleteContributions, + username: this.username, + csrfToken: csrf.token, + }, + }); + }, + }); + + $(document).on('shown.bs.modal', (event) => { + if (event.relatedTarget.classList.contains('delete-user-button')) { + const buttonProps = event.relatedTarget.dataset; + deleteModal.deleteUserUrl = buttonProps.deleteUserUrl; + deleteModal.blockUserUrl = buttonProps.blockUserUrl; + deleteModal.deleteContributions = event.relatedTarget.hasAttribute('data-delete-contributions'); + deleteModal.username = buttonProps.username; + } + }); +}); diff --git a/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js b/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js index b9469e5b7cb..9ab73be80a0 100644 --- a/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js +++ b/app/assets/javascripts/pages/ci/lints/ci_lint_editor.js @@ -2,11 +2,18 @@ export default class CILintEditor { constructor() { this.editor = window.ace.edit('ci-editor'); this.textarea = document.querySelector('#content'); + this.clearYml = document.querySelector('.clear-yml'); this.editor.getSession().setMode('ace/mode/yaml'); this.editor.on('input', () => { const content = this.editor.getSession().getValue(); this.textarea.value = content; }); + + this.clearYml.addEventListener('click', this.clear.bind(this)); + } + + clear() { + this.editor.setValue(''); } } diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index b7353669e65..c4901dd1cb6 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -1,7 +1,7 @@ import projectSelect from '~/project_select'; import initLegacyFilters from '~/init_legacy_filters'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { projectSelect(); initLegacyFilters(); -}; +}); diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index b7353669e65..c4901dd1cb6 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -1,7 +1,7 @@ import projectSelect from '~/project_select'; import initLegacyFilters from '~/init_legacy_filters'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { projectSelect(); initLegacyFilters(); -}; +}); diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js index 2e7a08a369c..06195d73c0a 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js @@ -1,7 +1,7 @@ import Milestone from '~/milestone'; import Sidebar from '~/right_sidebar'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { new Milestone(); // eslint-disable-line no-new new Sidebar(); // eslint-disable-line no-new -}; +}); diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js index c88cbf1a6ba..0c585e162cb 100644 --- a/app/assets/javascripts/pages/dashboard/projects/index.js +++ b/app/assets/javascripts/pages/dashboard/projects/index.js @@ -1,3 +1,3 @@ import ProjectsList from '~/projects_list'; -export default () => new ProjectsList(); +document.addEventListener('DOMContentLoaded', () => new ProjectsList()); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/index.js b/app/assets/javascripts/pages/dashboard/todos/index/index.js index 77c23685943..9d2c2f2994f 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/index.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/index.js @@ -1,3 +1,3 @@ import Todos from './todos'; -export default () => new Todos(); +document.addEventListener('DOMContentLoaded', () => new Todos()); diff --git a/app/assets/javascripts/pages/explore/groups/index.js b/app/assets/javascripts/pages/explore/groups/index.js index e59c38b8bc4..3c7edbdd7c7 100644 --- a/app/assets/javascripts/pages/explore/groups/index.js +++ b/app/assets/javascripts/pages/explore/groups/index.js @@ -2,7 +2,7 @@ import GroupsList from '~/groups_list'; import Landing from '~/landing'; import initGroupsList from '../../../groups'; -export default function () { +document.addEventListener('DOMContentLoaded', () => { new GroupsList(); // eslint-disable-line no-new initGroupsList(); const landingElement = document.querySelector('.js-explore-groups-landing'); @@ -13,4 +13,4 @@ export default function () { 'explore_groups_landing_dismissed', ); exploreGroupsLanding.toggle(); -} +}); diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js index c88cbf1a6ba..0c585e162cb 100644 --- a/app/assets/javascripts/pages/explore/projects/index.js +++ b/app/assets/javascripts/pages/explore/projects/index.js @@ -1,3 +1,3 @@ import ProjectsList from '~/projects_list'; -export default () => new ProjectsList(); +document.addEventListener('DOMContentLoaded', () => new ProjectsList()); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 78db543a64d..d149b307e7f 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -2,7 +2,9 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; -export default () => { - initFilteredSearch(FILTERED_SEARCH.ISSUES); +document.addEventListener('DOMContentLoaded', () => { + initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + }); projectSelect(); -}; +}); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 9b3af4537e7..a5cc1f34b63 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -2,7 +2,9 @@ import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { FILTERED_SEARCH } from '~/pages/constants'; -export default () => { - initFilteredSearch(FILTERED_SEARCH.MERGE_REQUESTS); +document.addEventListener('DOMContentLoaded', () => { + initFilteredSearch({ + page: FILTERED_SEARCH.MERGE_REQUESTS, + }); projectSelect(); -}; +}); diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js index 5c99c90e24d..ddd10fe5062 100644 --- a/app/assets/javascripts/pages/groups/milestones/edit/index.js +++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js @@ -1,3 +1,3 @@ import initForm from '../../../../shared/milestones/form'; -export default () => initForm(false); +document.addEventListener('DOMContentLoaded', () => initForm(false)); diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js index 5c99c90e24d..ddd10fe5062 100644 --- a/app/assets/javascripts/pages/groups/milestones/new/index.js +++ b/app/assets/javascripts/pages/groups/milestones/new/index.js @@ -1,3 +1,3 @@ import initForm from '../../../../shared/milestones/form'; -export default () => initForm(false); +document.addEventListener('DOMContentLoaded', () => initForm(false)); diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js index c9a18353f2e..88f40b5278e 100644 --- a/app/assets/javascripts/pages/groups/milestones/show/index.js +++ b/app/assets/javascripts/pages/groups/milestones/show/index.js @@ -1,3 +1,3 @@ import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; -export default initMilestonesShow; +document.addEventListener('DOMContentLoaded', initMilestonesShow); diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index f26c7360fbe..ad79f7e09ac 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,11 +1,12 @@ -import SecretValues from '~/behaviors/secret_values'; +import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; export default () => { - const secretVariableTable = document.querySelector('.js-secret-variable-table'); - if (secretVariableTable) { - const secretVariableTableValues = new SecretValues({ - container: secretVariableTable, - }); - secretVariableTableValues.init(); - } + const variableListEl = document.querySelector('.js-ci-variable-list-section'); + // eslint-disable-next-line no-new + new AjaxVariableList({ + container: variableListEl, + saveButton: variableListEl.querySelector('.js-secret-variables-save-button'), + errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), + saveEndpoint: variableListEl.dataset.saveEndpoint, + }); }; diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index cee0f19bf2a..8fa266a37ce 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -1,7 +1,7 @@ import AjaxLoadingSpinner from '~/ajax_loading_spinner'; import DeleteModal from '~/branches/branches_delete_modal'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { AjaxLoadingSpinner.init(); new DeleteModal(); // eslint-disable-line no-new -}; +}); diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js index ae5e033e97e..d32d5c6cb29 100644 --- a/app/assets/javascripts/pages/projects/branches/new/index.js +++ b/app/assets/javascripts/pages/projects/branches/new/index.js @@ -1,3 +1,5 @@ import NewBranchForm from '~/new_branch_form'; -export default () => new NewBranchForm($('.js-create-branch-form'), JSON.parse(document.getElementById('availableRefs').innerHTML)); +document.addEventListener('DOMContentLoaded', () => ( + new NewBranchForm($('.js-create-branch-form'), JSON.parse(document.getElementById('availableRefs').innerHTML)) +)); diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js index 90b5882a24f..6110fda17de 100644 --- a/app/assets/javascripts/pages/projects/commits/show/index.js +++ b/app/assets/javascripts/pages/projects/commits/show/index.js @@ -3,7 +3,7 @@ import GpgBadges from '~/gpg_badges'; import ShortcutsNavigation from '~/shortcuts_navigation'; export default () => { - CommitsList.init(document.querySelector('.js-project-commits-show').dataset.commitsLimit); + new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new GpgBadges.fetch(); }; diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js index 6b8d4503568..2b4fd3c47c0 100644 --- a/app/assets/javascripts/pages/projects/compare/show/index.js +++ b/app/assets/javascripts/pages/projects/compare/show/index.js @@ -1,8 +1,8 @@ import Diff from '~/diff'; import initChangesDropdown from '~/init_changes_dropdown'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { new Diff(); // eslint-disable-line no-new const paddingTop = 16; initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop); -}; +}); diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index f4760cb2720..0b644780ad4 100644 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js @@ -1,3 +1,3 @@ import monitoringBundle from '~/monitoring/monitoring_bundle'; -export default monitoringBundle; +document.addEventListener('DOMContentLoaded', monitoringBundle); diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index 7f27f379d8c..ffc84dc106b 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,5 +1,3 @@ import initForm from '../form'; -export default () => { - initForm(); -}; +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index 39c043edc38..70fdb0ef40d 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -8,7 +8,9 @@ import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; document.addEventListener('DOMContentLoaded', () => { - initFilteredSearch(FILTERED_SEARCH.ISSUES); + initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + }); new IssuableIndex(ISSUABLE_INDEX.ISSUE); new ShortcutsNavigation(); diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index 7f27f379d8c..ffc84dc106b 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,5 +1,3 @@ import initForm from '../form'; -export default () => { - initForm(); -}; +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/diffs/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/index.js index 734d01ae6f2..febfecebbd2 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/diffs/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/index.js @@ -1,3 +1,3 @@ import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request'; -export default initMergeRequest; +document.addEventListener('DOMContentLoaded', initMergeRequest); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index ccd0b54c5ed..1d5aec4001d 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -1,7 +1,7 @@ import Compare from '~/compare'; import MergeRequest from '~/merge_request'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); if (mrNewCompareNode) { new Compare({ // eslint-disable-line no-new @@ -15,4 +15,4 @@ export default () => { action: mrNewSubmitNode.dataset.mrSubmitAction, }); } -}; +}); diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js index 734d01ae6f2..febfecebbd2 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js @@ -1,3 +1,3 @@ import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request'; -export default initMergeRequest; +document.addEventListener('DOMContentLoaded', initMergeRequest); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index adadbf28e49..a7aa616319f 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -6,7 +6,9 @@ import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; document.addEventListener('DOMContentLoaded', () => { - initFilteredSearch(FILTERED_SEARCH.MERGE_REQUESTS); + initFilteredSearch({ + page: FILTERED_SEARCH.MERGE_REQUESTS, + }); new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js new file mode 100644 index 00000000000..c3463c266e3 --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -0,0 +1,24 @@ +import MergeRequest from '~/merge_request'; +import ZenMode from '~/zen_mode'; +import initNotes from '~/init_notes'; +import initIssuableSidebar from '~/init_issuable_sidebar'; +import ShortcutsIssuable from '~/shortcuts_issuable'; +import Diff from '~/diff'; +import { handleLocationHash } from '~/lib/utils/common_utils'; + +export default () => { + new Diff(); // eslint-disable-line no-new + new ZenMode(); // eslint-disable-line no-new + + initIssuableSidebar(); // eslint-disable-line no-new + initNotes(); // eslint-disable-line no-new + + const mrShowNode = document.querySelector('.merge-request'); + + window.mergeRequest = new MergeRequest({ + action: mrShowNode.dataset.mrAction, + }); + + new ShortcutsIssuable(true); // eslint-disable-line no-new + handleLocationHash(); +}; diff --git a/app/assets/javascripts/pages/projects/milestones/edit/index.js b/app/assets/javascripts/pages/projects/milestones/edit/index.js index 10e3979a36e..9a4ebf9890d 100644 --- a/app/assets/javascripts/pages/projects/milestones/edit/index.js +++ b/app/assets/javascripts/pages/projects/milestones/edit/index.js @@ -1,3 +1,3 @@ import initForm from '../../../../shared/milestones/form'; -export default () => initForm(); +document.addEventListener('DOMContentLoaded', () => initForm()); diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js index 8fb4d83d8a3..38789365a67 100644 --- a/app/assets/javascripts/pages/projects/milestones/index/index.js +++ b/app/assets/javascripts/pages/projects/milestones/index/index.js @@ -1,3 +1,3 @@ import milestones from '~/pages/milestones/shared'; -export default milestones; +document.addEventListener('DOMContentLoaded', milestones); diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js index 10e3979a36e..9a4ebf9890d 100644 --- a/app/assets/javascripts/pages/projects/milestones/new/index.js +++ b/app/assets/javascripts/pages/projects/milestones/new/index.js @@ -1,3 +1,3 @@ import initForm from '../../../../shared/milestones/form'; -export default () => initForm(); +document.addEventListener('DOMContentLoaded', () => initForm()); diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js index 35b5c9c2ced..84a52421598 100644 --- a/app/assets/javascripts/pages/projects/milestones/show/index.js +++ b/app/assets/javascripts/pages/projects/milestones/show/index.js @@ -1,7 +1,7 @@ import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; import milestones from '~/pages/milestones/shared'; -export default () => { +document.addEventListener('DOMContentLoaded', () => { initMilestonesShow(); milestones(); -}; +}); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js new file mode 100644 index 00000000000..d65be6bc69e --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/create/index.js @@ -0,0 +1,3 @@ +import initForm from '../shared/init_form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js new file mode 100644 index 00000000000..d65be6bc69e --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/edit/index.js @@ -0,0 +1,3 @@ +import initForm from '../shared/init_form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js index a6c945e22b0..544360dcd51 100644 --- a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import PipelineSchedulesCallout from './components/pipeline_schedules_callout.vue'; +import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue'; document.addEventListener('DOMContentLoaded', () => new Vue({ el: '#pipeline-schedules-callout', diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js new file mode 100644 index 00000000000..d65be6bc69e --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/new/index.js @@ -0,0 +1,3 @@ +import initForm from '../shared/init_form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 2d18fa2044b..2d18fa2044b 100644 --- a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue index aa04a0ac47a..77508e62cef 100644 --- a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue @@ -1,7 +1,7 @@ <script> import Vue from 'vue'; import Cookies from 'js-cookie'; - import Translate from '../../vue_shared/translate'; + import Translate from '../../../../../vue_shared/translate'; import illustrationSvg from '../icons/intro_illustration.svg'; Vue.use(Translate); diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js index 0c3926d76b5..0c3926d76b5 100644 --- a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/target_branch_dropdown.js diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js index 95ed9c7dc21..95ed9c7dc21 100644 --- a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js diff --git a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg index 26d1ff97b3e..26d1ff97b3e 100644 --- a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js index 0b1a81bae13..cfd30d6053f 100644 --- a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js @@ -1,10 +1,10 @@ import Vue from 'vue'; -import Translate from '../vue_shared/translate'; -import GlFieldErrors from '../gl_field_errors'; +import Translate from '../../../../vue_shared/translate'; +import GlFieldErrors from '../../../../gl_field_errors'; import intervalPatternInput from './components/interval_pattern_input.vue'; import TimezoneDropdown from './components/timezone_dropdown'; import TargetBranchDropdown from './components/target_branch_dropdown'; -import setupNativeFormVariableList from '../ci_variable_list/native_form_variable_list'; +import setupNativeFormVariableList from '../../../../ci_variable_list/native_form_variable_list'; Vue.use(Translate); @@ -27,7 +27,7 @@ function initIntervalPatternInput() { }); } -document.addEventListener('DOMContentLoaded', () => { +export default () => { /* Most of the form is written in haml, but for fields with more complex behaviors, * you should mount individual Vue components here. If at some point components need * to share state, it may make sense to refactor the whole form to Vue */ @@ -46,4 +46,4 @@ document.addEventListener('DOMContentLoaded', () => { container: $('.js-ci-variable-list-section'), formField: 'schedule', }); -}); +}; diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js new file mode 100644 index 00000000000..d65be6bc69e --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/update/index.js @@ -0,0 +1,3 @@ +import initForm from '../shared/init_form'; + +document.addEventListener('DOMContentLoaded', initForm); diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js new file mode 100644 index 00000000000..bb92f4e1459 --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js @@ -0,0 +1,56 @@ +import Chart from 'chart.js'; + +const options = { + scaleOverlay: true, + responsive: true, + maintainAspectRatio: false, +}; + +const buildChart = (chartScope) => { + const data = { + labels: chartScope.labels, + datasets: [{ + fillColor: '#707070', + strokeColor: '#707070', + pointColor: '#707070', + pointStrokeColor: '#EEE', + data: chartScope.totalValues, + }, + { + fillColor: '#1aaa55', + strokeColor: '#1aaa55', + pointColor: '#1aaa55', + pointStrokeColor: '#fff', + data: chartScope.successValues, + }, + ], + }; + const ctx = $(`#${chartScope.scope}Chart`).get(0).getContext('2d'); + + new Chart(ctx).Line(data, options); +}; + +document.addEventListener('DOMContentLoaded', () => { + const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); + const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); + const data = { + labels: chartTimesData.labels, + datasets: [{ + fillColor: 'rgba(220,220,220,0.5)', + strokeColor: 'rgba(220,220,220,1)', + barStrokeWidth: 1, + barValueSpacing: 1, + barDatasetSpacing: 1, + data: chartTimesData.values, + }], + }; + + if (window.innerWidth < 768) { + // Scale fonts if window width lower than 768px (iPad portrait) + options.scaleFontSize = 8; + } + + new Chart($('#build_timesChart').get(0).getContext('2d')).Bar(data, options); + + chartsData.forEach(scope => buildChart(scope)); +}); diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js new file mode 100644 index 00000000000..5c73171e62e --- /dev/null +++ b/app/assets/javascripts/pages/projects/services/edit/index.js @@ -0,0 +1,13 @@ +import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; + +export default () => { + const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); + const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + integrationSettingsForm.init(); + + if (prometheusSettingsWrapper) { + const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); + prometheusMetrics.loadActiveMetrics(); + } +}; diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 18dc1dc03a5..a563d0f9961 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -1,9 +1,11 @@ import initSettingsPanels from '~/settings_panels'; import SecretValues from '~/behaviors/secret_values'; +import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; export default function () { // Initialize expandable settings panels initSettingsPanels(); + const runnerToken = document.querySelector('.js-secret-runner-token'); if (runnerToken) { const runnerTokenSecretValue = new SecretValues({ @@ -12,11 +14,12 @@ export default function () { runnerTokenSecretValue.init(); } - const secretVariableTable = document.querySelector('.js-secret-variable-table'); - if (secretVariableTable) { - const secretVariableTableValues = new SecretValues({ - container: secretVariableTable, - }); - secretVariableTableValues.init(); - } + const variableListEl = document.querySelector('.js-ci-variable-list-section'); + // eslint-disable-next-line no-new + new AjaxVariableList({ + container: variableListEl, + saveButton: variableListEl.querySelector('.js-secret-variables-save-button'), + errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), + saveEndpoint: variableListEl.dataset.saveEndpoint, + }); } diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index c4b3356e478..cba57058380 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -14,7 +14,7 @@ export default () => { $('#tree-slider').waitForImages(() => ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath)); - const commitPipelineStatusEl = document.getElementById('commit-pipeline-status'); + const commitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); const statusLink = document.querySelector('.commit-actions .ci-status-link'); if (statusLink != null) { statusLink.remove(); diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js index 44853636aea..250f9d992ab 100644 --- a/app/assets/javascripts/pages/search/init_filtered_search.js +++ b/app/assets/javascripts/pages/search/init_filtered_search.js @@ -1,7 +1,7 @@ -export default (page) => { +export default ({ page }) => { const filteredSearchEnabled = gl.FilteredSearchManager && document.querySelector('.filtered-search'); if (filteredSearchEnabled) { - const filteredSearchManager = new gl.FilteredSearchManager(page); + const filteredSearchManager = new gl.FilteredSearchManager({ page }); filteredSearchManager.setup(); } }; diff --git a/app/assets/javascripts/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 57306322aa4..57306322aa4 100644 --- a/app/assets/javascripts/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js diff --git a/app/assets/javascripts/users/index.js b/app/assets/javascripts/pages/users/index.js index 9fd8452a2b6..899dcd42e37 100644 --- a/app/assets/javascripts/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -1,3 +1,4 @@ +import UserCallout from '~/user_callout'; import Cookies from 'js-cookie'; import UserTabs from './user_tabs'; @@ -22,4 +23,5 @@ document.addEventListener('DOMContentLoaded', () => { const page = $('body').attr('data-page'); const action = page.split(':')[1]; initUserProfile(action); + new UserCallout(); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js deleted file mode 100644 index f18f98b4e9a..00000000000 --- a/app/assets/javascripts/pages/users/show/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import UserCallout from '~/user_callout'; - -export default () => new UserCallout(); diff --git a/app/assets/javascripts/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index e13b9839a20..c1217623467 100644 --- a/app/assets/javascripts/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -1,9 +1,9 @@ -import axios from '../lib/utils/axios_utils'; -import Activities from '../activities'; +import axios from '~/lib/utils/axios_utils'; +import Activities from '~/activities'; +import { localTimeAgo } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import flash from '~/flash'; import ActivityCalendar from './activity_calendar'; -import { localTimeAgo } from '../lib/utils/datetime_utility'; -import { __ } from '../locale'; -import flash from '../flash'; /** * UserTabs diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue index 77553ca67cc..a5f22c4ec80 100644 --- a/app/assets/javascripts/pipelines/components/async_button.vue +++ b/app/assets/javascripts/pipelines/components/async_button.vue @@ -31,10 +31,9 @@ type: String, required: true, }, - confirmActionMessage: { - type: String, - required: false, - default: '', + id: { + type: Number, + required: true, }, }, data() { @@ -49,11 +48,10 @@ }, methods: { onClick() { - if (this.confirmActionMessage !== '' && confirm(this.confirmActionMessage)) { - this.makeRequest(); - } else if (this.confirmActionMessage === '') { - this.makeRequest(); - } + eventHub.$emit('actionConfirmationModal', { + id: this.id, + callback: this.makeRequest, + }); }, makeRequest() { this.isLoading = true; diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue index 6681b89e629..62fe479fdf4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue @@ -1,5 +1,7 @@ <script> import pipelinesTableRowComponent from './pipelines_table_row.vue'; + import stopConfirmationModal from './stop_confirmation_modal.vue'; + import retryConfirmationModal from './retry_confirmation_modal.vue'; /** * Pipelines Table Component. @@ -9,6 +11,8 @@ export default { components: { pipelinesTableRowComponent, + stopConfirmationModal, + retryConfirmationModal, }, props: { pipelines: { @@ -70,5 +74,7 @@ :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" /> + <stop-confirmation-modal /> + <retry-confirmation-modal /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index d0e4cf7ff40..0e3a10ed7f4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -305,6 +305,9 @@ css-class="js-pipelines-retry-button btn-default btn-retry" title="Retry" icon="repeat" + :id="pipeline.id" + data-toggle="modal" + data-target="#retry-confirmation-modal" /> <async-button-component @@ -313,7 +316,9 @@ css-class="js-pipelines-cancel-button btn-remove" title="Cancel" icon="close" - confirm-action-message="Are you sure you want to cancel this pipeline?" + :id="pipeline.id" + data-toggle="modal" + data-target="#stop-confirmation-modal" /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/retry_confirmation_modal.vue b/app/assets/javascripts/pipelines/components/retry_confirmation_modal.vue new file mode 100644 index 00000000000..e2ac08d67bc --- /dev/null +++ b/app/assets/javascripts/pipelines/components/retry_confirmation_modal.vue @@ -0,0 +1,65 @@ +<script> + import modal from '~/vue_shared/components/modal.vue'; + import { s__, sprintf } from '~/locale'; + import eventHub from '../event_hub'; + + export default { + components: { + modal, + }, + data() { + return { + id: '', + callback: () => {}, + }; + }, + computed: { + title() { + return sprintf(s__('Pipeline|Retry pipeline #%{id}?'), { + id: `'${this.id}'`, + }, false); + }, + text() { + return sprintf(s__('Pipeline|You’re about to retry pipeline %{id}.'), { + id: `<strong>#${this.id}</strong>`, + }, false); + }, + primaryButtonLabel() { + return s__('Pipeline|Retry pipeline'); + }, + }, + created() { + eventHub.$on('actionConfirmationModal', this.updateModal); + }, + beforeDestroy() { + eventHub.$off('actionConfirmationModal', this.updateModal); + }, + methods: { + updateModal(action) { + this.id = action.id; + this.callback = action.callback; + }, + onSubmit() { + this.callback(); + }, + }, + }; +</script> + +<template> + <modal + id="retry-confirmation-modal" + :title="title" + :text="text" + kind="danger" + :primary-button-label="primaryButtonLabel" + @submit="onSubmit" + > + <template + slot="body" + slot-scope="props" + > + <p v-html="props.text"></p> + </template> + </modal> +</template> diff --git a/app/assets/javascripts/pipelines/components/stop_confirmation_modal.vue b/app/assets/javascripts/pipelines/components/stop_confirmation_modal.vue new file mode 100644 index 00000000000..d737d567787 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/stop_confirmation_modal.vue @@ -0,0 +1,65 @@ +<script> + import modal from '~/vue_shared/components/modal.vue'; + import { s__, sprintf } from '~/locale'; + import eventHub from '../event_hub'; + + export default { + components: { + modal, + }, + data() { + return { + id: '', + callback: () => {}, + }; + }, + computed: { + title() { + return sprintf(s__('Pipeline|Stop pipeline #%{id}?'), { + id: `'${this.id}'`, + }, false); + }, + text() { + return sprintf(s__('Pipeline|You’re about to stop pipeline %{id}.'), { + id: `<strong>#${this.id}</strong>`, + }, false); + }, + primaryButtonLabel() { + return s__('Pipeline|Stop pipeline'); + }, + }, + created() { + eventHub.$on('actionConfirmationModal', this.updateModal); + }, + beforeDestroy() { + eventHub.$off('actionConfirmationModal', this.updateModal); + }, + methods: { + updateModal(action) { + this.id = action.id; + this.callback = action.callback; + }, + onSubmit() { + this.callback(); + }, + }, + }; +</script> + +<template> + <modal + id="stop-confirmation-modal" + :title="title" + :text="text" + kind="danger" + :primary-button-label="primaryButtonLabel" + @submit="onSubmit" + > + <template + slot="body" + slot-scope="props" + > + <p v-html="props.text"></p> + </template> + </modal> +</template> diff --git a/app/assets/javascripts/pipelines/pipelines_charts.js b/app/assets/javascripts/pipelines/pipelines_charts.js deleted file mode 100644 index 2fce1945906..00000000000 --- a/app/assets/javascripts/pipelines/pipelines_charts.js +++ /dev/null @@ -1,38 +0,0 @@ -import Chart from 'chart.js'; - -document.addEventListener('DOMContentLoaded', () => { - const chartData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); - const buildChart = (chartScope) => { - const data = { - labels: chartScope.labels, - datasets: [{ - fillColor: '#707070', - strokeColor: '#707070', - pointColor: '#707070', - pointStrokeColor: '#EEE', - data: chartScope.totalValues, - }, - { - fillColor: '#1aaa55', - strokeColor: '#1aaa55', - pointColor: '#1aaa55', - pointStrokeColor: '#fff', - data: chartScope.successValues, - }, - ], - }; - const ctx = $(`#${chartScope.scope}Chart`).get(0).getContext('2d'); - const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, - }; - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - new Chart(ctx).Line(data, options); - }; - - chartData.forEach(scope => buildChart(scope)); -}); diff --git a/app/assets/javascripts/pipelines/pipelines_times.js b/app/assets/javascripts/pipelines/pipelines_times.js deleted file mode 100644 index 14a0eca77e9..00000000000 --- a/app/assets/javascripts/pipelines/pipelines_times.js +++ /dev/null @@ -1,27 +0,0 @@ -import Chart from 'chart.js'; - -document.addEventListener('DOMContentLoaded', () => { - const chartData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); - const data = { - labels: chartData.labels, - datasets: [{ - fillColor: 'rgba(220,220,220,0.5)', - strokeColor: 'rgba(220,220,220,1)', - barStrokeWidth: 1, - barValueSpacing: 1, - barDatasetSpacing: 1, - data: chartData.values, - }], - }; - const ctx = $('#build_timesChart').get(0).getContext('2d'); - const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, - }; - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - new Chart(ctx).Bar(data, options); -}); diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index ba4ac850346..930f0fb381e 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,7 +1,9 @@ /* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */ import Cookies from 'js-cookie'; -import Flash from '../flash'; -import { getPagePath } from '../lib/utils/common_utils'; +import { getPagePath } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import flash from '../flash'; ((global) => { class Profile { @@ -31,9 +33,6 @@ import { getPagePath } from '../lib/utils/common_utils'; $('input[name="user[multi_file]"]').on('change', this.setNewRepoCookie); $('#user_notification_email').on('change', this.submitForm); $('#user_notified_of_own_activity').on('change', this.submitForm); - $('.update-username').on('ajax:before', this.beforeUpdateUsername); - $('.update-username').on('ajax:complete', this.afterUpdateUsername); - $('.update-notifications').on('ajax:success', this.onUpdateNotifs); this.form.on('submit', this.onSubmitForm); } @@ -46,21 +45,6 @@ import { getPagePath } from '../lib/utils/common_utils'; return this.saveForm(); } - beforeUpdateUsername() { - $('.loading-username', this).removeClass('hidden'); - } - - afterUpdateUsername() { - $('.loading-username', this).addClass('hidden'); - $('button[type=submit]', this).enable(); - } - - onUpdateNotifs(e, data) { - return data.saved ? - new Flash("Notification settings saved", "notice") : - new Flash("Failed to save new settings", "alert"); - } - saveForm() { const self = this; const formData = new FormData(this.form[0]); @@ -70,21 +54,18 @@ import { getPagePath } from '../lib/utils/common_utils'; formData.append('user[avatar]', avatarBlob, 'avatar.png'); } - return $.ajax({ + axios({ + method: this.form.attr('method'), url: this.form.attr('action'), - type: this.form.attr('method'), data: formData, - dataType: "json", - processData: false, - contentType: false, - success: response => new Flash(response.message, 'notice'), - error: jqXHR => new Flash(jqXHR.responseJSON.message, 'alert'), - complete: () => { - window.scrollTo(0, 0); - // Enable submit button after requests ends - return self.form.find(':input[disabled]').enable(); - } - }); + }) + .then(({ data }) => flash(data.message, 'notice')) + .then(() => { + window.scrollTo(0, 0); + // Enable submit button after requests ends + self.form.find(':input[disabled]').enable(); + }) + .catch(error => flash(error.message)); } setNewRepoCookie() { diff --git a/app/assets/javascripts/prometheus_metrics/index.js b/app/assets/javascripts/prometheus_metrics/index.js deleted file mode 100644 index a0c43c5abe1..00000000000 --- a/app/assets/javascripts/prometheus_metrics/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import PrometheusMetrics from './prometheus_metrics'; - -$(() => { - const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); - prometheusMetrics.loadActiveMetrics(); -}); diff --git a/app/assets/javascripts/render_math.js b/app/assets/javascripts/render_math.js index 73b6aafdd12..eabdb01b2a9 100644 --- a/app/assets/javascripts/render_math.js +++ b/app/assets/javascripts/render_math.js @@ -1,4 +1,5 @@ -/* global katex */ +import { __ } from './locale'; +import flash from './flash'; // Renders math using KaTeX in any element with the // `js-render-math` class @@ -8,15 +9,8 @@ // <code class="js-render-math"></div> // -import { __ } from './locale'; -import axios from './lib/utils/axios_utils'; -import flash from './flash'; - -// Only load once -let katexLoaded = false; - // Loop over all math elements and render math -function renderWithKaTeX(elements) { +function renderWithKaTeX(elements, katex) { elements.each(function katexElementsLoop() { const mathNode = $('<span></span>'); const $this = $(this); @@ -34,30 +28,10 @@ function renderWithKaTeX(elements) { export default function renderMath($els) { if (!$els.length) return; - - if (katexLoaded) { - renderWithKaTeX($els); - } else { - axios.get(gon.katex_css_url) - .then(() => { - const css = $('<link>', { - rel: 'stylesheet', - type: 'text/css', - href: gon.katex_css_url, - }); - css.appendTo('head'); - }) - .then(() => axios.get(gon.katex_js_url, { - responseType: 'text', - })) - .then(({ data }) => { - // Add katex js to our document - $.globalEval(data); - }) - .then(() => { - katexLoaded = true; - renderWithKaTeX($els); // Run KaTeX - }) - .catch(() => flash(__('An error occurred while rendering KaTeX'))); - } + Promise.all([ + import(/* webpackChunkName: 'katex' */ 'katex'), + import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.css'), + ]).then(([katex]) => { + renderWithKaTeX($els, katex); + }).catch(() => flash(__('An error occurred while rendering KaTeX'))); } diff --git a/app/assets/javascripts/render_mermaid.js b/app/assets/javascripts/render_mermaid.js index 31c7a772cf4..d4f18955bd2 100644 --- a/app/assets/javascripts/render_mermaid.js +++ b/app/assets/javascripts/render_mermaid.js @@ -30,6 +30,9 @@ export default function renderMermaid($els) { $els.each((i, el) => { const source = el.textContent; + // Remove any extra spans added by the backend syntax highlighting. + Object.assign(el, { textContent: source }); + mermaid.init(undefined, el, (id) => { const svg = document.getElementById(id); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 98b524f7e3f..8f4a8704c3b 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,4 +1,5 @@ /* eslint-disable no-return-assign, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-unused-vars, no-cond-assign, consistent-return, object-shorthand, prefer-arrow-callback, func-names, space-before-function-paren, prefer-template, quotes, class-methods-use-this, no-sequences, wrap-iife, no-lonely-if, no-else-return, no-param-reassign, vars-on-top, max-len */ +import axios from './lib/utils/axios_utils'; import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from './lib/utils/common_utils'; /** @@ -146,23 +147,25 @@ export default class SearchAutocomplete { this.loadingSuggestions = true; - return $.get(this.autocompletePath, { - project_id: this.projectId, - project_ref: this.projectRef, - term: term, - }, (response) => { - var firstCategory, i, lastCategory, len, suggestion; + return axios.get(this.autocompletePath, { + params: { + project_id: this.projectId, + project_ref: this.projectRef, + term: term, + }, + }).then((response) => { // Hide dropdown menu if no suggestions returns - if (!response.length) { + if (!response.data.length) { this.disableAutocomplete(); return; } const data = []; // List results - firstCategory = true; - for (i = 0, len = response.length; i < len; i += 1) { - suggestion = response[i]; + let firstCategory = true; + let lastCategory; + for (let i = 0, len = response.data.length; i < len; i += 1) { + const suggestion = response.data[i]; // Add group header before list each group if (lastCategory !== suggestion.category) { if (!firstCategory) { @@ -177,7 +180,7 @@ export default class SearchAutocomplete { lastCategory = suggestion.category; } data.push({ - id: (suggestion.category.toLowerCase()) + "-" + suggestion.id, + id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, category: suggestion.category, text: suggestion.label, url: suggestion.url, @@ -187,13 +190,17 @@ export default class SearchAutocomplete { if (data.length) { data.push('separator'); data.push({ - text: "Result name contains \"" + term + "\"", - url: "/search?search=" + term + "&project_id=" + (this.projectInputEl.val()) + "&group_id=" + (this.groupInputEl.val()), + text: `Result name contains "${term}"`, + url: `/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`, }); } - return callback(data); - }) - .always(() => { this.loadingSuggestions = false; }); + + callback(data); + + this.loadingSuggestions = false; + }).catch(() => { + this.loadingSuggestions = false; + }); } getCategoryContents() { diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js index d34a21b37e1..d0e4f533d8a 100644 --- a/app/assets/javascripts/settings_panels.js +++ b/app/assets/javascripts/settings_panels.js @@ -42,7 +42,7 @@ export default function initSettingsPanels() { if (location.hash) { const $target = $(location.hash); - if ($target.length && $target.hasClass('.settings')) { + if ($target.length && $target.hasClass('settings')) { expandSection($target); } } diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 02153fb86a5..8a86c409b62 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -2,6 +2,7 @@ import Flash from '../../../flash'; import editForm from './edit_form.vue'; import Icon from '../../../vue_shared/components/icon.vue'; + import { __ } from '../../../locale'; export default { components: { @@ -40,8 +41,7 @@ this.service.update('issue', { confidential }) .then(() => location.reload()) .catch(() => { - Flash(`Something went wrong trying to - change the confidentiality of this issue`); + Flash(__('Something went wrong trying to change the confidentiality of this issue')); }); }, }, @@ -58,7 +58,7 @@ /> </div> <div class="title hide-collapsed"> - Confidentiality + {{ __('Confidentiality') }} <a v-if="isEditable" class="pull-right confidential-edit" @@ -84,7 +84,7 @@ aria-hidden="true" class="sidebar-item-icon inline" /> - Not confidential + {{ __('Not confidential') }} </div> <div v-else @@ -95,7 +95,7 @@ aria-hidden="true" class="sidebar-item-icon inline is-active" /> - This issue is confidential + {{ __('This issue is confidential') }} </div> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index 6a81235a1a7..c569843b05f 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -1,5 +1,6 @@ <script> import editFormButtons from './edit_form_buttons.vue'; + import { s__ } from '../../../locale'; export default { components: { @@ -19,6 +20,14 @@ type: Function, }, }, + computed: { + confidentialityOnWarning() { + return s__('confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.'); + }, + confidentialityOffWarning() { + return s__('confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.'); + }, + }, }; </script> @@ -26,15 +35,13 @@ <div class="dropdown open"> <div class="dropdown-menu sidebar-item-warning-message"> <div> - <p v-if="!isConfidential"> - You are going to turn on the confidentiality. This means that only team members with - <strong>at least Reporter access</strong> - are able to see and leave comments on the issue. + <p + v-if="!isConfidential" + v-html="confidentialityOnWarning"> </p> - <p v-else> - You are going to turn off the confidentiality. This means - <strong>everyone</strong> - will be able to see and leave a comment on this issue. + <p + v-else + v-html="confidentialityOffWarning"> </p> <edit-form-buttons :is-confidential="isConfidential" diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 7ed0619ee6b..49d5dfeea1a 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -32,7 +32,7 @@ export default { class="btn btn-default append-right-10" @click="toggleForm" > - Cancel + {{ __('Cancel') }} </button> <button type="button" diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue index e7a87636aa7..bc32e974bc3 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -1,6 +1,7 @@ <script> import editFormButtons from './edit_form_buttons.vue'; import issuableMixin from '../../../vue_shared/mixins/issuable'; + import { __, sprintf } from '../../../locale'; export default { components: { @@ -25,6 +26,14 @@ type: Function, }, }, + computed: { + lockWarning() { + return sprintf(__('Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); + }, + unlockWarning() { + return sprintf(__('Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.'), { issuableDisplayName: this.issuableDisplayName }); + }, + }, }; </script> @@ -33,19 +42,14 @@ <div class="dropdown-menu sidebar-item-warning-message"> <p class="text" - v-if="isLocked"> - Unlock this {{ issuableDisplayName }}? - <strong>Everyone</strong> - will be able to comment. + v-if="isLocked" + v-html="unlockWarning"> </p> <p class="text" - v-else> - Lock this {{ issuableDisplayName }}? - Only - <strong>project members</strong> - will be able to comment. + v-else + v-html="lockWarning"> </p> <edit-form-buttons diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index 02876a6c175..9d22b9d77be 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -72,10 +72,10 @@ </div> <div class="title hide-collapsed"> - Lock {{ issuableDisplayName }} + {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} <button v-if="isEditable" - class="pull-right lock-edit btn btn-blank" + class="pull-right lock-edit" type="button" @click.prevent="toggleForm" > diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js index fd0d4570d68..b5ebccd3795 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js @@ -68,7 +68,7 @@ export default { <div class="compare-display-container"> <div class="compare-display pull-left"> <span class="compare-label"> - Spent + {{ s__('TimeTracking|Spent') }} </span> <span class="compare-value spent"> {{ timeSpentHumanReadable }} @@ -76,7 +76,7 @@ export default { </div> <div class="compare-display estimated pull-right"> <span class="compare-label"> - Est + {{ s__('TimeTrackingEstimated|Est') }} </span> <span class="compare-value"> {{ timeEstimateHumanReadable }} diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js index ad1b9179db0..2d324c71379 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js @@ -9,7 +9,7 @@ export default { template: ` <div class="time-tracking-estimate-only-pane"> <span class="bold"> - Estimated: + {{ s__('TimeTracking|Estimated:') }} </span> {{ timeEstimateHumanReadable }} </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js index 142ad437509..19f74ad3c6d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js @@ -1,3 +1,5 @@ +import { sprintf, s__ } from '../../../locale'; + export default { name: 'time-tracking-help-state', props: { @@ -10,33 +12,39 @@ export default { href() { return `${this.rootPath}help/workflow/time_tracking.md`; }, + estimateText() { + return sprintf( + s__('estimateCommand|%{slash_command} will update the estimated time with the latest command.'), { + slash_command: '<code>/estimate</code>', + }, false, + ); + }, + spendText() { + return sprintf( + s__('spendCommand|%{slash_command} will update the sum of the time spent.'), { + slash_command: '<code>/spend</code>', + }, false, + ); + }, }, template: ` <div class="time-tracking-help-state"> <div class="time-tracking-info"> <h4> - Track time with quick actions + {{ __('Track time with quick actions') }} </h4> <p> - Quick actions can be used in the issues description and comment boxes. + {{ __('Quick actions can be used in the issues description and comment boxes.') }} </p> - <p> - <code> - /estimate - </code> - will update the estimated time with the latest command. + <p v-html="estimateText"> </p> - <p> - <code> - /spend - </code> - will update the sum of the time spent. + <p v-html="spendText"> </p> <a class="btn btn-default learn-more-button" :href="href" > - Learn more + {{ __('Learn more') }} </a> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js index d1dd1dcdd27..38da76c6771 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js @@ -3,7 +3,7 @@ export default { template: ` <div class="time-tracking-no-tracking-pane"> <span class="no-value"> - No estimate or time spent + {{ __('No estimate or time spent') }} </span> </div> `, diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js index d32fe4abc7d..782e4ba4fad 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import '~/smart_interval'; -import timeTracker from './time_tracker'; +import IssuableTimeTracker from './time_tracker.vue'; import Store from '../../stores/sidebar_store'; import Mediator from '../../sidebar_mediator'; @@ -16,7 +16,7 @@ export default { }; }, components: { - 'issuable-time-tracker': timeTracker, + IssuableTimeTracker, }, methods: { listenForQuickActions() { diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index ed0d71a4f79..230736a56b8 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,3 +1,4 @@ +<script> import timeTrackingHelpState from './help_state'; import timeTrackingCollapsedState from './collapsed_state'; import timeTrackingSpentOnlyPane from './spent_only_pane'; @@ -8,7 +9,15 @@ import timeTrackingComparisonPane from './comparison_pane'; import eventHub from '../../event_hub'; export default { - name: 'issuable-time-tracker', + name: 'IssuableTimeTracker', + components: { + 'time-tracking-collapsed-state': timeTrackingCollapsedState, + 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane, + 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, + 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane, + 'time-tracking-comparison-pane': timeTrackingComparisonPane, + 'time-tracking-help-state': timeTrackingHelpState, + }, props: { time_estimate: { type: Number, @@ -38,14 +47,6 @@ export default { showHelp: false, }; }, - components: { - 'time-tracking-collapsed-state': timeTrackingCollapsedState, - 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane, - 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, - 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane, - 'time-tracking-comparison-pane': timeTrackingComparisonPane, - 'time-tracking-help-state': timeTrackingHelpState, - }, computed: { timeSpent() { return this.time_spent; @@ -81,6 +82,9 @@ export default { return !!this.showHelp; }, }, + created() { + eventHub.$on('timeTracker:updateData', this.update); + }, methods: { toggleHelpState(show) { this.showHelp = show; @@ -92,72 +96,73 @@ export default { this.human_time_spent = data.human_time_spent; }, }, - created() { - eventHub.$on('timeTracker:updateData', this.update); - }, - template: ` - <div - class="time_tracker time-tracking-component-wrap" - v-cloak - > - <time-tracking-collapsed-state - :show-comparison-state="showComparisonState" - :show-no-time-tracking-state="showNoTimeTrackingState" - :show-help-state="showHelpState" - :show-spent-only-state="showSpentOnlyState" - :show-estimate-only-state="showEstimateOnlyState" +}; +</script> + +<template> + <div + class="time_tracker time-tracking-component-wrap" + v-cloak + > + <time-tracking-collapsed-state + :show-comparison-state="showComparisonState" + :show-no-time-tracking-state="showNoTimeTrackingState" + :show-help-state="showHelpState" + :show-spent-only-state="showSpentOnlyState" + :show-estimate-only-state="showEstimateOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <div class="title hide-collapsed"> + {{ __('Time tracking') }} + <div + class="help-button pull-right" + v-if="!showHelpState" + @click="toggleHelpState(true)" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + > + </i> + </div> + <div + class="close-help-button pull-right" + v-if="showHelpState" + @click="toggleHelpState(false)" + > + <i + class="fa fa-close" + aria-hidden="true" + > + </i> + </div> + </div> + <div class="time-tracking-content hide-collapsed"> + <time-tracking-estimate-only-pane + v-if="showEstimateOnlyState" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <time-tracking-spent-only-pane + v-if="showSpentOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + /> + <time-tracking-no-tracking-pane + v-if="showNoTimeTrackingState" + /> + <time-tracking-comparison-pane + v-if="showComparisonState" + :time-estimate="timeEstimate" + :time-spent="timeSpent" :time-spent-human-readable="timeSpentHumanReadable" :time-estimate-human-readable="timeEstimateHumanReadable" /> - <div class="title hide-collapsed"> - Time tracking - <div - class="help-button pull-right" - v-if="!showHelpState" - @click="toggleHelpState(true)" - > - <i - class="fa fa-question-circle" - aria-hidden="true" - /> - </div> - <div - class="close-help-button pull-right" + <transition name="help-state-toggle"> + <time-tracking-help-state v-if="showHelpState" - @click="toggleHelpState(false)" - > - <i - class="fa fa-close" - aria-hidden="true" - /> - </div> - </div> - <div class="time-tracking-content hide-collapsed"> - <time-tracking-estimate-only-pane - v-if="showEstimateOnlyState" - :time-estimate-human-readable="timeEstimateHumanReadable" + :root-path="rootPath" /> - <time-tracking-spent-only-pane - v-if="showSpentOnlyState" - :time-spent-human-readable="timeSpentHumanReadable" - /> - <time-tracking-no-tracking-pane - v-if="showNoTimeTrackingState" - /> - <time-tracking-comparison-pane - v-if="showComparisonState" - :time-estimate="timeEstimate" - :time-spent="timeSpent" - :time-spent-human-readable="timeSpentHumanReadable" - :time-estimate-human-readable="timeEstimateHumanReadable" - /> - <transition name="help-state-toggle"> - <time-tracking-help-state - v-if="showHelpState" - :rootPath="rootPath" - /> - </transition> - </div> + </transition> </div> - `, -}; + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 40c3cb500bb..ebaf2b972eb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -44,7 +44,10 @@ type="button" class="btn btn-xs btn-default" > - <loading-icon v-if="isRefreshing" /> + <loading-icon + v-if="isRefreshing" + :inline="true" + /> {{ s__("mrWidget|Refresh") }} </button> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js deleted file mode 100644 index 7733fb74afe..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js +++ /dev/null @@ -1,43 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; -import tooltip from '../../../vue_shared/directives/tooltip'; -import mrWidgetMergeHelp from '../../components/mr_widget_merge_help.vue'; - -export default { - name: 'MRWidgetMissingBranch', - props: { - mr: { type: Object, required: true }, - }, - directives: { - tooltip, - }, - components: { - 'mr-widget-merge-help': mrWidgetMergeHelp, - statusIcon, - }, - computed: { - missingBranchName() { - return this.mr.sourceBranchRemoved ? 'source' : 'target'; - }, - message() { - return `If the ${this.missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line`; - }, - }, - template: ` - <div class="mr-widget-body media"> - <status-icon status="warning" :show-disabled-button="true" /> - <div class="media-body space-children"> - <span class="bold js-branch-text"> - <span class="capitalize"> - {{missingBranchName}} - </span> branch does not exist. - Please restore it or use a different {{missingBranchName}} branch - <i - v-tooltip - class="fa fa-question-circle" - :title="message" - :aria-label="message"></i> - </span> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue new file mode 100644 index 00000000000..718c0e4b3c6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -0,0 +1,62 @@ +<script> + import { sprintf, s__ } from '~/locale'; + import tooltip from '~/vue_shared/directives/tooltip'; + import statusIcon from '../mr_widget_status_icon.vue'; + import mrWidgetMergeHelp from '../../components/mr_widget_merge_help.vue'; + + export default { + name: 'MRWidgetMissingBranch', + directives: { + tooltip, + }, + components: { + mrWidgetMergeHelp, + statusIcon, + }, + props: { + mr: { + type: Object, + required: true, + }, + }, + computed: { + missingBranchName() { + return this.mr.sourceBranchRemoved ? 'source' : 'target'; + }, + missingBranchNameMessage() { + return sprintf(s__('mrWidget| Please restore it or use a different %{missingBranchName} branch'), { + missingBranchName: this.missingBranchName, + }); + }, + message() { + return sprintf(s__('mrWidget|If the %{missingBranchName} branch exists in your local repository, you can merge this merge request manually using the command line'), { + missingBranchName: this.missingBranchName, + }); + }, + }, + }; +</script> +<template> + <div class="mr-widget-body media"> + <status-icon + status="warning" + :show-disabled-button="true" + /> + + <div class="media-body space-children"> + <span class="bold js-branch-text"> + <span class="capitalize"> + {{ missingBranchName }} + </span> {{ s__("mrWidget|branch does not exist.") }} + {{ missingBranchNameMessage }} + <i + v-tooltip + class="fa fa-question-circle" + :title="message" + :aria-label="message" + > + </i> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js deleted file mode 100644 index cea3d97fa88..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js +++ /dev/null @@ -1,19 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; - -export default { - name: 'MRWidgetNotAllowed', - components: { - statusIcon, - }, - template: ` - <div class="mr-widget-body media"> - <status-icon status="success" :show-disabled-button="true" /> - <div class="media-body space-children"> - <span class="bold"> - Ready to be merged automatically. - Ask someone with write access to this repository to merge this request - </span> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue new file mode 100644 index 00000000000..e4af50b09f8 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue @@ -0,0 +1,25 @@ +<script> + import StatusIcon from '../mr_widget_status_icon.vue'; + + export default { + name: 'MRWidgetNotAllowed', + components: { + StatusIcon, + }, + }; +</script> + +<template> + <div class="mr-widget-body media"> + <status-icon + status="success" + :show-disabled-button="true" + /> + <div class="media-body space-children"> + <span class="bold"> + {{ s__(`mrWidget|Ready to be merged automatically. +Ask someone with write access to this repository to merge this request`) }} + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js deleted file mode 100644 index e66ce071ab4..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js +++ /dev/null @@ -1,18 +0,0 @@ -import statusIcon from '../mr_widget_status_icon.vue'; - -export default { - name: 'MRWidgetPipelineBlocked', - components: { - statusIcon, - }, - template: ` - <div class="mr-widget-body media"> - <status-icon status="warning" :show-disabled-button="true" /> - <div class="media-body space-children"> - <span class="bold"> - Pipeline blocked. The pipeline for this merge request requires a manual action to proceed - </span> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue new file mode 100644 index 00000000000..6d7cc03f7ad --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue @@ -0,0 +1,24 @@ +<script> + import StatusIcon from '../mr_widget_status_icon.vue'; + + export default { + name: 'MRWidgetPipelineBlocked', + components: { + StatusIcon, + }, + }; +</script> +<template> + <div class="mr-widget-body media"> + <status-icon + status="warning" + :show-disabled-button="true" + /> + <div class="media-body space-children"> + <span class="bold"> + {{ s__(`mrWidget|Pipeline blocked. +The pipeline for this merge request requires a manual action to proceed`) }} + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 7ca15537719..edb3baa39e4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -24,12 +24,12 @@ export { default as WipState } from './components/states/mr_widget_wip'; export { default as ArchivedState } from './components/states/mr_widget_archived.vue'; export { default as ConflictsState } from './components/states/mr_widget_conflicts.vue'; export { default as NothingToMergeState } from './components/states/mr_widget_nothing_to_merge'; -export { default as MissingBranchState } from './components/states/mr_widget_missing_branch'; -export { default as NotAllowedState } from './components/states/mr_widget_not_allowed'; +export { default as MissingBranchState } from './components/states/mr_widget_missing_branch.vue'; +export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue'; export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch'; export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions'; -export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked'; +export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue'; export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds.vue'; export { default as RebaseState } from './components/states/mr_widget_rebase.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index edf67fcd0a7..d8f0442ef9d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -152,6 +152,7 @@ export default { }, handleNotification(data) { if (data.ci_status === this.mr.ciStatus) return; + if (!data.pipeline) return; const label = data.pipeline.details.status.label; const title = `Pipeline ${label}`; diff --git a/app/assets/javascripts/vue_shared/components/confirmation_input.vue b/app/assets/javascripts/vue_shared/components/confirmation_input.vue deleted file mode 100644 index 1aa03ea6317..00000000000 --- a/app/assets/javascripts/vue_shared/components/confirmation_input.vue +++ /dev/null @@ -1,62 +0,0 @@ -<script> - import _ from 'underscore'; - import { __, sprintf } from '~/locale'; - - export default { - props: { - inputId: { - type: String, - required: true, - }, - confirmationKey: { - type: String, - required: true, - }, - confirmationValue: { - type: String, - required: true, - }, - shouldEscapeConfirmationValue: { - type: Boolean, - required: false, - default: true, - }, - }, - computed: { - inputLabel() { - let value = this.confirmationValue; - if (this.shouldEscapeConfirmationValue) { - value = _.escape(value); - } - - return sprintf( - __('Type %{value} to confirm:'), - { value: `<code>${value}</code>` }, - false, - ); - }, - }, - methods: { - hasCorrectValue() { - return this.$refs.enteredValue.value === this.confirmationValue; - }, - }, - }; -</script> - -<template> - <div> - <label - v-html="inputLabel" - :for="inputId" - > - </label> - <input - :id="inputId" - :name="confirmationKey" - type="text" - ref="enteredValue" - class="form-control" - /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue new file mode 100644 index 00000000000..67c9181c7b1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue @@ -0,0 +1,106 @@ +<script> + const buttonVariants = [ + 'danger', + 'primary', + 'success', + 'warning', + ]; + + export default { + name: 'GlModal', + + props: { + id: { + type: String, + required: false, + default: null, + }, + headerTitleText: { + type: String, + required: false, + default: '', + }, + footerPrimaryButtonVariant: { + type: String, + required: false, + default: 'primary', + validator: value => buttonVariants.indexOf(value) !== -1, + }, + footerPrimaryButtonText: { + type: String, + required: false, + default: '', + }, + }, + + methods: { + emitCancel(event) { + this.$emit('cancel', event); + }, + emitSubmit(event) { + this.$emit('submit', event); + }, + }, + }; +</script> + +<template> + <div + :id="id" + class="modal fade" + tabindex="-1" + role="dialog" + > + <div + class="modal-dialog" + role="document" + > + <div class="modal-content"> + <div class="modal-header"> + <slot name="header"> + <button + type="button" + class="close" + data-dismiss="modal" + :aria-label="s__('Modal|Close')" + @click="emitCancel($event)" + > + <span aria-hidden="true">×</span> + </button> + <h4 class="modal-title"> + <slot name="title"> + {{ headerTitleText }} + </slot> + </h4> + </slot> + </div> + + <div class="modal-body"> + <slot></slot> + </div> + + <div class="modal-footer"> + <slot name="footer"> + <button + type="button" + class="btn" + data-dismiss="modal" + @click="emitCancel($event)" + > + {{ s__('Modal|Cancel') }} + </button> + <button + type="button" + class="btn" + :class="`btn-${footerPrimaryButtonVariant}`" + data-dismiss="modal" + @click="emitSubmit($event)" + > + {{ footerPrimaryButtonText }} + </button> + </slot> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index ff8c0f7c1d2..6ae6b179f7f 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -40,7 +40,7 @@ required: false, }, containerClass: { - type: String, + type: [String, Array, Object], required: false, default: 'btn btn-align-content', }, diff --git a/app/assets/javascripts/vue_shared/components/modal.vue b/app/assets/javascripts/vue_shared/components/modal.vue index 8227428d8ba..5f1364421aa 100644 --- a/app/assets/javascripts/vue_shared/components/modal.vue +++ b/app/assets/javascripts/vue_shared/components/modal.vue @@ -46,6 +46,11 @@ required: false, default: '', }, + secondaryButtonLabel: { + type: String, + required: false, + default: '', + }, submitDisabled: { type: Boolean, required: false, @@ -129,6 +134,21 @@ > {{ closeButtonLabel }} </button> + + <slot + v-if="secondaryButtonLabel" + name="secondary-button" + > + <button + v-if="secondaryButtonLabel" + type="button" + class="btn" + data-dismiss="modal" + > + {{ secondaryButtonLabel }} + </button> + </slot> + <button v-if="primaryButtonLabel" type="button" diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 887879ab715..2fccfa4011c 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -21,7 +21,7 @@ @import "framework/flash"; @import "framework/forms"; @import "framework/gfm"; -@import "framework/gitlab-theme"; +@import "framework/gitlab_theme"; @import "framework/header"; @import "framework/highlight"; @import "framework/issue_box"; @@ -35,10 +35,10 @@ @import "framework/pagination"; @import "framework/panels"; @import "framework/popup"; -@import "framework/secondary-navigation-elements"; +@import "framework/secondary_navigation_elements"; @import "framework/selects"; @import "framework/sidebar"; -@import "framework/contextual-sidebar"; +@import "framework/contextual_sidebar"; @import "framework/tables"; @import "framework/notes"; @import "framework/tabs"; @@ -49,16 +49,16 @@ @import "framework/zen"; @import "framework/blank"; @import "framework/wells"; -@import "framework/page-header"; +@import "framework/page_header"; @import "framework/awards"; @import "framework/images"; -@import "framework/broadcast-messages"; +@import "framework/broadcast_messages"; @import "framework/emojis"; -@import "framework/emoji-sprites"; +@import "framework/emoji_sprites"; @import "framework/icons"; @import "framework/snippets"; @import "framework/memory_graph"; @import "framework/responsive_tables"; -@import "framework/stacked-progress-bar"; +@import "framework/stacked_progress_bar"; @import "framework/ci_variable_list"; @import "framework/feature_highlight"; diff --git a/app/assets/stylesheets/framework/broadcast-messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss index 9b54fb94cdc..9b54fb94cdc 100644 --- a/app/assets/stylesheets/framework/broadcast-messages.scss +++ b/app/assets/stylesheets/framework/broadcast_messages.scss diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss index 8f654ab363c..5fe835dd8f9 100644 --- a/app/assets/stylesheets/framework/ci_variable_list.scss +++ b/app/assets/stylesheets/framework/ci_variable_list.scss @@ -8,7 +8,11 @@ .ci-variable-row { display: flex; - align-items: flex-end; + align-items: flex-start; + + @media (max-width: $screen-xs-max) { + align-items: flex-end; + } &:not(:last-child) { margin-bottom: $gl-btn-padding; @@ -41,6 +45,7 @@ .ci-variable-row-body { display: flex; + align-items: flex-start; width: 100%; @media (max-width: $screen-xs-max) { @@ -65,6 +70,8 @@ flex: 0 1 auto; display: flex; align-items: center; + padding-top: 5px; + padding-bottom: 5px; } .ci-variable-row-remove-button { @@ -85,4 +92,8 @@ outline: none; color: $gl-text-color; } + + &[disabled] { + color: $gl-text-color-disabled; + } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 73524d5cf60..ae517c41cb2 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -449,9 +449,11 @@ img.emoji { .prepend-top-10 { margin-top: 10px; } .prepend-top-15 { margin-top: 15px; } .prepend-top-default { margin-top: $gl-padding !important; } +.prepend-top-16 { margin-top: 16px; } .prepend-top-20 { margin-top: 20px; } .prepend-left-4 { margin-left: 4px; } .prepend-left-5 { margin-left: 5px; } +.prepend-left-8 { margin-left: 8px; } .prepend-left-10 { margin-left: 10px; } .prepend-left-default { margin-left: $gl-padding; } .prepend-left-20 { margin-left: 20px; } diff --git a/app/assets/stylesheets/framework/contextual-sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index 1acde98c3ae..1acde98c3ae 100644 --- a/app/assets/stylesheets/framework/contextual-sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 691df098c70..1d7b0b602cc 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -736,10 +736,6 @@ } } -.droplab-item-ignore { - pointer-events: none; -} - .pika-single.animate-picker.is-bound, .pika-single.animate-picker.is-bound.is-hidden { /* diff --git a/app/assets/stylesheets/framework/emoji-sprites.scss b/app/assets/stylesheets/framework/emoji_sprites.scss index 0174e17b660..0174e17b660 100644 --- a/app/assets/stylesheets/framework/emoji-sprites.scss +++ b/app/assets/stylesheets/framework/emoji_sprites.scss diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index be96c8ee964..a2ea155a10e 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -182,6 +182,7 @@ label { .help-block { margin-bottom: 0; + margin-top: #{$grid-size / 2}; } .gl-field-error { diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index db36e27fa74..db36e27fa74 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index dcd98cb522f..7e829826eba 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -255,8 +255,6 @@ ul.controls { } .author_link { - display: inline-block; - .avatar-inline { margin-left: 0; margin-right: 0; diff --git a/app/assets/stylesheets/framework/page-header.scss b/app/assets/stylesheets/framework/page_header.scss index 0c879f40930..0c879f40930 100644 --- a/app/assets/stylesheets/framework/page-header.scss +++ b/app/assets/stylesheets/framework/page_header.scss diff --git a/app/assets/stylesheets/framework/secondary-navigation-elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 17c31d6b184..17c31d6b184 100644 --- a/app/assets/stylesheets/framework/secondary-navigation-elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss diff --git a/app/assets/stylesheets/framework/stacked-progress-bar.scss b/app/assets/stylesheets/framework/stacked_progress_bar.scss index 4869cda73e5..4869cda73e5 100644 --- a/app/assets/stylesheets/framework/stacked-progress-bar.scss +++ b/app/assets/stylesheets/framework/stacked_progress_bar.scss diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 0d21a9f5f77..25ee081ea9c 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -214,6 +214,7 @@ $tooltip-font-size: 12px; * Padding */ $gl-padding: 16px; +$gl-padding-8: 8px; $gl-col-padding: 15px; $gl-btn-padding: 10px; $gl-input-padding: 10px; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 794bc668562..884665d35c7 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -121,6 +121,10 @@ width: 100%; text-align: left; } + + .environment-child-row { + padding-left: 20px; + } } } @@ -205,7 +209,7 @@ } .prometheus-state { - max-width: 430px; + max-width: 460px; margin: 10px auto; text-align: center; @@ -213,6 +217,10 @@ max-width: 80vw; margin: 0 auto; } + + .state-button { + padding: $gl-padding / 2; + } } .environments-actions { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 759719a72da..3fe95b34e01 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -197,11 +197,18 @@ margin-left: 0; } + a.edit-link:not([href]):hover { + color: rgba($avatar-border, .2); + } + + .lock-edit, // uses same style, different js behaviour .edit-link { + @extend .btn-blank; color: $gl-text-color; - &:not([href]):hover { - color: rgba($avatar-border, .2); + &:hover { + text-decoration: underline; + color: $md-link-color; } } } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index c48e58af691..6763af4e98b 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -181,11 +181,6 @@ ul.related-merge-requests > li { } .create-mr-dropdown-wrap { - .branch-message, - .ref-message { - display: none; - } - .ref::selection { color: $placeholder-text-color; } @@ -216,6 +211,17 @@ ul.related-merge-requests > li { transform: translateY(0); display: none; margin-top: 4px; + + // override dropdown item styles + .btn.btn-success { + @include btn-default; + @include btn-green; + + border-style: solid; + border-width: 1px; + line-height: $line-height-base; + width: auto; + } } .create-merge-request-dropdown-toggle { @@ -225,66 +231,6 @@ ul.related-merge-requests > li { margin-left: 0; } } - - .droplab-item-ignore { - pointer-events: auto; - } - - .create-item { - cursor: pointer; - margin: 0 1px; - - &:hover, - &:focus { - background-color: $dropdown-item-hover-bg; - color: $gl-text-color; - } - } - - li.divider { - margin: 8px 10px; - } - - li:not(.divider) { - padding: 8px 9px; - - &:last-child { - padding-bottom: 8px; - } - - &.droplab-item-selected { - .icon-container { - i { - visibility: visible; - } - } - - .description { - display: block; - } - } - - &.droplab-item-ignore { - padding-top: 8px; - } - - .icon-container { - float: left; - - i { - visibility: hidden; - } - } - - .description { - padding-left: 22px; - } - - input, - span { - margin: 4px 0 0; - } - } } .discussion-reply-holder .note-edit-form { diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 6353482ede7..47672783d5a 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -135,6 +135,17 @@ padding-top: 0; } +.integration-settings-form { + .well { + padding: $gl-padding / 2; + box-shadow: none; + } + + .svg-container { + max-width: 150px; + } +} + .token-token-container { #impersonation-token-token { width: 80%; diff --git a/app/controllers/ci/lints_controller.rb b/app/controllers/ci/lints_controller.rb index be667687c18..e9bd1689a1e 100644 --- a/app/controllers/ci/lints_controller.rb +++ b/app/controllers/ci/lints_controller.rb @@ -16,10 +16,7 @@ module Ci @builds = @config_processor.builds @jobs = @config_processor.jobs end - rescue - @error = 'Undefined error' - @status = false - ensure + render :show end end diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 4311f9d4db9..5e4e8a87153 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -10,6 +10,8 @@ module LfsRequest extend ActiveSupport::Concern + CONTENT_TYPE = 'application/vnd.git-lfs+json'.freeze + included do before_action :require_lfs_enabled! before_action :lfs_check_access! @@ -50,7 +52,7 @@ module LfsRequest message: 'Access forbidden. Check your access level.', documentation_url: help_url }, - content_type: "application/vnd.git-lfs+json", + content_type: CONTENT_TYPE, status: 403 ) end @@ -61,7 +63,7 @@ module LfsRequest message: 'Not found.', documentation_url: help_url }, - content_type: "application/vnd.git-lfs+json", + content_type: CONTENT_TYPE, status: 404 ) end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 3d61458c064..c1acb50b76c 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -32,6 +32,7 @@ module ServiceParams :issues_events, :issues_url, :jira_issue_transition_id, + :manual_configuration, :merge_requests_events, :mock_service_url, :namespace, diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 10038ff3ad9..913e13bf734 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -1,60 +1,43 @@ module Groups class VariablesController < Groups::ApplicationController - before_action :variable, only: [:show, :update, :destroy] before_action :authorize_admin_build! - def index - redirect_to group_settings_ci_cd_path(group) - end - def show + respond_to do |format| + format.json do + render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) } + end + end end def update - if variable.update(variable_params) - redirect_to group_variables_path(group), - notice: 'Variable was successfully updated.' + if @group.update(group_variables_params) + respond_to do |format| + format.json { return render_group_variables } + end else - render "show" + respond_to do |format| + format.json { render_error } + end end end - def create - @variable = group.variables.create(variable_params) - .present(current_user: current_user) + private - if @variable.persisted? - redirect_to group_settings_ci_cd_path(group), - notice: 'Variable was successfully created.' - else - render "show" - end + def render_group_variables + render status: :ok, json: { variables: GroupVariableSerializer.new.represent(@group.variables) } end - def destroy - if variable.destroy - redirect_to group_settings_ci_cd_path(group), - status: 302, - notice: 'Variable was successfully removed.' - else - redirect_to group_settings_ci_cd_path(group), - status: 302, - notice: 'Failed to remove the variable.' - end + def render_error + render status: :bad_request, json: @group.errors.full_messages end - private - - def variable_params - params.require(:variable).permit(*variable_params_attributes) + def group_variables_params + params.permit(variables_attributes: [*variable_params_attributes]) end def variable_params_attributes - %i[key value protected] - end - - def variable - @variable ||= group.variables.find(params[:id]).present(current_user: current_user) + %i[id key value protected _destroy] end def authorize_admin_build! diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index 9de0297ecfd..c84fc2d305d 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -2,26 +2,16 @@ class Import::BaseController < ApplicationController private def find_or_create_namespace(names, owner) - return current_user.namespace if names == owner - return current_user.namespace unless current_user.can_create_group? - names = params[:target_namespace].presence || names - full_path_namespace = Namespace.find_by_full_path(names) - return full_path_namespace if full_path_namespace + return current_user.namespace if names == owner + + group = Groups::NestedCreateService.new(current_user, group_path: names).execute - names.split('/').inject(nil) do |parent, name| - begin - namespace = Group.create!(name: name, - path: name, - owner: current_user, - parent: parent) - namespace.add_owner(current_user) + group.errors.any? ? current_user.namespace : group + rescue => e + Gitlab::AppLogger.error(e) - namespace - rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid - Namespace.where(parent: parent).find_by_path_or_name(name) - end - end + current_user.namespace end end diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 5ad1e116e4e..13ea736688d 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -37,24 +37,30 @@ class Import::BitbucketController < Import::BaseController def create bitbucket_client = Bitbucket::Client.new(credentials) - @repo_id = params[:repo_id].to_s - name = @repo_id.gsub('___', '/') + repo_id = params[:repo_id].to_s + name = repo_id.gsub('___', '/') repo = bitbucket_client.repo(name) - @project_name = params[:new_name].presence || repo.name + project_name = params[:new_name].presence || repo.name repo_owner = repo.owner repo_owner = current_user.username if repo_owner == bitbucket_client.user.username namespace_path = params[:new_namespace].presence || repo_owner + target_namespace = find_or_create_namespace(namespace_path, current_user) - @target_namespace = find_or_create_namespace(namespace_path, current_user) - - if current_user.can?(:create_projects, @target_namespace) + if current_user.can?(:create_projects, target_namespace) # The token in a session can be expired, we need to get most recent one because # Bitbucket::Connection class refreshes it. session[:bitbucket_token] = bitbucket_client.connection.token - @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, credentials).execute + + project = Gitlab::BitbucketImport::ProjectCreator.new(repo, project_name, target_namespace, current_user, credentials).execute + + if project.persisted? + render json: ProjectSerializer.new.represent(project) + else + render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + end else - render 'unauthorized' + render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity end end diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index 5df6bd34185..669eb31a995 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -58,17 +58,17 @@ class Import::FogbugzController < Import::BaseController end def create - @repo_id = params[:repo_id] - repo = client.repo(@repo_id) + repo = client.repo(params[:repo_id]) fb_session = { uri: session[:fogbugz_uri], token: session[:fogbugz_token] } - @target_namespace = current_user.namespace - @project_name = repo.name - - namespace = @target_namespace - umap = session[:fogbugz_user_map] || client.user_map - @project = Gitlab::FogbugzImport::ProjectCreator.new(repo, fb_session, namespace, current_user, umap).execute + project = Gitlab::FogbugzImport::ProjectCreator.new(repo, fb_session, current_user.namespace, current_user, umap).execute + + if project.persisted? + render json: ProjectSerializer.new.represent(project) + else + render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + end end private diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index b8ba7921613..69fb8121ded 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -36,16 +36,21 @@ class Import::GithubController < Import::BaseController end def create - @repo_id = params[:repo_id].to_i - repo = client.repo(@repo_id) - @project_name = params[:new_name].presence || repo.name + repo = client.repo(params[:repo_id].to_i) + project_name = params[:new_name].presence || repo.name namespace_path = params[:target_namespace].presence || current_user.namespace_path - @target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path) + target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path) - if can?(current_user, :create_projects, @target_namespace) - @project = Gitlab::LegacyGithubImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, access_params, type: provider).execute + if can?(current_user, :create_projects, target_namespace) + project = Gitlab::LegacyGithubImport::ProjectCreator.new(repo, project_name, target_namespace, current_user, access_params, type: provider).execute + + if project.persisted? + render json: ProjectSerializer.new.represent(project) + else + render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + end else - render 'unauthorized' + render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity end end diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb index 407154e59a0..18f1d20f5a9 100644 --- a/app/controllers/import/gitlab_controller.rb +++ b/app/controllers/import/gitlab_controller.rb @@ -24,15 +24,19 @@ class Import::GitlabController < Import::BaseController end def create - @repo_id = params[:repo_id].to_i - repo = client.project(@repo_id) - @project_name = repo['name'] - @target_namespace = find_or_create_namespace(repo['namespace']['path'], client.user['username']) + repo = client.project(params[:repo_id].to_i) + target_namespace = find_or_create_namespace(repo['namespace']['path'], client.user['username']) - if current_user.can?(:create_projects, @target_namespace) - @project = Gitlab::GitlabImport::ProjectCreator.new(repo, @target_namespace, current_user, access_params).execute + if current_user.can?(:create_projects, target_namespace) + project = Gitlab::GitlabImport::ProjectCreator.new(repo, target_namespace, current_user, access_params).execute + + if project.persisted? + render json: ProjectSerializer.new.represent(project) + else + render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + end else - render 'unauthorized' + render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity end end diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb index 7d7f13ce5d5..baa19fb383d 100644 --- a/app/controllers/import/google_code_controller.rb +++ b/app/controllers/import/google_code_controller.rb @@ -85,16 +85,16 @@ class Import::GoogleCodeController < Import::BaseController end def create - @repo_id = params[:repo_id] - repo = client.repo(@repo_id) - @target_namespace = current_user.namespace - @project_name = repo.name - - namespace = @target_namespace - + repo = client.repo(params[:repo_id]) user_map = session[:google_code_user_map] - @project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, namespace, current_user, user_map).execute + project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, current_user.namespace, current_user, user_map).execute + + if project.persisted? + render json: ProjectSerializer.new.represent(project) + else + render json: { errors: project.errors.full_messages }, status: :unprocessable_entity + end end private diff --git a/app/controllers/projects/clusters/gcp_controller.rb b/app/controllers/projects/clusters/gcp_controller.rb index 94d33b91562..0f41af7d87b 100644 --- a/app/controllers/projects/clusters/gcp_controller.rb +++ b/app/controllers/projects/clusters/gcp_controller.rb @@ -39,12 +39,12 @@ class Projects::Clusters::GcpController < Projects::ApplicationController def verify_billing case google_project_billing_status - when 'true' - return - when 'false' - flash[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" } - else + when nil flash[:alert] = _('We could not verify that one of your projects on GCP has billing enabled. Please try again.') + when false + flash[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" } + when true + return end @cluster = ::Clusters::Cluster.new(create_params) @@ -81,9 +81,7 @@ class Projects::Clusters::GcpController < Projects::ApplicationController end def google_project_billing_status - Gitlab::Redis::SharedState.with do |redis| - redis.get(CheckGcpProjectBillingWorker.redis_shared_state_key_for(token_in_session)) - end + CheckGcpProjectBillingWorker.get_billing_state(token_in_session) end def token_in_session diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 515cb08f1fc..33fced99132 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -122,8 +122,7 @@ class Projects::IssuesController < Projects::ApplicationController end def referenced_merge_requests - @merge_requests = @issue.referenced_merge_requests(current_user) - @closed_by_merge_requests = @issue.closed_by_merge_requests(current_user) + @merge_requests, @closed_by_merge_requests = ::Issues::FetchReferencedMergeRequestsService.new(project, current_user).execute(issue) respond_to do |format| format.json do diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index 536f908d2c5..c77f10ef1dd 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -98,7 +98,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController json: { message: lfs_read_only_message }, - content_type: 'application/vnd.git-lfs+json', + content_type: LfsRequest::CONTENT_TYPE, status: 403 ) end diff --git a/app/controllers/projects/lfs_locks_api_controller.rb b/app/controllers/projects/lfs_locks_api_controller.rb new file mode 100644 index 00000000000..3fff0fd69ae --- /dev/null +++ b/app/controllers/projects/lfs_locks_api_controller.rb @@ -0,0 +1,70 @@ +class Projects::LfsLocksApiController < Projects::GitHttpClientController + include LfsRequest + + def create + @result = Lfs::LockFileService.new(project, user, params).execute + + render_json(@result[:lock]) + end + + def unlock + @result = Lfs::UnlockFileService.new(project, user, params).execute + + render_json(@result[:lock]) + end + + def index + @result = Lfs::LocksFinderService.new(project, user, params).execute + + render_json(@result[:locks]) + end + + def verify + @result = Lfs::LocksFinderService.new(project, user, {}).execute + + ours, theirs = split_by_owner(@result[:locks]) + + render_json({ ours: ours, theirs: theirs }, false) + end + + private + + def render_json(data, process = true) + render json: build_payload(data, process), + content_type: LfsRequest::CONTENT_TYPE, + status: @result[:http_status] + end + + def build_payload(data, process) + data = LfsFileLockSerializer.new.represent(data) if process + + return data if @result[:status] == :success + + # When the locking failed due to an existent Lock, the existent record + # is returned in `@result[:lock]` + error_payload(@result[:message], @result[:lock] ? data : {}) + end + + def error_payload(message, custom_attrs = {}) + custom_attrs.merge({ + message: message, + documentation_url: help_url + }) + end + + def split_by_owner(locks) + groups = locks.partition { |lock| lock.user_id == user.id } + + groups.map! do |records| + LfsFileLockSerializer.new.represent(records, root: false) + end + end + + def download_request? + params[:action] == 'index' + end + + def upload_request? + %w(create unlock verify).include?(params[:action]) + end +end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 8af4e379f0a..8eed957d9fe 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -50,10 +50,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo set_pipeline_variables - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37432 - Gitlab::GitalyClient.allow_n_plus_1_calls do - render - end + render end format.json do diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index 6a825137564..7eb509e2e64 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -1,60 +1,41 @@ class Projects::VariablesController < Projects::ApplicationController - before_action :variable, only: [:show, :update, :destroy] before_action :authorize_admin_build! - layout 'project_settings' - - def index - redirect_to project_settings_ci_cd_path(@project) - end - def show + respond_to do |format| + format.json do + render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) } + end + end end def update - if variable.update(variable_params) - redirect_to project_variables_path(project), - notice: 'Variable was successfully updated.' + if @project.update(variables_params) + respond_to do |format| + format.json { return render_variables } + end else - render "show" + respond_to do |format| + format.json { render_error } + end end end - def create - @variable = project.variables.create(variable_params) - .present(current_user: current_user) + private - if @variable.persisted? - redirect_to project_settings_ci_cd_path(project), - notice: 'Variable was successfully created.' - else - render "show" - end + def render_variables + render status: :ok, json: { variables: VariableSerializer.new.represent(@project.variables) } end - def destroy - if variable.destroy - redirect_to project_settings_ci_cd_path(project), - status: 302, - notice: 'Variable was successfully removed.' - else - redirect_to project_settings_ci_cd_path(project), - status: 302, - notice: 'Failed to remove the variable.' - end + def render_error + render status: :bad_request, json: @project.errors.full_messages end - private - - def variable_params - params.require(:variable).permit(*variable_params_attributes) + def variables_params + params.permit(variables_attributes: [*variable_params_attributes]) end def variable_params_attributes %i[id key value protected _destroy] end - - def variable - @variable ||= project.variables.find(params[:id]).present(current_user: current_user) - end end diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 8acefd58e77..63e5fdb1da5 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -13,10 +13,7 @@ class RootController < Dashboard::ProjectsController before_action :redirect_logged_user, if: -> { current_user.present? } def index - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37434 - Gitlab::GitalyClient.allow_n_plus_1_calls do - super - end + super end private diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 98831f5be4a..83245aadf6e 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -21,7 +21,7 @@ class IssuesFinder < IssuableFinder CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER def klass - Issue + Issue.includes(:author) end def with_confidentiality_access_check diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 12157818bcd..33ee1e975b9 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -57,7 +57,7 @@ class NotesFinder types = %w(commit issue merge_request snippet) note_relations = types.map { |t| notes_for_type(t) } note_relations.map! { |notes| search(notes) } - UnionFinder.new.find_union(note_relations, Note) + UnionFinder.new.find_union(note_relations, Note.includes(:author)) end def noteables_for_type(noteable_type) diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index c04f61de79c..33359fa1efb 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -1,14 +1,28 @@ +# Snippets Finder +# +# Used to filter Snippets collections by a set of params +# +# Arguments. +# +# current_user - The current user, nil also can be used. +# params: +# visibility (integer) - Individual snippet visibility: Public(20), internal(10) or private(0). +# project (Project) - Project related. +# author (User) - Author related. +# +# params are optional class SnippetsFinder < UnionFinder - attr_accessor :current_user, :params + include Gitlab::Allowable + attr_accessor :current_user, :params, :project def initialize(current_user, params = {}) @current_user = current_user @params = params + @project = params[:project] end def execute items = init_collection - items = by_project(items) items = by_author(items) items = by_visibility(items) @@ -18,25 +32,42 @@ class SnippetsFinder < UnionFinder private def init_collection - items = Snippet.all + if project.present? + authorized_snippets_from_project + else + authorized_snippets + end + end - accessible(items) + def authorized_snippets_from_project + if can?(current_user, :read_project_snippet, project) + if project.team.member?(current_user) + project.snippets + else + project.snippets.public_to_user(current_user) + end + else + Snippet.none + end end - def accessible(items) - segments = [] - segments << items.public_to_user(current_user) - segments << authorized_to_user(items) if current_user + def authorized_snippets + Snippet.where(feature_available_projects.or(not_project_related)).public_or_visible_to_user(current_user) + end - find_union(segments, Snippet) + def feature_available_projects + projects = Project.public_or_visible_to_user(current_user) + .with_feature_available_for_user(:snippets, current_user).select(:id) + arel_query = Arel::Nodes::SqlLiteral.new(projects.to_sql) + table[:project_id].in(arel_query) end - def authorized_to_user(items) - items.where( - 'author_id = :author_id - OR project_id IN (:project_ids)', - author_id: current_user.id, - project_ids: current_user.authorized_projects.select(:id)) + def not_project_related + table[:project_id].eq(nil) + end + + def table + Snippet.arel_table end def by_visibility(items) @@ -53,12 +84,6 @@ class SnippetsFinder < UnionFinder items.where(author_id: params[:author].id) end - def by_project(items) - return items unless params[:project] - - items.where(project_id: params[:project].id) - end - def visibility_from_scope case params[:scope].to_s when 'are_private' diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6530327698b..a6011eb9f30 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -68,18 +68,32 @@ module ApplicationHelper end end - def avatar_icon(user_or_email = nil, size = nil, scale = 2, only_path: true) - user = - if user_or_email.is_a?(User) - user_or_email - else - User.find_by_any_email(user_or_email.try(:downcase)) - end + # Takes both user and email and returns the avatar_icon by + # user (preferred) or email. + def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true) + if user + avatar_icon_for_user(user, size, scale, only_path: only_path) + elsif email + avatar_icon_for_email(email, size, scale, only_path: only_path) + else + default_avatar + end + end + + def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true) + user = User.find_by_any_email(email.try(:downcase)) + if user + avatar_icon_for_user(user, size, scale, only_path: only_path) + else + gravatar_icon(email, size, scale) + end + end + def avatar_icon_for_user(user = nil, size = nil, scale = 2, only_path: true) if user user.avatar_url(size: size, only_path: only_path) || default_avatar else - gravatar_icon(user_or_email, size, scale) + gravatar_icon(nil, size, scale) end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 7548bc30247..e293b3ef329 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -146,6 +146,7 @@ module ApplicationSettingsHelper :akismet_enabled, :authorized_keys_enabled, :auto_devops_enabled, + :auto_devops_domain, :circuitbreaker_access_retries, :circuitbreaker_check_interval, :circuitbreaker_failure_count_threshold, diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index d72457efec0..16451993e93 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -9,21 +9,28 @@ module AutoDevopsHelper end def auto_devops_warning_message(project) - missing_domain = !project.auto_devops&.has_domain? - missing_service = !project.deployment_platform&.active? - - if missing_service + if missing_auto_devops_service?(project) params = { kubernetes: link_to('Kubernetes cluster', project_clusters_path(project)) } - if missing_domain + if missing_auto_devops_domain?(project) _('Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly.') % params else _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params end - elsif missing_domain + elsif missing_auto_devops_domain?(project) _('Auto Review Apps and Auto Deploy need a domain name to work correctly.') end end + + private + + def missing_auto_devops_domain?(project) + !(project.auto_devops || project.build_auto_devops)&.has_domain? + end + + def missing_auto_devops_service?(project) + !project.deployment_platform&.active? + end end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index be11d453898..21b6c0a8ad5 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -8,10 +8,22 @@ module AvatarsHelper })) end + def user_avatar_url_for(options = {}) + if options[:url] + options[:url] + elsif options[:user] + avatar_icon_for_user(options[:user], options[:size]) + else + avatar_icon_for_email(options[:user_email], options[:size]) + end + end + def user_avatar_without_link(options = {}) avatar_size = options[:size] || 16 user_name = options[:user].try(:name) || options[:user_name] - avatar_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size) + + avatar_url = user_avatar_url_for(options.merge(size: avatar_size)) + has_tooltip = options[:has_tooltip].nil? ? true : options[:has_tooltip] data_attributes = options[:data] || {} css_class = %W[avatar s#{avatar_size}].push(*options[:css_class]) diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 0f5fc2823a3..b5ca39711bc 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -160,7 +160,7 @@ module DiffHelper end def diff_file_changed_icon(diff_file) - if diff_file.deleted_file? || diff_file.renamed_file? + if diff_file.deleted_file? "file-deletion" elsif diff_file.new_file? "file-addition" diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb index 6d303ba857d..1022070ab6f 100644 --- a/app/helpers/graph_helper.rb +++ b/app/helpers/graph_helper.rb @@ -1,10 +1,6 @@ module GraphHelper - def get_refs(repo, commit) - refs = "" - # Commit::ref_names already strips the refs/XXX from important refs (e.g. refs/heads/XXX) - # so anything leftover is internally used by GitLab - commit_refs = commit.ref_names(repo).reject { |name| name.starts_with?('refs/') } - refs << commit_refs.join(' ') + def refs(repo, commit) + refs = commit.ref_names(repo).join(' ') # append note count notes_count = @graph.notes[commit.id] diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index b78d3072186..40ca666f1bf 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -33,7 +33,7 @@ module NamespacesHelper if namespace.is_a?(Group) group_icon(namespace) else - avatar_icon(namespace.owner.email, size) + avatar_icon_for_user(namespace.owner, size) end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 6512617a02d..b97b72d62c3 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -19,7 +19,7 @@ module ProjectsHelper classes = %W[avatar avatar-inline s#{opts[:size]}] classes << opts[:avatar_class] if opts[:avatar_class] - avatar = avatar_icon(author, opts[:size]) + avatar = avatar_icon_for_user(author, opts[:size]) src = opts[:lazy_load] ? nil : avatar image_tag(src, width: opts[:size], class: classes, alt: '', "data-src" => avatar) @@ -296,6 +296,10 @@ module ProjectsHelper nav_tabs << :pipelines end + if project.external_issue_tracker + nav_tabs << :external_issue_tracker + end + tab_ability_map.each do |tab, ability| if can?(current_user, ability, project) nav_tabs << tab diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb index 9d071f2d59a..8bcced70d63 100644 --- a/app/helpers/webpack_helper.rb +++ b/app/helpers/webpack_helper.rb @@ -7,17 +7,24 @@ module WebpackHelper def webpack_controller_bundle_tags bundles = [] - segments = [*controller.controller_path.split('/'), controller.action_name].compact - until segments.empty? + action = case controller.action_name + when 'create' then 'new' + when 'update' then 'edit' + else controller.action_name + end + + route = [*controller.controller_path.split('/'), action].compact + + until route.empty? begin - asset_paths = gitlab_webpack_asset_paths("pages.#{segments.join('.')}", extension: 'js') + asset_paths = gitlab_webpack_asset_paths("pages.#{route.join('.')}", extension: 'js') bundles.unshift(*asset_paths) rescue Webpack::Rails::Manifest::EntryPointMissingError # no bundle exists for this path end - segments.pop + route.pop end javascript_include_tag(*bundles) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 80bda7f22ff..0dee6df525d 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -117,6 +117,11 @@ class ApplicationSetting < ActiveRecord::Base validates :repository_storages, presence: true validate :check_repository_storages + validates :auto_devops_domain, + allow_blank: true, + hostname: { allow_numeric_hostname: true, require_valid_tld: true }, + if: :auto_devops_enabled? + validates :enabled_git_access_protocol, inclusion: { in: %w(ssh http), allow_blank: true, allow_nil: true } diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 20534b8eed0..490edf4ac57 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -41,12 +41,41 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } + + # This convoluted mess is because we need to handle two cases of + # artifact files during the migration. And a simple OR clause + # makes it impossible to optimize. + + # Instead we want to use UNION ALL and do two carefully + # constructed disjoint queries. But Rails cannot handle UNION or + # UNION ALL queries so we do the query in a subquery and wrap it + # in an otherwise redundant WHERE IN query (IN is fine for + # non-null columns). + + # This should all be ripped out when the migration is finished and + # replaced with just the new storage to avoid the extra work. + scope :with_artifacts, ->() do - where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)', - '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id')) + old = Ci::Build.select(:id).where(%q[artifacts_file <> '']) + new = Ci::Build.select(:id).where(%q[(artifacts_file IS NULL OR artifacts_file = '') AND EXISTS (?)], + Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id')) + where('ci_builds.id IN (? UNION ALL ?)', old, new) end - scope :with_artifacts_not_expired, ->() { with_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } - scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } + + scope :with_artifacts_not_expired, ->() do + old = Ci::Build.select(:id).where(%q[artifacts_file <> '' AND (artifacts_expire_at IS NULL OR artifacts_expire_at > ?)], Time.now) + new = Ci::Build.select(:id).where(%q[(artifacts_file IS NULL OR artifacts_file = '') AND EXISTS (?)], + Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id AND (expire_at IS NULL OR expire_at > ?)', Time.now)) + where('ci_builds.id IN (? UNION ALL ?)', old, new) + end + + scope :with_expired_artifacts, ->() do + old = Ci::Build.select(:id).where(%q[artifacts_file <> '' AND artifacts_expire_at < ?], Time.now) + new = Ci::Build.select(:id).where(%q[(artifacts_file IS NULL OR artifacts_file = '') AND EXISTS (?)], + Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id AND expire_at < ?', Time.now)) + where('ci_builds.id IN (? UNION ALL ?)', old, new) + end + scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) } scope :ref_protected, -> { where(protected: true) } @@ -543,6 +572,7 @@ module Ci variables = [ { key: 'CI', value: 'true', public: true }, { key: 'GITLAB_CI', value: 'true', public: true }, + { key: 'GITLAB_FEATURES', value: project.namespace.features.join(','), public: true }, { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index f84bf132854..2abe90dd181 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -394,7 +394,7 @@ module Ci @config_processor ||= begin Gitlab::Ci::YamlProcessor.new(ci_yaml_file) - rescue Gitlab::Ci::YamlProcessor::ValidationError, Psych::SyntaxError => e + rescue Gitlab::Ci::YamlProcessor::ValidationError => e self.yaml_errors = e.message nil rescue diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index dcbb397fb78..13c784bea0d 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -2,9 +2,11 @@ module Ci class Runner < ActiveRecord::Base extend Gitlab::Ci::Model include Gitlab::SQL::Pattern + include RedisCacheable RUNNER_QUEUE_EXPIRY_TIME = 60.minutes ONLINE_CONTACT_TIMEOUT = 1.hour + UPDATE_DB_RUNNER_INFO_EVERY = 40.minutes AVAILABLE_SCOPES = %w[specific shared active paused online].freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level].freeze @@ -47,6 +49,8 @@ module Ci ref_protected: 1 } + cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at + # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. @@ -152,6 +156,18 @@ module Ci ensure_runner_queue_value == value if value.present? end + def update_cached_info(values) + values = values&.slice(:version, :revision, :platform, :architecture) || {} + values[:contacted_at] = Time.now + + cache_attributes(values) + + if persist_cached_data? + self.assign_attributes(values) + self.save if self.changed? + end + end + private def cleanup_runner_queue @@ -164,6 +180,17 @@ module Ci "runner:build_queue:#{self.token}" end + def persist_cached_data? + # Use a random threshold to prevent beating DB updates. + # It generates a distribution between [40m, 80m]. + + contacted_at_max_age = UPDATE_DB_RUNNER_INFO_EVERY + Random.rand(UPDATE_DB_RUNNER_INFO_EVERY) + + real_contacted_at = read_attribute(:contacted_at) + real_contacted_at.nil? || + (Time.now - real_contacted_at) >= contacted_at_max_age + end + def tag_constraints unless has_tags? || run_untagged? errors.add(:tags_list, diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 9b0787ee6ca..aa22e9d5d58 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -10,10 +10,26 @@ module Clusters default_value_for :version, VERSION + state_machine :status do + after_transition any => [:installed] do |application| + application.cluster.projects.each do |project| + project.find_or_initialize_service('prometheus').update(active: true) + end + end + end + def chart 'stable/prometheus' end + def service_name + 'prometheus-prometheus-server' + end + + def service_port + 80 + end + def chart_values_file "#{Rails.root}/vendor/#{name}/values.yaml" end @@ -21,6 +37,22 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InstallCommand.new(name, chart: chart, chart_values_file: chart_values_file) end + + def proxy_client + return unless kube_client + + proxy_url = kube_client.proxy_url('service', service_name, service_port, Gitlab::Kubernetes::Helm::NAMESPACE) + + # ensures headers containing auth data are appended to original k8s client options + options = kube_client.rest_client.options.merge(headers: kube_client.headers) + RestClient::Resource.new(proxy_url, options) + end + + private + + def kube_client + cluster&.kubeclient + end end end end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 5ecbd4cbceb..8678f70f78c 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -49,6 +49,9 @@ module Clusters scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } + scope :for_environment, -> (env) { where(environment_scope: ['*', '', env.slug]) } + scope :for_all_environments, -> { where(environment_scope: ['*', '']) } + def status_name if provider provider.status_name diff --git a/app/models/commit.rb b/app/models/commit.rb index 2d2d89af030..8c960389652 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -116,6 +116,10 @@ class Commit raw.id end + def project_id + project.id + end + def ==(other) other.is_a?(self.class) && raw == other.raw end diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb new file mode 100644 index 00000000000..b889f4202dc --- /dev/null +++ b/app/models/concerns/redis_cacheable.rb @@ -0,0 +1,41 @@ +module RedisCacheable + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + CACHED_ATTRIBUTES_EXPIRY_TIME = 24.hours + + class_methods do + def cached_attr_reader(*attributes) + attributes.each do |attribute| + define_method("#{attribute}") do + cached_attribute(attribute) || read_attribute(attribute) + end + end + end + end + + def cached_attribute(attribute) + (cached_attributes || {})[attribute] + end + + def cache_attributes(values) + Gitlab::Redis::SharedState.with do |redis| + redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME) + end + end + + private + + def cache_attribute_key + "cache:#{self.class.name}:#{self.id}:attributes" + end + + def cached_attributes + strong_memoize(:cached_attributes) do + Gitlab::Redis::SharedState.with do |redis| + data = redis.get(cache_attribute_key) + JSON.parse(data, symbolize_names: true) if data + end + end + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 8a79100de5a..75538ba196c 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -96,10 +96,6 @@ class Event < ActiveRecord::Base self.inheritance_column = 'action' - # "data" will be removed in 10.0 but it may be possible that JOINs happen that - # include this column, hence we're ignoring it as well. - ignore_column :data - class << self def model_name ActiveModel::Name.new(self, nil, 'event') diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb index 2aaba2e4c90..282fd7edcb7 100644 --- a/app/models/external_issue.rb +++ b/app/models/external_issue.rb @@ -39,7 +39,7 @@ class ExternalIssue end def to_reference(_from = nil, full: nil) - id + reference_link_text end def reference_link_text(from = nil) diff --git a/app/models/group.rb b/app/models/group.rb index 5b7f1b38612..75bf013ecd2 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -31,9 +31,12 @@ class Group < Namespace has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + accepts_nested_attributes_for :variables, allow_destroy: true + validate :visibility_level_allowed_by_projects validate :visibility_level_allowed_by_sub_groups validate :visibility_level_allowed_by_parent + validates :variables, variable_duplicates: true validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } diff --git a/app/models/identity.rb b/app/models/identity.rb index b3fa7d8176a..2b433e9b988 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -9,6 +9,7 @@ class Identity < ActiveRecord::Base validates :user_id, uniqueness: { scope: :provider } before_save :ensure_normalized_extern_uid, if: :extern_uid_changed? + after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider? scope :with_provider, ->(provider) { where(provider: provider) } scope :with_extern_uid, ->(provider, extern_uid) do @@ -34,4 +35,12 @@ class Identity < ActiveRecord::Base self.extern_uid = Identity.normalize_uid(self.provider, self.extern_uid) end + + def user_synced_attributes_metadata_from_provider? + user.user_synced_attributes_metadata&.provider == provider + end + + def clear_user_synced_attributes + user.user_synced_attributes_metadata&.destroy + end end diff --git a/app/models/key.rb b/app/models/key.rb index ae5769c0627..7406c98c99e 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -33,8 +33,9 @@ class Key < ActiveRecord::Base after_destroy :refresh_user_cache def key=(value) - write_attribute(:key, value.present? ? Gitlab::SSHPublicKey.sanitize(value) : nil) - + value&.delete!("\n\r") + value.strip! unless value.blank? + write_attribute(:key, value) @public_key = nil end @@ -96,7 +97,7 @@ class Key < ActiveRecord::Base def generate_fingerprint self.fingerprint = nil - return unless public_key.valid? + return unless self.key.present? self.fingerprint = public_key.fingerprint end diff --git a/app/models/lfs_file_lock.rb b/app/models/lfs_file_lock.rb new file mode 100644 index 00000000000..50bb6ca382d --- /dev/null +++ b/app/models/lfs_file_lock.rb @@ -0,0 +1,12 @@ +class LfsFileLock < ActiveRecord::Base + belongs_to :project + belongs_to :user + + validates :project_id, :user_id, :path, presence: true + + def can_be_unlocked_by?(current_user, forced = false) + return true if current_user.id == user_id + + forced && current_user.can?(:admin_project, project) + end +end diff --git a/app/models/member.rb b/app/models/member.rb index c47145667b5..2d17795e62d 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -314,7 +314,7 @@ class Member < ActiveRecord::Base end def notification_setting - @notification_setting ||= user.notification_settings_for(source) + @notification_setting ||= user&.notification_settings_for(source) end def notifiable?(type, opts = {}) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index d025062f562..5bec68ce4f6 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -158,10 +158,12 @@ class MergeRequest < ActiveRecord::Base end def rebase_in_progress? - # The source project can be deleted - return false unless source_project + strong_memoize(:rebase_in_progress) do + # The source project can be deleted + next false unless source_project - source_project.repository.rebase_in_progress?(id) + source_project.repository.rebase_in_progress?(id) + end end # Use this method whenever you need to make sure the head_pipeline is synced with the diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 69a846da9be..c1c27ccf3e5 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -290,7 +290,7 @@ class MergeRequestDiff < ActiveRecord::Base end def keep_around_commits - [repository, merge_request.source_project.repository].each do |repo| + [repository, merge_request.source_project.repository].uniq.each do |repo| repo.keep_around(start_commit_sha) repo.keep_around(head_commit_sha) repo.keep_around(base_commit_sha) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index d95489ee9f2..db274ea8172 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -243,6 +243,10 @@ class Namespace < ActiveRecord::Base all_projects.with_storage_feature(:repository).find_each(&:remove_exports) end + def features + [] + end + private def path_or_parent_changed? diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index c351d2012c6..1e0d1f9edcb 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -61,11 +61,8 @@ module Network @reserved[i] = [] end - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37436 - Gitlab::GitalyClient.allow_n_plus_1_calls do - commits_sort_by_ref.each do |commit| - place_chain(commit) - end + commits_sort_by_ref.each do |commit| + place_chain(commit) end # find parent spaces for not overlap lines diff --git a/app/models/project.rb b/app/models/project.rb index 3edb6109fb8..2ba6a863500 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -179,6 +179,7 @@ class Project < ActiveRecord::Base has_many :releases has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :lfs_objects, through: :lfs_objects_projects + has_many :lfs_file_locks has_many :project_group_links has_many :invited_groups, through: :project_group_links, source: :group has_many :pages_domains @@ -260,6 +261,7 @@ class Project < ActiveRecord::Base validates :repository_storage, presence: true, inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } } + validates :variables, variable_duplicates: { scope: :environment_scope } has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -1587,8 +1589,11 @@ class Project < ActiveRecord::Base end def protected_for?(ref) - ProtectedBranch.protected?(self, ref) || + if repository.branch_exists?(ref) + ProtectedBranch.protected?(self, ref) + elsif repository.tag_exists?(ref) ProtectedTag.protected?(self, ref) + end end def deployment_variables @@ -1600,7 +1605,7 @@ class Project < ActiveRecord::Base def auto_devops_variables return [] unless auto_devops_enabled? - auto_devops&.variables || [] + (auto_devops || build_auto_devops)&.variables end def append_or_update_attribute(name, value) diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb index 9a52edbff8e..112ed7ed434 100644 --- a/app/models/project_auto_devops.rb +++ b/app/models/project_auto_devops.rb @@ -6,13 +6,17 @@ class ProjectAutoDevops < ActiveRecord::Base validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true } + def instance_domain + Gitlab::CurrentSettings.auto_devops_domain + end + def has_domain? - domain.present? + domain.present? || instance_domain.present? end def variables variables = [] - variables << { key: 'AUTO_DEVOPS_DOMAIN', value: domain, public: true } if domain.present? + variables << { key: 'AUTO_DEVOPS_DOMAIN', value: domain.presence || instance_domain, public: true } if has_domain? variables end end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 30eafe31454..436a870b0c4 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -10,6 +10,8 @@ class JiraService < IssueTrackerService before_update :reset_password + alias_method :project_url, :url + # This is confusing, but JiraService does not really support these events. # The values here are required to display correct options in the service # configuration screen. diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index fa7b3f2bcaf..1bb576ff971 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -7,11 +7,14 @@ class PrometheusService < MonitoringService # Access to prometheus is directly through the API prop_accessor :api_url + boolean_accessor :manual_configuration - with_options presence: true, if: :activated? do + with_options presence: true, if: :manual_configuration? do validates :api_url, url: true end + before_save :synchronize_service_state! + after_save :clear_reactive_cache! def initialize_properties @@ -20,12 +23,20 @@ class PrometheusService < MonitoringService end end + def show_active_box? + false + end + + def editable? + manual_configuration? || !prometheus_installed? + end + def title 'Prometheus' end def description - s_('PrometheusService|Prometheus monitoring') + s_('PrometheusService|Time-series monitoring service') end def self.to_param @@ -33,8 +44,16 @@ class PrometheusService < MonitoringService end def fields + return [] unless editable? + [ { + type: 'checkbox', + name: 'manual_configuration', + title: s_('PrometheusService|Active'), + required: true + }, + { type: 'text', name: 'api_url', title: 'API URL', @@ -59,7 +78,7 @@ class PrometheusService < MonitoringService end def deployment_metrics(deployment) - metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &method(:rename_data_to_metrics)) + metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.environment.id, deployment.id, &method(:rename_data_to_metrics)) metrics&.merge(deployment_time: deployment.created_at.to_i) || {} end @@ -68,7 +87,7 @@ class PrometheusService < MonitoringService end def additional_deployment_metrics(deployment) - with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery.name, deployment.id, &:itself) + with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery.name, deployment.environment.id, deployment.id, &:itself) end def matched_metrics @@ -79,6 +98,9 @@ class PrometheusService < MonitoringService def calculate_reactive_cache(query_class_name, *args) return unless active? && project && !project.pending_delete? + environment_id = args.first + client = client(environment_id) + data = Kernel.const_get(query_class_name).new(client).query(*args) { success: true, @@ -89,14 +111,55 @@ class PrometheusService < MonitoringService { success: false, result: err.message } end - def client - @prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url) + def client(environment_id = nil) + if manual_configuration? + Gitlab::PrometheusClient.new(RestClient::Resource.new(api_url)) + else + cluster = cluster_with_prometheus(environment_id) + raise Gitlab::PrometheusError, "couldn't find cluster with Prometheus installed" unless cluster + + rest_client = client_from_cluster(cluster) + raise Gitlab::PrometheusError, "couldn't create proxy Prometheus client" unless rest_client + + Gitlab::PrometheusClient.new(rest_client) + end + end + + def prometheus_installed? + return false if template? + return false unless project + + project.clusters.enabled.any? { |cluster| cluster.application_prometheus&.installed? } end private + def cluster_with_prometheus(environment_id = nil) + clusters = if environment_id + ::Environment.find_by(id: environment_id).try do |env| + # sort results by descending order based on environment_scope being longer + # thus more closely matching environment slug + project.clusters.enabled.for_environment(env).sort_by { |c| c.environment_scope&.length }.reverse! + end + else + project.clusters.enabled.for_all_environments + end + + clusters&.detect { |cluster| cluster.application_prometheus&.installed? } + end + + def client_from_cluster(cluster) + cluster.application_prometheus.proxy_client + end + def rename_data_to_metrics(metrics) metrics[:metrics] = metrics.delete :data metrics end + + def synchronize_service_state! + self.active = prometheus_installed? || manual_configuration? + + true + end end diff --git a/app/models/repository.rb b/app/models/repository.rb index 3d6f8f0c305..4f754b11da4 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -164,6 +164,13 @@ class Repository commits end + # Returns a list of commits that are not present in any reference + def new_commits(newrev) + refs = ::Gitlab::Git::RevList.new(raw, newrev: newrev).new_refs + + refs.map { |sha| commit(sha.strip) } + end + # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/384 def find_commits_by_message(query, ref = nil, path = nil, limit = 1000, offset = 0) unless exists? && has_visible_content? && query.present? @@ -586,7 +593,15 @@ class Repository def license_key return unless exists? - Licensee.license(path).try(:key) + # The licensee gem creates a Rugged object from the path: + # https://github.com/benbalter/licensee/blob/v8.7.0/lib/licensee/projects/git_project.rb + begin + Licensee.license(path).try(:key) + # Normally we would rescue Rugged::Error, but that is banned by lint-rugged + # and we need to migrate this endpoint to Gitaly: + # https://gitlab.com/gitlab-org/gitaly/issues/1026 + rescue + end end cache_method :license_key diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 7c8716f8c18..a58c208279e 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -74,6 +74,27 @@ class Snippet < ActiveRecord::Base @link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/) end + # Returns a collection of snippets that are either public or visible to the + # logged in user. + # + # This method does not verify the user actually has the access to the project + # the snippet is in, so it should be only used on a relation that's already scoped + # for project access + def self.public_or_visible_to_user(user = nil) + if user + authorized = user + .project_authorizations + .select(1) + .where('project_authorizations.project_id = snippets.project_id') + + levels = Gitlab::VisibilityLevel.levels_for_user(user) + + where('EXISTS (?) OR snippets.visibility_level IN (?) or snippets.author_id = (?)', authorized, levels, user.id) + else + public_to_user + end + end + def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{id}" diff --git a/app/models/user.rb b/app/models/user.rb index 05c93d3cb17..5e84d2da805 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -249,7 +249,7 @@ class User < ActiveRecord::Base def find_for_database_authentication(warden_conditions) conditions = warden_conditions.dup if login = conditions.delete(:login) - where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase) + where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase.strip) else find_by(conditions) end @@ -551,7 +551,7 @@ class User < ActiveRecord::Base gpg_keys.each(&:update_invalid_gpg_signatures) end - # Returns the groups a user has access to + # Returns the groups a user has access to, either through a membership or a project authorization def authorized_groups union = Gitlab::SQL::Union .new([groups.select(:id), authorized_projects.select(:namespace_id)]) @@ -559,6 +559,11 @@ class User < ActiveRecord::Base Group.where("namespaces.id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection end + # 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 + end + # Returns a relation of groups the user has access to, including their parent # and child groups (recursively). def all_expanded_groups diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 1dd8f0a25a9..61a7bf02675 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -119,7 +119,6 @@ class ProjectPolicy < BasePolicy enable :create_note enable :upload_file enable :read_cycle_analytics - enable :read_project_snippet end rule { can?(:reporter_access) }.policy do diff --git a/app/presenters/ci/group_variable_presenter.rb b/app/presenters/ci/group_variable_presenter.rb index 81fea106a5c..98d68bc7a83 100644 --- a/app/presenters/ci/group_variable_presenter.rb +++ b/app/presenters/ci/group_variable_presenter.rb @@ -7,19 +7,15 @@ module Ci end def form_path - if variable.persisted? - group_variable_path(group, variable) - else - group_variables_path(group) - end + group_settings_ci_cd_path(group) end def edit_path - group_variable_path(group, variable) + group_variables_path(group) end def delete_path - group_variable_path(group, variable) + group_variables_path(group) end end end diff --git a/app/presenters/ci/variable_presenter.rb b/app/presenters/ci/variable_presenter.rb index 5d7998393a6..96159f88c59 100644 --- a/app/presenters/ci/variable_presenter.rb +++ b/app/presenters/ci/variable_presenter.rb @@ -7,19 +7,15 @@ module Ci end def form_path - if variable.persisted? - project_variable_path(project, variable) - else - project_variables_path(project) - end + project_settings_ci_cd_path(project) end def edit_path - project_variable_path(project, variable) + project_variables_path(project) end def delete_path - project_variable_path(project, variable) + project_variables_path(project) end end end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index c6806b7cc26..08ae49562c7 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -3,6 +3,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated include GitlabRoutingHelper include MarkupHelper include TreeHelper + include Gitlab::Utils::StrongMemoize presents :merge_request @@ -43,7 +44,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def revert_in_fork_path - if user_can_fork_project? && can_be_reverted?(current_user) + if user_can_fork_project? && cached_can_be_reverted? continue_params = { to: merge_request_path(merge_request), notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.", @@ -151,7 +152,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def can_revert_on_current_merge_request? - user_can_collaborate_with_project? && can_be_reverted?(current_user) + user_can_collaborate_with_project? && cached_can_be_reverted? end def can_cherry_pick_on_current_merge_request? @@ -164,6 +165,12 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated private + def cached_can_be_reverted? + strong_memoize(:can_be_reverted) do + can_be_reverted?(current_user) + end + end + def conflicts @conflicts ||= MergeRequests::Conflicts::ListService.new(merge_request) end diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb new file mode 100644 index 00000000000..62cf0b21e1e --- /dev/null +++ b/app/serializers/group_variable_entity.rb @@ -0,0 +1,7 @@ +class GroupVariableEntity < Grape::Entity + expose :id + expose :key + expose :value + + expose :protected?, as: :protected +end diff --git a/app/serializers/group_variable_serializer.rb b/app/serializers/group_variable_serializer.rb new file mode 100644 index 00000000000..8f8205924aa --- /dev/null +++ b/app/serializers/group_variable_serializer.rb @@ -0,0 +1,3 @@ +class GroupVariableSerializer < BaseSerializer + entity GroupVariableEntity +end diff --git a/app/serializers/lfs_file_lock_entity.rb b/app/serializers/lfs_file_lock_entity.rb new file mode 100644 index 00000000000..264a77adc3f --- /dev/null +++ b/app/serializers/lfs_file_lock_entity.rb @@ -0,0 +1,11 @@ +class LfsFileLockEntity < Grape::Entity + root 'locks', 'lock' + + expose :path + expose(:id) { |entity| entity.id.to_s } + expose(:created_at, as: :locked_at) { |entity| entity.created_at.to_s(:iso8601) } + + expose :owner do + expose(:name) { |entity| entity.user&.name } + end +end diff --git a/app/serializers/lfs_file_lock_serializer.rb b/app/serializers/lfs_file_lock_serializer.rb new file mode 100644 index 00000000000..ba8fb1a461d --- /dev/null +++ b/app/serializers/lfs_file_lock_serializer.rb @@ -0,0 +1,3 @@ +class LfsFileLockSerializer < BaseSerializer + entity LfsFileLockEntity +end diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb new file mode 100644 index 00000000000..74de1e79a8f --- /dev/null +++ b/app/serializers/project_serializer.rb @@ -0,0 +1,3 @@ +class ProjectSerializer < BaseSerializer + entity ProjectEntity +end diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb new file mode 100644 index 00000000000..d576745c073 --- /dev/null +++ b/app/serializers/variable_entity.rb @@ -0,0 +1,7 @@ +class VariableEntity < Grape::Entity + expose :id + expose :key + expose :value + + expose :protected?, as: :protected +end diff --git a/app/serializers/variable_serializer.rb b/app/serializers/variable_serializer.rb new file mode 100644 index 00000000000..32ae82ab51c --- /dev/null +++ b/app/serializers/variable_serializer.rb @@ -0,0 +1,3 @@ +class VariableSerializer < BaseSerializer + entity VariableEntity +end diff --git a/app/services/ci/ensure_stage_service.rb b/app/services/ci/ensure_stage_service.rb index dc2f49e8db1..87f19b333de 100644 --- a/app/services/ci/ensure_stage_service.rb +++ b/app/services/ci/ensure_stage_service.rb @@ -7,6 +7,8 @@ module Ci # stage. # class EnsureStageService < BaseService + EnsureStageError = Class.new(StandardError) + def execute(build) @build = build @@ -22,8 +24,16 @@ module Ci private - def ensure_stage + def ensure_stage(attempts: 2) find_stage || create_stage + rescue ActiveRecord::RecordNotUnique + retry if (attempts -= 1) > 0 + + raise EnsureStageError, <<~EOS + We failed to find or create a unique pipeline stage after 2 retries. + This should never happen and is most likely the result of a bug in + the database load balancing code. + EOS end def find_stage diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index c552193e66b..6128b2a8fbb 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -1,7 +1,7 @@ module Ci class RetryBuildService < ::BaseService CLONE_ACCESSORS = %i[pipeline project ref tag options commands name - allow_failure stage_id stage stage_idx trigger_request + allow_failure stage stage_id stage_idx trigger_request yaml_variables when environment coverage_regex description tag_list protected].freeze diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb index cb235a85daf..c98d1e3c540 100644 --- a/app/services/delete_merged_branches_service.rb +++ b/app/services/delete_merged_branches_service.rb @@ -6,18 +6,14 @@ class DeleteMergedBranchesService < BaseService def execute raise Gitlab::Access::AccessDeniedError unless can?(current_user, :push_code, project) - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37438 - Gitlab::GitalyClient.allow_n_plus_1_calls do - branches = project.repository.branch_names - branches = branches.select { |branch| project.repository.merged_to_root_ref?(branch) } - # Prevent deletion of branches relevant to open merge requests - branches -= merge_request_branch_names - # Prevent deletion of protected branches - branches = branches.reject { |branch| ProtectedBranch.protected?(project, branch) } + branches = project.repository.merged_branch_names + # Prevent deletion of branches relevant to open merge requests + branches -= merge_request_branch_names + # Prevent deletion of protected branches + branches = branches.reject { |branch| ProtectedBranch.protected?(project, branch) } - branches.each do |branch| - DeleteBranchService.new(project, current_user).execute(branch) - end + branches.each do |branch| + DeleteBranchService.new(project, current_user).execute(branch) end end diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb index d6f08fc3cce..5c337a9faa5 100644 --- a/app/services/groups/nested_create_service.rb +++ b/app/services/groups/nested_create_service.rb @@ -11,8 +11,8 @@ module Groups def execute return nil unless group_path - if group = Group.find_by_full_path(group_path) - return group + if namespace = namespace_or_group(group_path) + return namespace end if group_path.include?('/') && !Group.supports_nested_groups? @@ -40,10 +40,14 @@ module Groups ) new_params[:visibility_level] ||= Gitlab::CurrentSettings.current_application_settings.default_group_visibility - last_group = Group.find_by_full_path(partial_path) || Groups::CreateService.new(current_user, new_params).execute + last_group = namespace_or_group(partial_path) || Groups::CreateService.new(current_user, new_params).execute end last_group end + + def namespace_or_group(group_path) + Namespace.find_by_full_path(group_path) + end end end diff --git a/app/services/issues/fetch_referenced_merge_requests_service.rb b/app/services/issues/fetch_referenced_merge_requests_service.rb new file mode 100644 index 00000000000..39c8ded9df4 --- /dev/null +++ b/app/services/issues/fetch_referenced_merge_requests_service.rb @@ -0,0 +1,12 @@ +module Issues + class FetchReferencedMergeRequestsService < Issues::BaseService + def execute(issue) + referenced_merge_requests = issue.referenced_merge_requests(current_user) + referenced_merge_requests = Gitlab::IssuableSorter.sort(project, referenced_merge_requests) { |i| i.iid.to_s } + closed_by_merge_requests = issue.closed_by_merge_requests(current_user) + closed_by_merge_requests = Gitlab::IssuableSorter.sort(project, closed_by_merge_requests) { |i| i.iid.to_s } + + [referenced_merge_requests, closed_by_merge_requests] + end + end +end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 2f511ab44b7..299b9c6215f 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -19,19 +19,10 @@ module Issues # on rewriting notes (unfolding references) # ActiveRecord::Base.transaction do - # New issue tasks - # @new_issue = create_new_issue - rewrite_notes - rewrite_issue_award_emoji - add_note_moved_from - - # Old issue tasks - # - add_note_moved_to - close_issue - mark_as_moved + update_new_issue + update_old_issue end notify_participants @@ -41,6 +32,18 @@ module Issues private + def update_new_issue + rewrite_notes + rewrite_issue_award_emoji + add_note_moved_from + end + + def update_old_issue + add_note_moved_to + close_issue + mark_as_moved + end + def create_new_issue new_params = { id: nil, iid: nil, label_ids: cloneable_label_ids, milestone_id: cloneable_milestone_id, diff --git a/app/services/lfs/lock_file_service.rb b/app/services/lfs/lock_file_service.rb new file mode 100644 index 00000000000..bbe10f84ef4 --- /dev/null +++ b/app/services/lfs/lock_file_service.rb @@ -0,0 +1,39 @@ +module Lfs + class LockFileService < BaseService + def execute + unless can?(current_user, :push_code, project) + raise Gitlab::GitAccess::UnauthorizedError, 'You have no permissions' + end + + create_lock! + rescue ActiveRecord::RecordNotUnique + error('already locked', 409, current_lock) + rescue Gitlab::GitAccess::UnauthorizedError => ex + error(ex.message, 403) + rescue => ex + error(ex.message, 500) + end + + private + + def current_lock + project.lfs_file_locks.find_by(path: params[:path]) + end + + def create_lock! + lock = project.lfs_file_locks.create!(user: current_user, + path: params[:path]) + + success(http_status: 201, lock: lock) + end + + def error(message, http_status, lock = nil) + { + status: :error, + message: message, + http_status: http_status, + lock: lock + } + end + end +end diff --git a/app/services/lfs/locks_finder_service.rb b/app/services/lfs/locks_finder_service.rb new file mode 100644 index 00000000000..13c6cc6f81c --- /dev/null +++ b/app/services/lfs/locks_finder_service.rb @@ -0,0 +1,17 @@ +module Lfs + class LocksFinderService < BaseService + def execute + success(locks: find_locks) + rescue => ex + error(ex.message, 500) + end + + private + + def find_locks + options = params.slice(:id, :path).compact.symbolize_keys + + project.lfs_file_locks.where(options) + end + end +end diff --git a/app/services/lfs/unlock_file_service.rb b/app/services/lfs/unlock_file_service.rb new file mode 100644 index 00000000000..6c93dc69bb0 --- /dev/null +++ b/app/services/lfs/unlock_file_service.rb @@ -0,0 +1,43 @@ +module Lfs + class UnlockFileService < BaseService + def execute + unless can?(current_user, :push_code, project) + raise Gitlab::GitAccess::UnauthorizedError, 'You have no permissions' + end + + unlock_file + rescue Gitlab::GitAccess::UnauthorizedError => ex + error(ex.message, 403) + rescue ActiveRecord::RecordNotFound + error('Lock not found', 404) + rescue => ex + error(ex.message, 500) + end + + private + + def unlock_file + forced = params[:force] == true + + if lock.can_be_unlocked_by?(current_user, forced) + lock.destroy! + + success(lock: lock, http_status: :ok) + elsif forced + error('You must have master access to force delete a lock', 403) + else + error("#{lock.path} is locked by GitLab User #{lock.user_id}", 403) + end + end + + def lock + return @lock if defined?(@lock) + + @lock = if params[:id].present? + project.lfs_file_locks.find(params[:id]) + elsif params[:path].present? + project.lfs_file_locks.find_by!(path: params[:path]) + end + end + end +end diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb index de3a252d6c6..2e89f00dad8 100644 --- a/app/services/members/authorized_destroy_service.rb +++ b/app/services/members/authorized_destroy_service.rb @@ -11,6 +11,7 @@ module Members Member.transaction do unassign_issues_and_merge_requests(member) unless member.invite? + member.notification_setting&.destroy member.destroy end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 2ae855d078b..4b186d93772 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -134,7 +134,7 @@ module MergeRequests end def append_closes_description - return unless issue + return unless issue&.to_reference.present? closes_issue = "Closes #{issue.to_reference}" @@ -160,10 +160,12 @@ module MergeRequests merge_request.title = "Resolve \"#{issue.title}\"" if issue.is_a?(Issue) - unless merge_request.title + return if merge_request.title.present? + + if issue_iid.present? + merge_request.title = "Resolve #{issue.to_reference}" branch_title = source_branch.downcase.remove(issue_iid.downcase).titleize.humanize - merge_request.title = "Resolve #{issue_iid}" - merge_request.title += " \"#{branch_title}\"" unless branch_title.empty? + merge_request.title += " \"#{branch_title}\"" if branch_title.present? end end diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 634bf3bd690..a18b1c90765 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -9,10 +9,7 @@ module MergeRequests merge_request.source_branch = params[:source_branch] merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch) - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37439 - Gitlab::GitalyClient.allow_n_plus_1_calls do - create(merge_request) - end + create(merge_request) end def before_create(merge_request) diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb index 87d9ed7a0e6..a549cfbabea 100644 --- a/app/services/projects/create_from_template_service.rb +++ b/app/services/projects/create_from_template_service.rb @@ -5,11 +5,15 @@ module Projects end def execute - params[:file] = Gitlab::ProjectTemplate.find(params[:template_name]).file + template_name = params.delete(:template_name) + file = Gitlab::ProjectTemplate.find(template_name).file + + params[:file] = file + + GitlabProjectsImportService.new(current_user, params).execute - GitlabProjectsImportService.new(@current_user, @params).execute ensure - params[:file]&.close + file&.close end end end diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb index a3d7f5cbed5..a68ecb4abe1 100644 --- a/app/services/projects/gitlab_projects_import_service.rb +++ b/app/services/projects/gitlab_projects_import_service.rb @@ -11,12 +11,14 @@ module Projects def execute FileUtils.mkdir_p(File.dirname(import_upload_path)) + + file = params.delete(:file) FileUtils.copy_entry(file.path, import_upload_path) - Gitlab::ImportExport::ProjectCreator.new(params[:namespace_id], - current_user, - import_upload_path, - params[:path]).execute + params[:import_type] = 'gitlab_project' + params[:import_source] = import_upload_path + + ::Projects::CreateService.new(current_user, params).execute end private @@ -28,9 +30,5 @@ module Projects def tmp_filename SecureRandom.hex end - - def file - params[:file] - end end end diff --git a/app/validators/variable_duplicates_validator.rb b/app/validators/variable_duplicates_validator.rb index 8a9d8892e9b..4bfa3c45303 100644 --- a/app/validators/variable_duplicates_validator.rb +++ b/app/validators/variable_duplicates_validator.rb @@ -1,13 +1,28 @@ # VariableDuplicatesValidator # -# This validtor is designed for especially the following condition +# This validator is designed for especially the following condition # - Use `accepts_nested_attributes_for :xxx` in a parent model # - Use `validates :xxx, uniqueness: { scope: :xxx_id }` in a child model class VariableDuplicatesValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - duplicates = value.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first) + if options[:scope] + scoped = value.group_by do |variable| + Array(options[:scope]).map { |attr| variable.send(attr) } # rubocop:disable GitlabSecurity/PublicSend + end + scoped.each_value { |scope| validate_duplicates(record, attribute, scope) } + else + validate_duplicates(record, attribute, value) + end + end + + private + + def validate_duplicates(record, attribute, values) + duplicates = values.reject(&:marked_for_destruction?).group_by(&:key).select { |_, v| v.many? }.map(&:first) if duplicates.any? - record.errors.add(attribute, "Duplicate variables: #{duplicates.join(", ")}") + error_message = "have duplicate values (#{duplicates.join(", ")})" + error_message += " for #{values.first.send(options[:scope])} scope" if options[:scope] # rubocop:disable GitlabSecurity/PublicSend + record.errors.add(attribute, error_message) end end end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index fb5e6f337a7..60f12030f98 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -249,7 +249,12 @@ .help-block It will automatically build, test, and deploy applications based on a predefined CI/CD configuration = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md') - + .form-group + = f.label :auto_devops_domain, class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com' + .help-block + = s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.") .form-group .col-sm-offset-2.col-sm-10 .checkbox diff --git a/app/views/admin/jobs/index.html.haml b/app/views/admin/jobs/index.html.haml index a01676d82a8..4e3e2f7a475 100644 --- a/app/views/admin/jobs/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -7,10 +7,9 @@ - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope - .nav-controls - - if @all_builds.running_or_pending.any? - #stop-jobs-modal - + - if @all_builds.running_or_pending.any? + #stop-jobs-modal + .nav-controls %button#stop-jobs-button.btn.btn-danger{ data: { toggle: 'modal', target: '#stop-jobs-modal', url: cancel_all_admin_jobs_path } } diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index c69c2761189..b5d7b889504 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -5,7 +5,12 @@ %li.project-row{ class: ('no-description' if project.description.blank?) } .controls = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn" - = link_to 'Delete', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-remove" + %button.delete-project-button.btn.btn-danger{ data: { toggle: 'modal', + target: '#delete-project-modal', + delete_project_url: project_path(project), + project_name: project.name }, type: 'button' } + = s_('AdminProjects|Delete') + .stats %span.badge = storage_counter(project.statistics.storage_size) @@ -31,3 +36,5 @@ = paginate @projects, theme: 'gitlab' - else .nothing-here-block No projects found + + #delete-project-modal diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index ca6e43e091c..bbfeceff5b9 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -1,6 +1,6 @@ %li.flex-row .user-avatar - = image_tag avatar_icon(user), class: "avatar", alt: '' + = image_tag avatar_icon_for_user(user), class: "avatar", alt: '' .row-main-content .user-name.row-title.str-truncated-100 = link_to user.name, [:admin, user] @@ -38,12 +38,19 @@ %li.divider - if user.can_be_removed? %li - = link_to 'Remove user', admin_user_path(user), - data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, - class: 'text-danger', - method: :delete + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', + target: '#delete-user-modal', + delete_user_url: admin_user_path(user), + block_user_url: block_admin_user_path(user), + username: user.name, + delete_contributions: 'false' }, type: 'button' } + = s_('AdminUsers|Delete user') + %li - = link_to 'Remove user and contributions', admin_user_path(user, hard_delete: true), - data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and comments authored by this user, and groups owned solely by them, will also be removed! Are you sure?" }, - class: 'text-danger', - method: :delete + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', + target: '#delete-user-modal', + delete_user_url: admin_user_path(user, hard_delete: true), + block_user_url: block_admin_user_path(user), + username: user.name, + delete_contributions: 'true' }, type: 'button' } + = s_('AdminUsers|Delete user and contributions') diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 38ce1564eff..0ef4b71f4fe 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -76,3 +76,6 @@ = render partial: 'admin/users/user', collection: @users = paginate @users, theme: "gitlab" + +#delete-user-modal + diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 63c5a15de1c..ec3be869797 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -10,7 +10,7 @@ = @user.name %ul.well-list %li - = image_tag avatar_icon(@user, 60), class: "avatar s60" + = image_tag avatar_icon_for_user(@user, 60), class: "avatar s60" %li %span.light Profile page: %strong @@ -172,13 +172,19 @@ .panel.panel-danger .panel-heading - Remove user + = s_('AdminUsers|Delete user') .panel-body - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting a user has the following effects: = render 'users/deletion_guidance', user: @user %br - = link_to 'Remove user', admin_user_path(@user), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', + target: '#delete-user-modal', + delete_user_url: admin_user_path(@user), + block_user_url: block_admin_user_path(@user), + username: @user.name, + delete_contributions: 'false' }, type: 'button' } + = s_('AdminUsers|Delete user') - else - if @user.solo_owned_groups.present? %p @@ -192,7 +198,7 @@ .panel.panel-danger .panel-heading - Remove user and contributions + = s_('AdminUsers|Delete user and contributions') .panel-body - if can?(current_user, :destroy_user, @user) %p @@ -204,7 +210,15 @@ the user, and projects in them, will also be removed. Commits to other projects are unaffected. %br - = link_to 'Remove user and contributions', admin_user_path(@user, hard_delete: true), data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" + %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', + target: '#delete-user-modal', + delete_user_url: admin_user_path(@user, hard_delete: true), + block_user_url: block_admin_user_path(@user), + username: @user.name, + delete_contributions: 'true' }, type: 'button' } + = s_('AdminUsers|Delete user and contributions') - else %p You don't have access to delete this user. + + #delete-user-modal diff --git a/app/views/ci/lints/show.html.haml b/app/views/ci/lints/show.html.haml index e6408f35201..3c0881caa06 100644 --- a/app/views/ci/lints/show.html.haml +++ b/app/views/ci/lints/show.html.haml @@ -18,6 +18,8 @@ .col-sm-12 .pull-left.prepend-top-10 = submit_tag('Validate', class: 'btn btn-success submit-yml') + .pull-right.prepend-top-10 + = button_tag('Clear', type: 'button', class: 'btn btn-default clear-yml') .row.prepend-top-20 .col-sm-12 diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index fbfe3e56588..d355e7799df 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,3 +1 @@ -%p.append-bottom-default - 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. += _('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.') diff --git a/app/views/ci/variables/_form.html.haml b/app/views/ci/variables/_form.html.haml deleted file mode 100644 index eebd0955c80..00000000000 --- a/app/views/ci/variables/_form.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -= form_for @variable, as: :variable, url: @variable.form_path do |f| - = form_errors(@variable) - - .form-group - = f.label :key, "Key", class: "label-light" - = f.text_field :key, class: "form-control", placeholder: @variable.placeholder, required: true - .form-group - = f.label :value, "Value", class: "label-light" - = f.text_area :value, class: "form-control", placeholder: @variable.placeholder - .form-group - .checkbox - = f.label :protected do - = f.check_box :protected - %strong Protected - .help-block - This variable will be passed only to pipelines running on protected branches and tags - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'protected-secret-variables'), target: '_blank' - - = f.submit btn_text, class: "btn btn-save" diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 6e399fc7392..e402801a776 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -1,16 +1,20 @@ -.row.prepend-top-default.append-bottom-default - .col-lg-12 - %h5.prepend-top-0 - Add a variable - = render "ci/variables/form", btn_text: "Add new variable" - %hr - %h5.prepend-top-0 - Your variables (#{@variables.size}) - - if @variables.empty? - %p.settings-message.text-center.append-bottom-0 - No variables found, add one with the form above. - - else - .js-secret-variable-table - = render "ci/variables/table" - %button.btn.btn-info.js-secret-value-reveal-button{ data: { secret_reveal_status: 'false' } } +- save_endpoint = local_assigns.fetch(:save_endpoint, nil) + +.row + .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } } + .hide.alert.alert-danger.js-ci-variable-error-box + + %ul.ci-variable-list + - @variables.each.each do |variable| + = render 'ci/variables/variable_row', form_field: 'variables', variable: variable + = render 'ci/variables/variable_row', form_field: 'variables' + .prepend-top-20 + %button.btn.btn-success.js-secret-variables-save-button{ type: 'button' } + %span.hide.js-secret-variables-save-loading-icon + = icon('spinner spin') + = _('Save variables') + %button.btn.btn-info.btn-inverted.prepend-left-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@variables.size == 0}" } } + - if @variables.size == 0 + = n_('Hide value', 'Hide values', @variables.size) + - else = n_('Reveal value', 'Reveal values', @variables.size) diff --git a/app/views/ci/variables/_show.html.haml b/app/views/ci/variables/_show.html.haml deleted file mode 100644 index 6d75ae96124..00000000000 --- a/app/views/ci/variables/_show.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- page_title "Variables" - -.row.prepend-top-default.append-bottom-default - .col-lg-3 - = render "ci/variables/content" - .col-lg-9 - %h4.prepend-top-0 - Update variable - = render "ci/variables/form", btn_text: "Save variable" diff --git a/app/views/ci/variables/_table.html.haml b/app/views/ci/variables/_table.html.haml deleted file mode 100644 index 2298930d0c7..00000000000 --- a/app/views/ci/variables/_table.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -.table-responsive.variables-table - %table.table - %colgroup - %col - %col - %col - %col{ width: 100 } - %thead - %th Key - %th Value - %th Protected - %th - %tbody - - @variables.each do |variable| - - if variable.id? - %tr - %td.variable-key= variable.key - %td.variable-value - %span.js-secret-value-placeholder - = '*' * 6 - %span.hide.js-secret-value - = variable.value - %td.variable-protected= Gitlab::Utils.boolean_to_yes_no(variable.protected) - %td.variable-menu - = link_to variable.edit_path, class: "btn btn-transparent btn-variable-edit" do - %span.sr-only - Update - = icon("pencil") - = link_to variable.delete_path, class: "btn btn-transparent btn-variable-delete", method: :delete, data: { confirm: "Are you sure?" } do - %span.sr-only - Remove - = icon("trash") diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index 495a55660cb..15201780451 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -5,7 +5,7 @@ - id = variable&.id - key = variable&.key - value = variable&.value -- is_protected = variable && !only_key_value ? variable.protected : true +- is_protected = variable && !only_key_value ? variable.protected : false - id_input_name = "#{form_field}[variables_attributes][][id]" - destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 205320ed87c..8b9fa3d6b05 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -3,7 +3,7 @@ .timeline-entry-inner .timeline-icon = link_to user_path(discussion.author) do - = image_tag avatar_icon(discussion.author), class: "avatar s40" + = image_tag avatar_icon_for_user(discussion.author), class: "avatar s40" .timeline-content .discussion.js-toggle-container{ data: { discussion_id: discussion.id } } .discussion-header diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder index 38741fe6662..d56234e6c1a 100644 --- a/app/views/events/_event.atom.builder +++ b/app/views/events/_event.atom.builder @@ -10,7 +10,7 @@ xml.entry do # eager-loaded. This allows us to re-use the user object's Email address, # instead of having to run additional queries to figure out what Email to use # for the avatar. - xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author)) + xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(event.author)) xml.author do xml.username event.author_username diff --git a/app/views/groups/labels/new.html.haml b/app/views/groups/labels/new.html.haml index ae240490bbd..538c353cf2d 100644 --- a/app/views/groups/labels/new.html.haml +++ b/app/views/groups/labels/new.html.haml @@ -1,6 +1,5 @@ - breadcrumb_title "Labels" - page_title 'New Label' -- header_title group_title(@group, 'Labels', group_labels_path(@group)) %h3.page-title New Label diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 472da2a6a72..dd82922ec55 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -1,4 +1,11 @@ - breadcrumb_title "CI / CD Settings" - page_title "CI / CD" -= render 'ci/variables/index' +%h4 + = _('Secret variables') + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' + +%p + = render "ci/variables/content" + += render 'ci/variables/index', save_endpoint: group_variables_path diff --git a/app/views/groups/variables/show.html.haml b/app/views/groups/variables/show.html.haml deleted file mode 100644 index df533952b76..00000000000 --- a/app/views/groups/variables/show.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render 'ci/variables/show' diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 445f0dffbcc..1c4d67a8d2c 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -68,7 +68,7 @@ .example .cover-block .avatar-holder - = image_tag avatar_icon('admin@example.com', 90), class: "avatar s90", alt: '' + = image_tag avatar_icon_for_email('admin@example.com', 90), class: "avatar s90", alt: '' .cover-title John Smith diff --git a/app/views/import/base/create.js.haml b/app/views/import/base/create.js.haml deleted file mode 100644 index 4dc3a4a0acf..00000000000 --- a/app/views/import/base/create.js.haml +++ /dev/null @@ -1,13 +0,0 @@ -- if @project.persisted? - :plain - job = $("tr#repo_#{@repo_id}") - job.attr("id", "project_#{@project.id}") - target_field = job.find(".import-target") - target_field.empty() - target_field.append('#{link_to @project.full_path, project_path(@project)}') - $("table.import-jobs tbody").prepend(job) - job.addClass("active").find(".import-actions").html("<i class='fa fa-spinner fa-spin'></i> started") -- else - :plain - job = $("tr#repo_#{@repo_id}") - job.find(".import-actions").html("<i class='fa fa-exclamation-circle'></i> Error saving project: #{escape_javascript(h(@project.errors.full_messages.join(',')))}") diff --git a/app/views/import/base/unauthorized.js.haml b/app/views/import/base/unauthorized.js.haml deleted file mode 100644 index ada5f99f4e2..00000000000 --- a/app/views/import/base/unauthorized.js.haml +++ /dev/null @@ -1,14 +0,0 @@ -:plain - tr = $("tr#repo_#{@repo_id}") - target_field = tr.find(".import-target") - import_button = tr.find(".btn-import") - origin_target = target_field.text() - project_name = "#{@project_name}" - origin_namespace = "#{@target_namespace.full_path}" - target_field.empty() - target_field.append("<p class='alert alert-danger'>This namespace has already been taken! Please choose another one.</p>") - target_field.append("<input type='text' name='target_namespace' />") - target_field.append("/" + project_name) - target_field.data("project_name", project_name) - target_field.find('input').prop("value", origin_namespace) - import_button.enable().removeClass('is-loading') diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 0c113c08526..21cf6d0dd65 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -3,7 +3,7 @@ xml.entry do xml.link href: project_issue_url(issue.project, issue) xml.title truncate(issue.title, length: 80) xml.updated issue.updated_at.xmlschema - xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(issue.author_email)) + xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(issue.author)) xml.author do xml.name issue.author_name diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index eba9cd253bb..f0963cf9da8 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,7 +1,7 @@ .layout-page{ class: page_with_sidebar_class } - if defined?(nav) && nav = render "layouts/nav/sidebar/#{nav}" - .content-wrapper + .content-wrapper{ class: "#{@content_wrapper_class}" } = render 'shared/outdated_browser' .mobile-overlay .alert-wrapper diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index e7fc83a8d04..1d00ae928f6 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -45,7 +45,7 @@ = todos_count_format(todos_pending_count) %li.header-user.dropdown = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do - = image_tag avatar_icon(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar" + = 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') .dropdown-menu-nav.dropdown-menu-align-right %ul diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 2b98cb9de99..059571f795f 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -127,6 +127,19 @@ = link_to project_milestones_path(@project), title: 'Milestones' do %span Milestones + - if project_nav_tab? :external_issue_tracker + = nav_link do + - issue_tracker = @project.external_issue_tracker + = link_to issue_tracker.issue_tracker_path, class: 'shortcuts-external_tracker' do + .nav-icon-container + = sprite_icon('issue-external') + %span.nav-item-name + = issue_tracker.title + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(html_options: { class: "fly-out-top-item" } ) do + = link_to issue_tracker.issue_tracker_path do + %strong.fly-out-top-item-name + = issue_tracker.title - if project_nav_tab? :merge_requests = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml index 8eb3f2d5192..38dab104eb5 100644 --- a/app/views/notify/pipeline_failed_email.html.haml +++ b/app/views/notify/pipeline_failed_email.html.haml @@ -60,7 +60,7 @@ %tbody %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } - %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ + %img.avatar{ height: "24", src: avatar_icon_for(commit.author, commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } - if commit.author %a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" } @@ -76,7 +76,7 @@ %tbody %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } - %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ + %img.avatar{ height: "24", src: avatar_icon_for(commit.committer, commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } - if commit.committer %a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" } @@ -100,7 +100,7 @@ triggered by - if @pipeline.user %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" } - %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ + %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" } %a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" } = @pipeline.user.name diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml index bae37292d62..7b06e8afa0b 100644 --- a/app/views/notify/pipeline_success_email.html.haml +++ b/app/views/notify/pipeline_success_email.html.haml @@ -60,7 +60,7 @@ %tbody %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } - %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ + %img.avatar{ height: "24", src: avatar_icon_for(commit.author, commit.author_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } - if commit.author %a.muted{ href: user_url(commit.author), style: "color:#333333;text-decoration:none;" } @@ -76,7 +76,7 @@ %tbody %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } - %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ + %img.avatar{ height: "24", src: avatar_icon_for(commit.committer, commit.committer_email, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } - if commit.committer %a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" } @@ -100,7 +100,7 @@ triggered by - if @pipeline.user %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" } - %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ + %img.avatar{ height: "24", src: avatar_icon_for_user(@pipeline.user, 24, only_path: false), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" } %a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" } = @pipeline.user.name diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 5c76d2d8f51..110736dc557 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -20,8 +20,8 @@ or change it at #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host} .col-lg-8 .clearfix.avatar-image.append-bottom-default - = link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do - = image_tag avatar_icon(@user, 160), alt: '', class: 'avatar s160' + = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do + = image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160' %h5.prepend-top-0= _("Upload new avatar") .prepend-top-5.append-bottom-10 %button.btn.js-choose-user-avatar-button{ type: 'button' }= _("Choose file...") diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index a60afde06d2..2b1b23ba198 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -14,7 +14,8 @@ cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason, help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), - ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address') } } + ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address'), + manage_prometheus_path: edit_project_service_path(@cluster.project, 'prometheus') } } .js-cluster-application-notice .flash-container diff --git a/app/views/projects/commits/_commit.atom.builder b/app/views/projects/commits/_commit.atom.builder index 04914888763..50f7e7a3a33 100644 --- a/app/views/projects/commits/_commit.atom.builder +++ b/app/views/projects/commits/_commit.atom.builder @@ -3,7 +3,7 @@ xml.entry do xml.link href: project_commit_url(@project, id: commit.id) xml.title truncate(commit.title, length: 80, escape: false) xml.updated commit.committed_date.xmlschema - xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(commit.author_email)) + xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_email(commit.author_email)) xml.author do |author| xml.name commit.author_name diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 64259669c19..6ff7bcae54f 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -51,7 +51,7 @@ - if commit.status(ref) = render_commit_status(commit, ref: ref) - #commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id) } } + .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id) } } = link_to commit.short_id, link, class: "commit-sha btn btn-transparent btn-link" = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard")) = link_to_browse_code(project, commit) diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index e16d132f869..0931ceb1512 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -58,7 +58,7 @@ - if @project.avatar? %hr = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted" - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: "btn btn-success js-btn-save-general-project-settings" %section.settings.sharing-permissions.no-animate{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 5257b42548e..10812f67cbe 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -13,6 +13,7 @@ = link_to @environment.name, environment_path(@environment) #prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'), + "clusters-path": project_clusters_path(@project), "documentation-path": help_page_path('administration/monitoring/prometheus/index.md'), "empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'), "empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'), diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index b46a1207889..efdb494e1ae 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -5,7 +5,7 @@ .repo-charts{ class: container_class } %h4.sub-header - Programming languages used in this repository + = _("Programming languages used in this repository") .row .col-md-4 @@ -28,9 +28,11 @@ .row.tree-ref-header .col-md-6 %h4 - Commit statistics for - %strong= @ref - #{@commits_graph.start_date.strftime('%b %d')} - #{@commits_graph.end_date.strftime('%b %d')} + - start_time = capture do + #{@commits_graph.start_date.strftime('%b %d')} + - end_time = capture do + #{@commits_graph.end_date.strftime('%b %d')} + = (_("Commit statistics for %{ref} %{start_time} - %{end_time}") % { ref: "<strong>#{@ref}</strong>", start_time: start_time, end_time: end_time }).html_safe .col-md-6 .tree-ref-container @@ -43,32 +45,35 @@ .col-md-6 %ul.commit-stats %li - Total: - %strong #{@commits_graph.commits.size} commits + - total = capture do + #{@commits_graph.commits.size} + = (_("Total: %{total}") % { total: "<strong>#{total} commits</strong>" }).html_safe %li - Average per day: - %strong #{@commits_graph.commit_per_day} commits + - average = capture do + #{@commits_graph.commit_per_day} + = (_("Average per day: %{average}") % { average: "<strong>#{average} commits</strong>" }).html_safe %li - Authors: - %strong= @commits_graph.authors + - authors = capture do + #{@commits_graph.authors} + = (_("Authors: %{authors}") % { authors: "<strong>#{authors}</strong>" }).html_safe .col-md-6 %div %p.slead - Commits per day of month + = _("Commits per day of month") %canvas#month-chart .row .col-md-6 .col-md-6 %div %p.slead - Commits per weekday + = _("Commits per weekday") %canvas#weekday-chart .row .col-md-6 .col-md-6 %div %p.slead - Commits per day hour (UTC) + = _("Commits per day hour (UTC)") %canvas#hour-chart %script#projectChartData{ type: "application/json" } diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 9779c1985d5..11b5e02f1e0 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -12,6 +12,8 @@ markdown_docs_path: help_page_path('user/markdown'), quick_actions_docs_path: help_page_path('user/project/quick_actions'), notes_path: notes_url, + close_issue_path: issue_path(@issue, issue: { state_event: :close }, format: 'json'), + reopen_issue_path: issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), last_fetched_at: Time.now.to_i, noteable_data: serialize_issuable(@issue), current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 37b00a14fc6..36e24037214 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -21,30 +21,33 @@ %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } } = icon('caret-down') - %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } } - - if can_create_merge_request - %li.create-item.droplab-item-selected.droplab-item-ignore-hiding{ role: 'button', data: { value: 'create-mr', text: 'Create merge request' } } - .menu-item.droplab-item-ignore-hiding - .icon-container.droplab-item-ignore-hiding= icon('check') - .description.droplab-item-ignore-hiding Create merge request and branch - - %li.create-item.droplab-item-ignore-hiding{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: 'Create branch' } } - .menu-item.droplab-item-ignore-hiding - .icon-container.droplab-item-ignore-hiding= icon('check') - .description.droplab-item-ignore-hiding Create branch - %li.divider - - %li.droplab-item-ignore - Branch name - %input.js-branch-name.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" } - %span.js-branch-message.branch-message.droplab-item-ignore - - %li.droplab-item-ignore - Source (branch or tag) - %input.js-ref.ref.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } } - %span.js-ref-message.ref-message.droplab-item-ignore - - %li.droplab-item-ignore - %button.btn.btn-success.js-create-target.droplab-item-ignore{ type: 'button', data: { action: 'create-mr' } } - Create merge request - + .droplab-dropdown + %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } } + - if can_create_merge_request + %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: _('Create merge request') } } + .menu-item + = icon('check', class: 'icon') + = _('Create merge request and branch') + + %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } } + .menu-item + = icon('check', class: 'icon') + = _('Create branch') + %li.divider.droplab-item-ignore + + %li.droplab-item-ignore.prepend-left-8.append-right-8.prepend-top-16 + .form-group + %label{ for: 'new-branch-name' } + = _('Branch name') + %input#new-branch-name.js-branch-name.form-control{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" } + %span.js-branch-message.help-block + + .form-group + %label{ for: 'source-name' } + = _('Source (branch or tag)') + %input#source-name.js-ref.ref.form-control{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } } + %span.js-ref-message.help-block + + .form-group + %button.btn.btn-success.js-create-target{ type: 'button', data: { action: 'create-mr' } } + = _('Create merge request') diff --git a/app/views/projects/jobs/_user.html.haml b/app/views/projects/jobs/_user.html.haml index 83f299da651..461d503f95d 100644 --- a/app/views/projects/jobs/_user.html.haml +++ b/app/views/projects/jobs/_user.html.haml @@ -1,7 +1,7 @@ by %a{ href: user_path(@build.user) } %span.hidden-xs - = image_tag avatar_icon(@build.user, 24), class: "avatar s24" + = image_tag avatar_icon_for_user(@build.user, 24), class: "avatar s24" %strong{ data: { toggle: 'tooltip', placement: 'top', title: @build.user.to_reference } } = @build.user.name %strong.visible-xs-inline= @build.user.to_reference diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 623c42ba88e..de381d489c6 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -27,7 +27,7 @@ Edit - if @project.group - = link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "You are about to promote #{@milestone.title} to a group level. This will make this milestone available to all projects inside #{@project.group.name}. The existing project milestone will be merged into the group level. This action cannot be reversed.", toggle: "tooltip" }, method: :post do + = link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "Promoting #{@milestone.title} will make it available for all projects inside #{@project.group.name}. Existing project milestones with the same name will be merged. This action cannot be reversed.", toggle: "tooltip" }, method: :post do Promote - if @milestone.active? diff --git a/app/views/projects/network/_head.html.haml b/app/views/projects/network/_head.html.haml index 8f6805268d5..f08526f485e 100644 --- a/app/views/projects/network/_head.html.haml +++ b/app/views/projects/network/_head.html.haml @@ -6,4 +6,4 @@ = render partial: 'shared/ref_switcher', locals: {destination: 'graph'} .oneline - You can move around the graph by using the arrow keys. + = _("You can move around the graph by using the arrow keys.") diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 9f68a91759f..97be8950db0 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -7,14 +7,14 @@ .project-network .controls = form_tag project_network_path(@project, @id), method: :get, class: 'form-inline network-form' do |f| - = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: "Git revision", class: 'search-input form-control input-mx-250 search-sha' + = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control input-mx-250 search-sha' = button_tag class: 'btn btn-success' do = icon('search') .inline.prepend-left-20 .checkbox.light = label_tag :filter_ref do = check_box_tag :filter_ref, 1, @options[:filter_ref] - %span Begin with the selected commit + %span= _("Begin with the selected commit") - if @commit .network-graph{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } } diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb index 122e84b41b2..a0e82e891ff 100644 --- a/app/views/projects/network/show.json.erb +++ b/app/views/projects/network/show.json.erb @@ -9,11 +9,11 @@ author: { name: c.author_name, email: c.author_email, - icon: image_path(avatar_icon(c.author_email, 20)) + icon: image_path(avatar_icon_for_email(c.author_email, 20)) }, time: c.time, space: c.spaces.first, - refs: get_refs(@graph.repo, c), + refs: refs(@graph.repo, c), id: c.sha, date: c.date, message: c.message, diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index ff440e99042..160e325996a 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -1,7 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'schedule_form' - = form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "form-horizontal js-pipeline-schedule-form" } do |f| = form_errors(@schedule) .form-group diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index a8692b83b07..55d0e8bb7f9 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -21,7 +21,7 @@ = s_("PipelineSchedules|Inactive") %td - if pipeline_schedule.owner - = image_tag avatar_icon(pipeline_schedule.owner, 20), class: "avatar s20" + = image_tag avatar_icon_for_user(pipeline_schedule.owner, 20), class: "avatar s20" = link_to user_path(pipeline_schedule.owner) do = pipeline_schedule.owner&.name %td diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index 4fbdd1dd5e4..bcb6dddba1a 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -1,9 +1,5 @@ - breadcrumb_title _("Schedules") -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'schedules_index' - - @no_container = true - page_title _("Pipeline Schedules") diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index 8512ac2a89b..a86cb14960a 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -1,8 +1,6 @@ - @no_container = true - breadcrumb_title "CI / CD Charts" - page_title _("Charts"), _("Pipelines") -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_d3') %div{ class: container_class } .sub-header-block diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml index a5dbd1b1532..510697c2ae9 100644 --- a/app/views/projects/pipelines/charts/_pipeline_times.haml +++ b/app/views/projects/pipelines/charts/_pipeline_times.haml @@ -1,6 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('pipelines_times') - %div %p.light = _("Commit duration in minutes for last 30 commits") diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml index 41dc2f6cf9d..2f4b6def155 100644 --- a/app/views/projects/pipelines/charts/_pipelines.haml +++ b/app/views/projects/pipelines/charts/_pipelines.haml @@ -1,6 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('pipelines_charts') - %h4= _("Pipelines charts") %p diff --git a/app/views/projects/repositories/_feed.html.haml b/app/views/projects/repositories/_feed.html.haml index 170f9e259df..87895a15239 100644 --- a/app/views/projects/repositories/_feed.html.haml +++ b/app/views/projects/repositories/_feed.html.haml @@ -11,7 +11,7 @@ %div = link_to project_commits_path(@project, commit.id) do %code= commit.short_id - = image_tag avatar_icon(commit.author_email), class: "", width: 16, alt: '' + = image_tag avatar_icon_for_email(commit.author_email), class: "", width: 16, alt: '' = markdown(truncate(commit.title, length: 40), pipeline: :single_line, author: commit.author) %td %span.pull-right.cgray diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 21acd857ce7..17e804d682b 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -1,6 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('integrations') - .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 @@ -9,7 +6,7 @@ %p= @service.description .col-lg-9 - = form_for(@service, as: :service, url: project_service_path(@project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form| + = form_for(@service, as: :service, url: project_service_path(@project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal integration-settings-form js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form| = render 'shared/service_settings', form: form, subject: @service - if @service.editable? .footer-block.row-content-block diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml new file mode 100644 index 00000000000..5e320a252d8 --- /dev/null +++ b/app/views/projects/services/prometheus/_help.html.haml @@ -0,0 +1,33 @@ +%h4 + = s_('PrometheusService|Auto configuration') + +- if @service.manual_configuration? + .well + = s_('PrometheusService|To enable the installation of Prometheus on your clusters, deactivate the manual configuration below') +- else + .container-fluid + .row + - if @service.prometheus_installed? + .col-sm-2 + .svg-container + = image_tag 'illustrations/monitoring/getting_started.svg' + .col-sm-10 + %p.text-success.prepend-top-default + = s_('PrometheusService|Prometheus is being automatically managed on your clusters') + = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(@project), class: 'btn' + - else + .col-sm-2 + = image_tag 'illustrations/monitoring/loading.svg' + .col-sm-10 + %p.prepend-top-default + = s_('PrometheusService|Automatically deploy and configure Prometheus on your clusters to monitor your project’s environments') + = link_to s_('PrometheusService|Install Prometheus on clusters'), project_clusters_path(@project), class: 'btn btn-success' + +%hr + +%h4.append-bottom-default + = s_('PrometheusService|Manual configuration') + +- unless @service.editable? + .well + = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters') diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml index b0cb5ce5e8f..5f38ecd6820 100644 --- a/app/views/projects/services/prometheus/_show.html.haml +++ b/app/views/projects/services/prometheus/_show.html.haml @@ -1,6 +1,3 @@ -- content_for :page_specific_javascripts do - = webpack_bundle_tag('prometheus_metrics') - .row.prepend-top-default.append-bottom-default.prometheus-metrics-monitoring.js-prometheus-metrics-monitoring .col-lg-3 %h4.prepend-top-0 diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 664a4554692..756f31f91d9 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -29,14 +29,14 @@ %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 - Secret variables - = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank' + = _('Secret variables') + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.js-settings-toggle = expanded ? 'Collapse' : 'Expand' - %p + %p.append-bottom-0 = render "ci/variables/content" .settings-content - = render 'ci/variables/index' + = render 'ci/variables/index', save_endpoint: project_variables_path(@project) %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/update.js.haml b/app/views/projects/update.js.haml index 2c05ebe52ae..1a353953838 100644 --- a/app/views/projects/update.js.haml +++ b/app/views/projects/update.js.haml @@ -6,4 +6,4 @@ $(".project-edit-errors").html("#{escape_javascript(render('errors'))}"); $('.save-project-loader').hide(); $('.project-edit-container').show(); - $('.edit-project .btn-save').enable(); + $('.edit-project .js-btn-save-general-project-settings').enable(); diff --git a/app/views/projects/variables/show.html.haml b/app/views/projects/variables/show.html.haml deleted file mode 100644 index df533952b76..00000000000 --- a/app/views/projects/variables/show.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render 'ci/variables/show' diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index c4a5131c1a7..57a0b64bfd5 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -7,7 +7,7 @@ = snippet.title by = link_to user_snippets_path(snippet.author) do - = image_tag avatar_icon(snippet.author), class: "avatar avatar-inline s16", alt: '' + = image_tag avatar_icon_for_user(snippet.author), class: "avatar avatar-inline s16", alt: '' = snippet.author_name %span.light= time_ago_with_tooltip(snippet.created_at) %h4.snippet-title diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml index aef825691e0..65710c09a89 100644 --- a/app/views/search/results/_snippet_title.html.haml +++ b/app/views/search/results/_snippet_title.html.haml @@ -18,6 +18,6 @@ %span by = link_to user_snippets_path(snippet_title.author) do - = image_tag avatar_icon(snippet_title.author), class: "avatar avatar-inline s16", alt: '' + = image_tag avatar_icon_for_user(snippet_title.author), class: "avatar avatar-inline s16", alt: '' = snippet_title.author_name %span.light= time_ago_with_tooltip(snippet_title.created_at) diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index c0eebdfaddd..8847d11f623 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -48,7 +48,7 @@ .pull-right.hidden-xs.hidden-sm - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group) - = link_to promote_project_label_path(label.project, label), title: "Promote to Group Label", class: 'btn btn-transparent btn-action', data: {confirm: "You are about to promote #{label.title} to a group level. This will make this milestone available to all projects inside #{label.project.group.name}. The existing project label will be merged into the group level. This action cannot be reversed.", toggle: "tooltip"}, method: :post do + = link_to promote_project_label_path(label.project, label), title: "Promote to Group Label", class: 'btn btn-transparent btn-action', data: {confirm: "Promoting #{label.title} will make it available for all projects inside #{label.project.group.name}. Existing project labels with the same name will be merged. This action cannot be reversed.", toggle: "tooltip"}, method: :post do %span.sr-only Promote to Group = sprite_icon('level-up') - if can?(current_user, :admin_label, label) diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 1a259b679c7..8607be9cd06 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -23,11 +23,11 @@ - if show_archive_options %li.divider %li.js-filter-archived-projects - = link_to group_children_path(@group, archived: nil), class: ("is-active" unless params[:archived].present?) do + = link_to filter_groups_path(archived: nil), class: ("is-active" unless params[:archived].present?) do Hide archived projects %li.js-filter-archived-projects - = link_to group_children_path(@group, archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do + = link_to filter_groups_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do Show archived projects %li.js-filter-archived-projects - = link_to group_children_path(@group, archived: 'only'), class: ("is-active" if params[:archived] == 'only') do + = link_to filter_groups_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do Show archived projects only diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index 0a692d9653f..d5e7d3b87b7 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -2,16 +2,16 @@ = dropdown_title("Create new label", options: { back: true }) = dropdown_content do .dropdown-labels-error.js-label-error - %input#new_label_name.default-dropdown-input{ type: "text", placeholder: "Name new label" } + %input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') } .suggest-colors.suggest-colors-dropdown - suggested_colors.each do |color| = link_to '#', style: "background-color: #{color}", data: { color: color } do   .dropdown-label-color-input .dropdown-label-color-preview.js-dropdown-label-color-preview - %input#new_label_color.default-dropdown-input{ type: "text", placeholder: "Assign custom color like #FF0000" } + %input#new_label_color.default-dropdown-input{ type: "text", placeholder: _('Assign custom color like #FF0000') } .clearfix %button.btn.btn-primary.pull-left.js-new-label-btn{ type: "button" } - Create + = _('Create') %button.btn.btn-default.pull-right.js-cancel-label-btn{ type: "button" } - Cancel + = _('Cancel') diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index ad031e6af80..6a83321abcb 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -1,4 +1,4 @@ -- title = local_assigns.fetch(:title, 'Assign labels') +- title = local_assigns.fetch(:title, _('Assign labels')) - show_create = local_assigns.fetch(:show_create, true) - show_footer = local_assigns.fetch(:show_footer, true) - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search') @@ -8,7 +8,7 @@ - if show_boards_content .issue-board-dropdown-content %p - Create lists from labels. Issues with that label appear in that list. + = _('Create lists from labels. Issues with that label appear in that list.') = dropdown_filter(filter_placeholder) = dropdown_content - if current_board_parent && show_footer @@ -17,11 +17,11 @@ - if can?(current_user, :admin_label, current_board_parent) %li %a.dropdown-toggle-page{ href: "#" } - Create new label + = _('Create new label') %li = link_to labels_path, :"data-is-link" => true do - if show_create && can?(current_user, :admin_label, current_board_parent) - Manage labels + = _('Manage labels') - else - View labels + = _('View labels') = dropdown_loading diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index f384f8da11a..dc583d3eb3b 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -9,7 +9,7 @@ .block.issuable-sidebar-header - if current_user %span.issuable-header-text.hide-collapsed.pull-left - Todo + = _('Todo') %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } = sidebar_gutter_toggle_icon - if current_user @@ -29,9 +29,9 @@ %span.has-tooltip{ title: "#{issuable.milestone.title}<br>#{milestone_tooltip_title(issuable.milestone)}", data: { container: 'body', html: 1, placement: 'left' } } = issuable.milestone.title - else - None + = _('None') .title.hide-collapsed - Milestone + = _('Milestone') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' @@ -39,16 +39,17 @@ - if issuable.milestone = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_title(issuable.milestone), data: { container: "body", html: 1 } - else - %span.no-value None + %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 }}) + = 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 }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block // Fallback while content is loading .title.hide-collapsed - Time tracking + = _('Time tracking') = icon('spinner spin', 'aria-hidden': 'true') - if issuable.has_attribute?(:due_date) .block.due_date @@ -57,7 +58,7 @@ %span.js-due-date-sidebar-value = issuable.due_date.try(:to_s, :medium) || 'None' .title.hide-collapsed - Due date + = _('Due date') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' @@ -66,21 +67,23 @@ - if issuable.due_date %span.bold= issuable.due_date.to_s(:medium) - else - %span.no-value No due date + %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?) } \- %a.js-remove-due-date{ href: "#", role: "button" } - remove due date + = _('remove due date') - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .selectbox.hide-collapsed = f.hidden_field :due_date, value: issuable.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) } } - %span.dropdown-toggle-text Due date + %span.dropdown-toggle-text + = _('Due date') = icon('chevron-down', 'aria-hidden': 'true') .dropdown-menu.dropdown-menu-due-date - = dropdown_title('Due date') + = dropdown_title(_('Due date')) = dropdown_content do .js-due-date-calendar @@ -92,7 +95,7 @@ %span = selected_labels.size .title.hide-collapsed - Labels + = _('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 pull-right' @@ -101,7 +104,8 @@ - selected_labels.each do |label| = link_to_label(label, subject: issuable.project, type: issuable.to_ability_name) - else - %span.no-value None + %span.no-value + = _('None') .selectbox.hide-collapsed - selected_labels.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil @@ -131,29 +135,29 @@ - project_ref = cross_project_reference(@project, issuable) .block.project-reference .sidebar-collapsed-icon.dont-change-state - = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") + = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left") .cross-project-reference.hide-collapsed %span - Reference: + = _('Reference:') %cite{ title: project_ref } = project_ref - = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") + = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left") - if current_user && issuable.can_move?(current_user) .block.js-sidebar-move-issue-block - .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: 'Move issue' } + .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: _('Move issue') } = custom_icon('icon_arrow_right') .dropdown.sidebar-move-issue-dropdown.hide-collapsed %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', data: { toggle: 'dropdown' } } - Move issue + = _('Move issue') .dropdown-menu.dropdown-menu-selectable - = dropdown_title('Move issue') - = dropdown_filter('Search project', search_id: 'sidebar-move-issue-dropdown-search') + = dropdown_title(_('Move issue')) + = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search') = dropdown_content = dropdown_loading = dropdown_footer add_content_class: true do %button.btn.btn-new.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true } - Move + = _('Move') = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon') %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).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 0fca4162ec9..304df38a096 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,7 +1,7 @@ - if issuable.is_a?(Issue) #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]", signed_in: signed_in } } .title.hide-collapsed - Assignee + = _('Assignee') = icon('spinner spin') - else .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } @@ -10,35 +10,35 @@ - else = icon('user', 'aria-hidden': 'true') .title.hide-collapsed - Assignee + = _('Assignee') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' - if !signed_in - %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } + %a.gutter-toggle.pull-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) - %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + %span.pull-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 - else %span.assign-yourself.no-value - No assignee + = _('No assignee') - if can_edit_issuable \- %a.js-assign-yourself{ href: '#' } - assign yourself + = _('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 } - - 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 } } - - title = 'Select assignee' + - 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 } } + - title = _('Select assignee') - if issuable.is_a?(Issue) - unless issuable.assignees.any? diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml index 574e2958ae8..b77e104c072 100644 --- a/app/views/shared/issuable/_sidebar_todo.html.haml +++ b/app/views/shared/issuable/_sidebar_todo.html.haml @@ -1,11 +1,11 @@ - is_collapsed = local_assigns.fetch(:is_collapsed, false) -- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : 'Mark done' -- todo_content = is_collapsed ? icon('plus-square') : 'Add todo' +- mark_content = is_collapsed ? icon('check-square', class: 'todo-undone') : _('Mark done') +- todo_content = is_collapsed ? icon('plus-square') : _('Add todo') %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 pull-right'), - title: (todo.nil? ? 'Add todo' : 'Mark done'), - 'aria-label' => (todo.nil? ? 'Add todo' : 'Mark done'), + title: (todo.nil? ? _('Add todo') : _('Mark done')), + 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark done')), data: issuable_todo_button_data(issuable, todo, is_collapsed) } %span.issuable-todo-inner.js-issuable-todo-inner< - if todo diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 71878e93255..ba57d922c6d 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -8,7 +8,7 @@ %li.member{ class: dom_class(member), id: dom_id(member) } %span.list-item-name - if user - = image_tag avatar_icon(user, 40), class: "avatar s40", alt: '' + = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: '' .user-info = link_to user.name, user_path(user), class: 'member' %span.cgray= user.to_reference @@ -36,7 +36,7 @@ Expires in #{distance_of_time_in_words_to_now(member.expires_at)} - else - = image_tag avatar_icon(member.invite_email, 40), class: "avatar s40", alt: '' + = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40", alt: '' .user-info .member= member.invite_email .cgray diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index 14395bcc661..479b7270b28 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -28,4 +28,4 @@ - assignees.each do |assignee| = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }), class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do - - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '') + - image_tag(avatar_icon_for_user(assignee, 16), class: "avatar s16", alt: '') diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index e08a49b4e59..e3b2b53833e 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -51,7 +51,7 @@ \ - if @project.group - = link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "You are about to promote #{milestone.title} to a group level. This will make this milestone available to all projects inside #{@project.group.name}. The existing project milestone will be merged into the group level. This action cannot be reversed.", toggle: "tooltip" }, method: :post do + = link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "Promoting #{milestone.title} will make it available for all projects inside #{@project.group.name}. Existing project milestones with the same name will be merged. This action cannot be reversed.", toggle: "tooltip" }, method: :post do Promote = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped" diff --git a/app/views/shared/milestones/_participants_tab.html.haml b/app/views/shared/milestones/_participants_tab.html.haml index 1615871385e..fe83040c168 100644 --- a/app/views/shared/milestones/_participants_tab.html.haml +++ b/app/views/shared/milestones/_participants_tab.html.haml @@ -2,7 +2,7 @@ - users.each do |user| %li = link_to user, title: user.name, class: "darken" do - = image_tag avatar_icon(user, 32), class: "avatar s32" + = image_tag avatar_icon_for_user(user, 32), class: "avatar s32" %strong= truncate(user.name, length: 40) %div %small.cgray= user.username diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 98e0161f7d1..bf359774ead 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -16,7 +16,7 @@ = icon_for_system_note(note) - else %a.image-diff-avatar-link{ href: user_path(note.author) } - = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40' + = image_tag avatar_icon_for_user(note.author), alt: '', class: 'avatar s40' - if note.is_a?(DiffNote) && note.on_image? - if show_image_comment_badge && note_counter == 0 -# Only show this for the first comment in the discussion diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index e11f778adf5..b3f865c5b47 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -14,7 +14,7 @@ .timeline-icon.hidden-xs.hidden-sm %a.author_link{ href: user_path(current_user) } - = image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40' + = image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form = render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete - elsif !current_user diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 2a75b46d376..33435216c14 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -17,7 +17,7 @@ .avatar-container.s40 = link_to project_path(project), class: dom_class(project) do - if project.creator && use_creator_avatar - = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:'' + = image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:'' - else = project_icon(project, alt: '', class: 'avatar project-avatar s40') .project-details diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 7388f20a9fd..491a8a41090 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,7 +1,7 @@ - link_project = local_assigns.fetch(:link_project, false) %li.snippet-row - = image_tag avatar_icon(snippet.author_email), class: "avatar s40 hidden-xs", alt: '' + = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 hidden-xs", alt: '' .title = link_to reliable_snippet_path(snippet) do diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 90aa1be30ac..a396d1007a7 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -4,9 +4,6 @@ - page_description @user.bio - header_title @user.name, user_path(@user) - @no_container = true -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_d3' - = webpack_bundle_tag 'users' = content_for :meta_tags do = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity") @@ -35,8 +32,8 @@ .profile-header .avatar-holder - = link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do - = image_tag avatar_icon(@user, 90), class: "avatar s90", alt: '' + = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do + = image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '' .user-info .cover-title diff --git a/app/workers/check_gcp_project_billing_worker.rb b/app/workers/check_gcp_project_billing_worker.rb index 5466ccdda59..363f81590ab 100644 --- a/app/workers/check_gcp_project_billing_worker.rb +++ b/app/workers/check_gcp_project_billing_worker.rb @@ -7,6 +7,7 @@ class CheckGcpProjectBillingWorker LEASE_TIMEOUT = 3.seconds.to_i SESSION_KEY_TIMEOUT = 5.minutes BILLING_TIMEOUT = 1.hour + BILLING_CHANGED_LABELS = { state_transition: nil }.freeze def self.get_session_token(token_key) Gitlab::Redis::SharedState.with do |redis| @@ -22,8 +23,11 @@ class CheckGcpProjectBillingWorker end end - def self.redis_shared_state_key_for(token) - "gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled" + def self.get_billing_state(token) + Gitlab::Redis::SharedState.with do |redis| + value = redis.get(redis_shared_state_key_for(token)) + ActiveRecord::Type::Boolean.new.type_cast_from_user(value) + end end def perform(token_key) @@ -33,12 +37,9 @@ class CheckGcpProjectBillingWorker return unless token return unless try_obtain_lease_for(token) - billing_enabled_projects = CheckGcpProjectBillingService.new.execute(token) - Gitlab::Redis::SharedState.with do |redis| - redis.set(self.class.redis_shared_state_key_for(token), - !billing_enabled_projects.empty?, - ex: BILLING_TIMEOUT) - end + billing_enabled_state = !CheckGcpProjectBillingService.new.execute(token).empty? + update_billing_change_counter(self.class.get_billing_state(token), billing_enabled_state) + self.class.set_billing_state(token, billing_enabled_state) end private @@ -51,9 +52,41 @@ class CheckGcpProjectBillingWorker "gitlab:gcp:session:#{token_key}" end + def self.redis_shared_state_key_for(token) + "gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled" + end + + def self.set_billing_state(token, value) + Gitlab::Redis::SharedState.with do |redis| + redis.set(redis_shared_state_key_for(token), value, ex: BILLING_TIMEOUT) + end + end + def try_obtain_lease_for(token) Gitlab::ExclusiveLease .new("check_gcp_project_billing_worker:#{token.hash}", timeout: LEASE_TIMEOUT) .try_obtain end + + def billing_changed_counter + @billing_changed_counter ||= Gitlab::Metrics.counter( + :gcp_billing_change_count, + "Counts the number of times a GCP project changed billing_enabled state from false to true", + BILLING_CHANGED_LABELS + ) + end + + def state_transition(previous_state, current_state) + if previous_state.nil? && !current_state + 'no_billing' + elsif previous_state.nil? && current_state + 'with_billing' + elsif !previous_state && current_state + 'billing_configured' + end + end + + def update_billing_change_counter(previous_state, current_state) + billing_changed_counter.increment(state_transition: state_transition(previous_state, current_state)) + end end |