diff options
Diffstat (limited to 'app/assets')
102 files changed, 1928 insertions, 501 deletions
diff --git a/app/assets/images/auth_buttons/signin_with_google.png b/app/assets/images/auth_buttons/signin_with_google.png Binary files differnew file mode 100644 index 00000000000..b1327b4f7b4 --- /dev/null +++ b/app/assets/images/auth_buttons/signin_with_google.png diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js index bd479700fd3..19388f1f9ae 100644 --- a/app/assets/javascripts/build_artifacts.js +++ b/app/assets/javascripts/build_artifacts.js @@ -1,9 +1,12 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, no-return-assign, max-len */ +import { visitUrl } from './lib/utils/url_utility'; +import { convertPermissionToBoolean } from './lib/utils/common_utils'; window.BuildArtifacts = (function() { function BuildArtifacts() { this.disablePropagation(); this.setupEntryClick(); + this.setupTooltips(); } BuildArtifacts.prototype.disablePropagation = function() { @@ -17,9 +20,28 @@ window.BuildArtifacts = (function() { BuildArtifacts.prototype.setupEntryClick = function() { return $('.tree-holder').on('click', 'tr[data-link]', function(e) { - return window.location = this.dataset.link; + visitUrl(this.dataset.link, convertPermissionToBoolean(this.dataset.externalLink)); }); }; + BuildArtifacts.prototype.setupTooltips = function() { + $('.js-artifact-tree-tooltip').tooltip({ + placement: 'bottom', + // Stop the tooltip from hiding when we stop hovering the element directly + // We handle all the showing/hiding below + trigger: 'manual', + }); + + // We want the tooltip to show if you hover anywhere on the row + // But be placed below and in the middle of the file name + $('.js-artifact-tree-row') + .on('mouseenter', (e) => { + $(e.currentTarget).find('.js-artifact-tree-tooltip').tooltip('show'); + }) + .on('mouseleave', (e) => { + $(e.currentTarget).find('.js-artifact-tree-tooltip').tooltip('hide'); + }); + }; + return BuildArtifacts; })(); diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js new file mode 100644 index 00000000000..50dbeb06362 --- /dev/null +++ b/app/assets/javascripts/clusters.js @@ -0,0 +1,112 @@ +/* globals Flash */ +import Visibility from 'visibilityjs'; +import axios from 'axios'; +import Poll from './lib/utils/poll'; +import { s__ } from './locale'; +import './flash'; + +/** + * Cluster page has 2 separate parts: + * Toggle button + * + * - Polling status while creating or scheduled + * -- Update status area with the response result + */ + +class ClusterService { + constructor(options = {}) { + this.options = options; + } + fetchData() { + return axios.get(this.options.endpoint); + } +} + +export default class Clusters { + constructor() { + const dataset = document.querySelector('.js-edit-cluster-form').dataset; + + this.state = { + statusPath: dataset.statusPath, + clusterStatus: dataset.clusterStatus, + clusterStatusReason: dataset.clusterStatusReason, + toggleStatus: dataset.toggleStatus, + }; + + this.service = new ClusterService({ endpoint: this.state.statusPath }); + this.toggleButton = document.querySelector('.js-toggle-cluster'); + this.toggleInput = document.querySelector('.js-toggle-input'); + this.errorContainer = document.querySelector('.js-cluster-error'); + this.successContainer = document.querySelector('.js-cluster-success'); + this.creatingContainer = document.querySelector('.js-cluster-creating'); + this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); + + this.toggleButton.addEventListener('click', this.toggle.bind(this)); + + if (this.state.clusterStatus !== 'created') { + this.updateContainer(this.state.clusterStatus, this.state.clusterStatusReason); + } + + if (this.state.statusPath) { + this.initPolling(); + } + } + + toggle() { + this.toggleButton.classList.toggle('checked'); + this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString()); + } + + initPolling() { + this.poll = new Poll({ + resource: this.service, + method: 'fetchData', + successCallback: (data) => { + const { status, status_reason } = data.data; + this.updateContainer(status, status_reason); + }, + errorCallback: () => { + Flash(s__('ClusterIntegration|Something went wrong on our end.')); + }, + }); + + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } else { + this.service.fetchData(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + hideAll() { + this.errorContainer.classList.add('hidden'); + this.successContainer.classList.add('hidden'); + this.creatingContainer.classList.add('hidden'); + } + + updateContainer(status, error) { + this.hideAll(); + switch (status) { + case 'created': + this.successContainer.classList.remove('hidden'); + break; + case 'errored': + this.errorContainer.classList.remove('hidden'); + this.errorReasonContainer.textContent = error; + break; + case 'scheduled': + case 'creating': + this.creatingContainer.classList.remove('hidden'); + break; + default: + this.hideAll(); + } + } +} diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue index 9941b997b3f..62efd4f9c28 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.vue @@ -20,7 +20,7 @@ <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template> <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template> <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template> - <template v-if="time.seconds && hasDa === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template> + <template v-if="time.seconds && hasData === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template> </template> <template v-else> -- diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index ae8338f5fd2..9ddfdb6ae21 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -17,7 +17,8 @@ class Diff { } }); - FilesCommentButton.init($diffFile); + const tab = document.getElementById('diffs'); + if (!tab || (tab && tab.dataset && tab.dataset.isLocked !== '')) FilesCommentButton.init($diffFile); $diffFile.each((index, file) => new gl.ImageFile(file)); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index bbaa4e4d91e..e4e7cae540e 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -525,6 +525,11 @@ import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils'; case 'admin:impersonation_tokens:index': new gl.DueDateSelectors(); break; + case 'projects:clusters:show': + import(/* webpackChunkName: "clusters" */ './clusters') + .then(cluster => new cluster.default()) // eslint-disable-line new-cap + .catch(() => {}); + break; } switch (path[0]) { case 'sessions': diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index ff218ccad62..e8d8fef8579 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -738,7 +738,7 @@ GitLabDropdown = (function() { : selectedObject.id; if (isInput) { field = $(this.el); - } else if (value) { + } else if (value != null) { field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, '\\\'') + "']"); } @@ -746,7 +746,7 @@ GitLabDropdown = (function() { return; } - if (el.hasClass(ACTIVE_CLASS)) { + if (el.hasClass(ACTIVE_CLASS) && value !== 0) { isMarking = false; el.removeClass(ACTIVE_CLASS); if (field && field.length) { @@ -852,7 +852,7 @@ GitLabDropdown = (function() { if (href && href !== '#') { gl.utils.visitUrl(href); } else { - $el.first().trigger('click'); + $el.trigger('click'); } } }; diff --git a/app/assets/javascripts/lib/utils/csrf.js b/app/assets/javascripts/lib/utils/csrf.js index ae41cc5e8a8..0bdb547d31a 100644 --- a/app/assets/javascripts/lib/utils/csrf.js +++ b/app/assets/javascripts/lib/utils/csrf.js @@ -14,6 +14,9 @@ If you need to compose a headers object, use the spread operator: someOtherHeader: '12345', } ``` + +see also http://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf +and https://github.com/rails/jquery-rails/blob/v4.3.1/vendor/assets/javascripts/jquery_ujs.js#L59-L62 */ const csrf = { @@ -53,4 +56,3 @@ if ($.rails) { } export default csrf; - diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 3328ff9cc23..78c7a094127 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, one-var, one-var-declaration-per-line, no-void, guard-for-in, no-restricted-syntax, prefer-template, quotes, max-len */ + var base; var w = window; if (w.gl == null) { @@ -86,6 +87,21 @@ w.gl.utils.getLocationHash = function(url) { w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href); -w.gl.utils.visitUrl = (url) => { - document.location.href = url; +// eslint-disable-next-line import/prefer-default-export +export function visitUrl(url, external = false) { + if (external) { + // Simulate `target="blank" rel="noopener noreferrer"` + // See https://mathiasbynens.github.io/rel-noopener/ + const otherWindow = window.open(); + otherWindow.opener = null; + otherWindow.location = url; + } else { + document.location.href = url; + } +} + +window.gl = window.gl || {}; +window.gl.utils = { + ...(window.gl.utils || {}), + visitUrl, }; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index a16d00b5cef..a75d1a4b8d0 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -54,12 +54,14 @@ LineHighlighter.prototype.bindEvents = function() { $fileHolder.on('highlight:line', this.highlightHash); }; -LineHighlighter.prototype.highlightHash = function() { - var range; +LineHighlighter.prototype.highlightHash = function(newHash) { + let range; + if (newHash && typeof newHash === 'string') this._hash = newHash; + + this.clearHighlight(); if (this._hash !== '') { range = this.hashToRange(this._hash); - if (range[0]) { this.highlightRange(range); const lineSelector = `#L${range[0]}`; diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index af718e894cf..1003b9ba0af 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -1,30 +1,13 @@ import Jed from 'jed'; - import sprintf from './sprintf'; -/** - This is required to require all the translation folders in the current directory - this saves us having to do this manually & keep up to date with new languages -**/ -function requireAll(requireContext) { return requireContext.keys().map(requireContext); } - -const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/)); -const locales = allLocales.reduce((d, obj) => { - const data = d; - const localeKey = Object.keys(obj)[0]; - - data[localeKey] = obj[localeKey]; - - return data; -}, {}); - const langAttribute = document.querySelector('html').getAttribute('lang'); const lang = (langAttribute || 'en').replace(/-/g, '_'); -const locale = new Jed(locales[lang]); +const locale = new Jed(window.translations || {}); +delete window.translations; /** Translates `text` - @param text The text to be translated @returns {String} The translated text **/ diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 0db2abe507d..af0658eb668 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -127,6 +127,21 @@ import IssuablesHelper from './helpers/issuables_helper'; $el.text(gl.text.addDelimiter(count)); }; + MergeRequest.prototype.hideCloseButton = function() { + const el = document.querySelector('.merge-request .issuable-actions'); + const closeDropdownItem = el.querySelector('li.close-item'); + if (closeDropdownItem) { + closeDropdownItem.classList.add('hidden'); + // Selects the next dropdown item + el.querySelector('li.report-item').click(); + } else { + // No dropdown just hide the Close button + el.querySelector('.btn-close').classList.add('hidden'); + } + // Dropdown for mobile screen + el.querySelector('li.js-close-item').classList.add('hidden'); + }; + return MergeRequest; })(); }).call(window); diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index f80a26b3fd4..442ed86d50c 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -29,6 +29,7 @@ showEmptyState: true, updateAspectRatio: false, updatedAspectRatios: 0, + hoverData: {}, resizeThrottled: {}, }; }, @@ -64,6 +65,10 @@ this.updatedAspectRatios = 0; } }, + + hoverChanged(data) { + this.hoverData = data; + }, }, created() { @@ -72,10 +77,12 @@ deploymentEndpoint: this.deploymentEndpoint, }); eventHub.$on('toggleAspectRatio', this.toggleAspectRatio); + eventHub.$on('hoverChanged', this.hoverChanged); }, beforeDestroy() { eventHub.$off('toggleAspectRatio', this.toggleAspectRatio); + eventHub.$off('hoverChanged', this.hoverChanged); window.removeEventListener('resize', this.resizeThrottled, false); }, @@ -102,6 +109,7 @@ v-for="(graphData, index) in groupData.metrics" :key="index" :graph-data="graphData" + :hover-data="hoverData" :update-aspect-ratio="updateAspectRatio" :deployment-data="store.deploymentData" /> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index a7b483f6786..a18164482a2 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -73,34 +73,22 @@ <template> <div class="prometheus-state"> - <div class="row"> - <div class="col-md-4 col-md-offset-4 state-svg svg-content"> - <img :src="currentState.svgUrl"/> - </div> + <div class="state-svg svg-content"> + <img :src="currentState.svgUrl"/> </div> - <div class="row"> - <div class="col-md-6 col-md-offset-3"> - <h4 class="text-center state-title"> - {{currentState.title}} - </h4> - </div> - </div> - <div class="row"> - <div class="col-md-6 col-md-offset-3"> - <div class="description-text text-center state-description"> - {{currentState.description}} - <a v-if="showButtonDescription" :href="settingsPath"> - Prometheus server - </a> - </div> - </div> - </div> - <div class="row state-button-section"> - <div class="col-md-4 col-md-offset-4 text-center state-button"> - <a class="btn btn-success" :href="buttonPath"> - {{currentState.buttonText}} - </a> - </div> + <h4 class="state-title"> + {{currentState.title}} + </h4> + <p class="state-description"> + {{currentState.description}} + <a v-if="showButtonDescription" :href="settingsPath"> + Prometheus server + </a> + </p> + <div class="state-button"> + <a class="btn btn-success" :href="buttonPath"> + {{currentState.buttonText}} + </a> </div> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 6b3e341f936..5aa3865f96a 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -3,16 +3,14 @@ import GraphLegend from './graph/legend.vue'; import GraphFlag from './graph/flag.vue'; import GraphDeployment from './graph/deployment.vue'; - import GraphPath from './graph_path.vue'; + import GraphPath from './graph/path.vue'; import MonitoringMixin from '../mixins/monitoring_mixins'; import eventHub from '../event_hub'; import measurements from '../utils/measurements'; - import { timeScaleFormat } from '../utils/date_time_formatters'; + import { timeScaleFormat, bisectDate } from '../utils/date_time_formatters'; import createTimeSeries from '../utils/multiple_time_series'; import bp from '../../breakpoints'; - const bisectDate = d3.bisector(d => d.time).left; - export default { props: { graphData: { @@ -27,6 +25,11 @@ type: Array, required: true, }, + hoverData: { + type: Object, + required: false, + default: () => ({}), + }, }, mixins: [MonitoringMixin], @@ -52,6 +55,7 @@ currentXCoordinate: 0, currentFlagPosition: 0, showFlag: false, + showFlagContent: false, showDeployInfo: true, timeSeries: [], }; @@ -65,7 +69,7 @@ }, computed: { - outterViewBox() { + outerViewBox() { return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`; }, @@ -122,36 +126,30 @@ const d1 = firstTimeSeries.values[overlayIndex]; if (d0 === undefined || d1 === undefined) return; const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; - this.currentData = evalTime ? d1 : d0; - this.currentDataIndex = evalTime ? overlayIndex : (overlayIndex - 1); - this.currentXCoordinate = Math.floor(firstTimeSeries.timeSeriesScaleX(this.currentData.time)); + const hoveredDataIndex = evalTime ? overlayIndex : (overlayIndex - 1); + const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time; const currentDeployXPos = this.mouseOverDeployInfo(point.x); - if (this.currentXCoordinate > (this.graphWidth - 200)) { - this.currentFlagPosition = this.currentXCoordinate - 103; - } else { - this.currentFlagPosition = this.currentXCoordinate; - } - - if (currentDeployXPos) { - this.showFlag = false; - } else { - this.showFlag = true; - } + eventHub.$emit('hoverChanged', { + hoveredDate, + currentDeployXPos, + }); }, renderAxesPaths() { - this.timeSeries = createTimeSeries(this.graphData.queries[0], - this.graphWidth, - this.graphHeight, - this.graphHeightOffset); + this.timeSeries = createTimeSeries( + this.graphData.queries[0], + this.graphWidth, + this.graphHeight, + this.graphHeightOffset, + ); if (this.timeSeries.length > 3) { this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; } const axisXScale = d3.time.scale() - .range([0, this.graphWidth]); + .range([0, this.graphWidth - 70]); const axisYScale = d3.scale.linear() .range([this.graphHeight - this.graphHeightOffset, 0]); @@ -194,6 +192,10 @@ eventHub.$emit('toggleAspectRatio'); } }, + + hoverData() { + this.positionFlag(); + }, }, mounted() { @@ -203,7 +205,10 @@ </script> <template> - <div class="prometheus-graph"> + <div + class="prometheus-graph" + @mouseover="showFlagContent = true" + @mouseleave="showFlagContent = false"> <h5 class="text-center graph-title"> {{graphData.title}} </h5> @@ -211,7 +216,7 @@ class="prometheus-svg-container" :style="paddingBottomRootSvg"> <svg - :viewBox="outterViewBox" + :viewBox="outerViewBox" ref="baseSvg"> <g class="x-axis" @@ -247,6 +252,7 @@ <graph-deployment :show-deploy-info="showDeployInfo" :deployment-data="reducedDeploymentData" + :graph-width="graphWidth" :graph-height="graphHeight" :graph-height-offset="graphHeightOffset" /> @@ -257,6 +263,7 @@ :current-flag-position="currentFlagPosition" :graph-height="graphHeight" :graph-height-offset="graphHeightOffset" + :show-flag-content="showFlagContent" /> <rect class="prometheus-graph-overlay" diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue index 3623d2ed946..e3b8be0c7fb 100644 --- a/app/assets/javascripts/monitoring/components/graph/deployment.vue +++ b/app/assets/javascripts/monitoring/components/graph/deployment.vue @@ -19,6 +19,10 @@ type: Number, required: true, }, + graphWidth: { + type: Number, + required: true, + }, }, computed: { @@ -47,6 +51,14 @@ transformDeploymentGroup(deployment) { return `translate(${Math.floor(deployment.xPos) + 1}, 20)`; }, + + positionFlag(deployment) { + let xPosition = 3; + if (deployment.xPos > (this.graphWidth - 200)) { + xPosition = -97; + } + return xPosition; + }, }, }; </script> @@ -77,7 +89,7 @@ <svg v-if="deployment.showDeploymentFlag" class="js-deploy-info-box" - x="3" + :x="positionFlag(deployment)" y="0" width="92" height="60"> diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index a98e3d06c18..10fb7ff6803 100644 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -23,6 +23,10 @@ type: Number, required: true, }, + showFlagContent: { + type: Boolean, + required: true, + }, }, data() { @@ -57,6 +61,7 @@ transform="translate(-5, 20)"> </line> <svg + v-if="showFlagContent" class="rect-text-metric" :x="currentFlagPosition" y="0"> diff --git a/app/assets/javascripts/monitoring/components/graph_path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue index 043f1bf66bb..043f1bf66bb 100644 --- a/app/assets/javascripts/monitoring/components/graph_path.vue +++ b/app/assets/javascripts/monitoring/components/graph/path.vue diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js index 345a0b37a76..31f38aca5d6 100644 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js @@ -1,3 +1,5 @@ +import { bisectDate } from '../utils/date_time_formatters'; + const mixins = { methods: { mouseOverDeployInfo(mouseXPos) { @@ -18,6 +20,7 @@ const mixins = { return dataFound; }, + formatDeployments() { this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => { const time = new Date(deployment.created_at); @@ -40,6 +43,25 @@ const mixins = { return deploymentDataArray; }, []); }, + + positionFlag() { + const timeSeries = this.timeSeries[0]; + const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate, 1); + this.currentData = timeSeries.values[hoveredDataIndex]; + this.currentDataIndex = hoveredDataIndex; + this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time)); + if (this.currentXCoordinate > (this.graphWidth - 200)) { + this.currentFlagPosition = this.currentXCoordinate - 103; + } else { + this.currentFlagPosition = this.currentXCoordinate; + } + + if (this.hoverData.currentDeployXPos) { + this.showFlag = false; + } else { + this.showFlag = true; + } + }, }, }; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index ef280e02092..104432ef5de 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -3,8 +3,5 @@ import Dashboard from './components/dashboard.vue'; document.addEventListener('DOMContentLoaded', () => new Vue({ el: '#prometheus-graphs', - components: { - Dashboard, - }, - render: createElement => createElement('dashboard'), + render: createElement => createElement(Dashboard), })); diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 7592af5878e..854636e9a89 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -13,7 +13,7 @@ function normalizeMetrics(metrics) { ...result, values: result.values.map(([timestamp, value]) => ({ time: new Date(timestamp * 1000), - value, + value: Number(value), })), })), })), diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js index 26bcaa02511..c4c6b1ac1f5 100644 --- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js +++ b/app/assets/javascripts/monitoring/utils/date_time_formatters.js @@ -2,6 +2,7 @@ import d3 from 'd3'; export const dateFormat = d3.time.format('%b %-d, %Y'); export const timeFormat = d3.time.format('%-I:%M%p'); +export const bisectDate = d3.bisector(d => d.time).left; export const timeScaleFormat = d3.time.format.multi([ ['.%L', d => d.getMilliseconds()], diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue index b8a16356576..b4067d229aa 100644 --- a/app/assets/javascripts/notebook/cells/code.vue +++ b/app/assets/javascripts/notebook/cells/code.vue @@ -1,18 +1,3 @@ -<template> - <div class="cell"> - <code-cell - type="input" - :raw-code="rawInputCode" - :count="cell.execution_count" - :code-css-class="codeCssClass" /> - <output-cell - v-if="hasOutput" - :count="cell.execution_count" - :output="output" - :code-css-class="codeCssClass" /> - </div> -</template> - <script> import CodeCell from './code/index.vue'; import OutputCell from './output/index.vue'; @@ -51,6 +36,21 @@ export default { }; </script> +<template> + <div class="cell"> + <code-cell + type="input" + :raw-code="rawInputCode" + :count="cell.execution_count" + :code-css-class="codeCssClass" /> + <output-cell + v-if="hasOutput" + :count="cell.execution_count" + :output="output" + :code-css-class="codeCssClass" /> + </div> +</template> + <style scoped> .cell { flex-direction: column; diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue index 31b30f601e2..0f3083f05b2 100644 --- a/app/assets/javascripts/notebook/cells/code/index.vue +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -1,17 +1,3 @@ -<template> - <div :class="type"> - <prompt - :type="promptType" - :count="count" /> - <pre - class="language-python" - :class="codeCssClass" - ref="code" - v-text="code"> - </pre> - </div> -</template> - <script> import Prism from '../../lib/highlight'; import Prompt from '../prompt.vue'; @@ -55,3 +41,17 @@ }, }; </script> + +<template> + <div :class="type"> + <prompt + :type="promptType" + :count="count" /> + <pre + class="language-python" + :class="codeCssClass" + ref="code" + v-text="code"> + </pre> + </div> +</template> diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 814d2ea92b4..82c51a1068c 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,10 +1,3 @@ -<template> - <div class="cell text-cell"> - <prompt /> - <div class="markdown" v-html="markdown"></div> - </div> -</template> - <script> /* global katex */ import marked from 'marked'; @@ -95,6 +88,13 @@ }; </script> +<template> + <div class="cell text-cell"> + <prompt /> + <div class="markdown" v-html="markdown"></div> + </div> +</template> + <style> .markdown .katex { display: block; diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index 0f39cd138df..2110a9de7ed 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -1,10 +1,3 @@ -<template> - <div class="output"> - <prompt /> - <div v-html="rawCode"></div> - </div> -</template> - <script> import Prompt from '../prompt.vue'; @@ -20,3 +13,10 @@ export default { }, }; </script> + +<template> + <div class="output"> + <prompt /> + <div v-html="rawCode"></div> + </div> +</template> diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue index f3b873bbc0f..fbb39ea6e2d 100644 --- a/app/assets/javascripts/notebook/cells/output/image.vue +++ b/app/assets/javascripts/notebook/cells/output/image.vue @@ -1,11 +1,3 @@ -<template> - <div class="output"> - <prompt /> - <img - :src="'data:' + outputType + ';base64,' + rawCode" /> - </div> -</template> - <script> import Prompt from '../prompt.vue'; @@ -25,3 +17,11 @@ export default { }, }; </script> + +<template> + <div class="output"> + <prompt /> + <img + :src="'data:' + outputType + ';base64,' + rawCode" /> + </div> +</template> diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue index 23c9ea78939..05af0bf1e8e 100644 --- a/app/assets/javascripts/notebook/cells/output/index.vue +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -1,12 +1,3 @@ -<template> - <component :is="componentName" - type="output" - :outputType="outputType" - :count="count" - :raw-code="rawCode" - :code-css-class="codeCssClass" /> -</template> - <script> import CodeCell from '../code/index.vue'; import Html from './html.vue'; @@ -81,3 +72,12 @@ export default { }, }; </script> + +<template> + <component :is="componentName" + type="output" + :outputType="outputType" + :count="count" + :raw-code="rawCode" + :code-css-class="codeCssClass" /> +</template> diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue index 4540e4248d8..039fb99293d 100644 --- a/app/assets/javascripts/notebook/cells/prompt.vue +++ b/app/assets/javascripts/notebook/cells/prompt.vue @@ -1,11 +1,3 @@ -<template> - <div class="prompt"> - <span v-if="type && count"> - {{ type }} [{{ count }}]: - </span> - </div> -</template> - <script> export default { props: { @@ -21,6 +13,14 @@ }; </script> +<template> + <div class="prompt"> + <span v-if="type && count"> + {{ type }} [{{ count }}]: + </span> + </div> +</template> + <style scoped> .prompt { padding: 0 10px; diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue index fd62c1231ef..e88806431af 100644 --- a/app/assets/javascripts/notebook/index.vue +++ b/app/assets/javascripts/notebook/index.vue @@ -1,14 +1,3 @@ -<template> - <div v-if="hasNotebook"> - <component - v-for="(cell, index) in cells" - :is="cellType(cell.cell_type)" - :cell="cell" - :key="index" - :code-css-class="codeCssClass" /> - </div> -</template> - <script> import { MarkdownCell, @@ -59,6 +48,17 @@ }; </script> +<template> + <div v-if="hasNotebook"> + <component + v-for="(cell, index) in cells" + :is="cellType(cell.cell_type)" + :cell="cell" + :key="index" + :code-css-class="codeCssClass" /> + </div> +</template> + <style> .cell, .input, diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue index fa7ac994058..ab8516296a8 100644 --- a/app/assets/javascripts/notes/components/issue_comment_form.vue +++ b/app/assets/javascripts/notes/components/issue_comment_form.vue @@ -7,10 +7,12 @@ import TaskList from '../../task_list'; import * as constants from '../constants'; import eventHub from '../event_hub'; - import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue'; + import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; + import issueDiscussionLockedWidget from './issue_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 issuableStateMixin from '../mixins/issuable_state'; export default { name: 'issueCommentForm', @@ -26,8 +28,9 @@ }; }, components: { - confidentialIssue, + issueWarning, issueNoteSignedOutWidget, + issueDiscussionLockedWidget, markdownField, userAvatarLink, }, @@ -55,6 +58,9 @@ isIssueOpen() { return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; }, + canCreateNote() { + return this.getIssueData.current_user.can_create_note; + }, issueActionButtonTitle() { if (this.note.length) { const actionText = this.isIssueOpen ? 'close' : 'reopen'; @@ -90,9 +96,6 @@ endpoint() { return this.getIssueData.create_note_path; }, - isConfidentialIssue() { - return this.getIssueData.confidential; - }, }, methods: { ...mapActions([ @@ -220,6 +223,9 @@ }); }, }, + mixins: [ + issuableStateMixin, + ], mounted() { // jQuery is needed here because it is a custom event being dispatched with jQuery. $(document).on('issuable:change', (e, isClosed) => { @@ -235,6 +241,7 @@ <template> <div> <issue-note-signed-out-widget v-if="!isLoggedIn" /> + <issue-discussion-locked-widget v-else-if="!canCreateNote" /> <ul v-else class="notes notes-form timeline"> @@ -253,15 +260,22 @@ <div class="timeline-content timeline-content-form"> <form ref="commentForm" - class="new-note js-quick-submit common-note-form gfm-form js-main-target-form"> - <confidentialIssue v-if="isConfidentialIssue" /> + class="new-note js-quick-submit common-note-form gfm-form js-main-target-form" + > + <div class="error-alert"></div> + + <issue-warning + v-if="hasWarning(getIssueData)" + :is-locked="isLocked(getIssueData)" + :is-confidential="isConfidential(getIssueData)" + /> + <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" :add-spacing-classes="false" - :is-confidential-issue="isConfidentialIssue" ref="markdownField"> <textarea id="note-body" @@ -272,6 +286,7 @@ v-model="note" ref="textarea" slot="textarea" + :disabled="isSubmitting" placeholder="Write a comment or drag your files here..." @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleSave()"> diff --git a/app/assets/javascripts/notes/components/issue_discussion_locked_widget.vue b/app/assets/javascripts/notes/components/issue_discussion_locked_widget.vue new file mode 100644 index 00000000000..e73ec2aaf71 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_discussion_locked_widget.vue @@ -0,0 +1,19 @@ +<script> + export default { + computed: { + lockIcon() { + return gl.utils.spriteIcon('lock'); + }, + }, + }; + +</script> + +<template> + <div class="disabled-comment text-center"> + <span class="issuable-note-warning"> + <span class="icon" v-html="lockIcon"></span> + <span>This issue is locked. Only <b>project members</b> can comment.</span> + </span> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/issue_note_form.vue index 626c0f2ce18..e2539d6b89d 100644 --- a/app/assets/javascripts/notes/components/issue_note_form.vue +++ b/app/assets/javascripts/notes/components/issue_note_form.vue @@ -1,8 +1,9 @@ <script> import { mapGetters } from 'vuex'; import eventHub from '../event_hub'; - import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue'; + import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; + import issuableStateMixin from '../mixins/issuable_state'; export default { name: 'issueNoteForm', @@ -39,12 +40,13 @@ }; }, components: { - confidentialIssue, + issueWarning, markdownField, }, computed: { ...mapGetters([ 'getDiscussionLastNote', + 'getIssueData', 'getIssueDataByProp', 'getNotesDataByProp', 'getUserDataByProp', @@ -67,9 +69,6 @@ isDisabled() { return !this.note.length || this.isSubmitting; }, - isConfidentialIssue() { - return this.getIssueDataByProp('confidential'); - }, }, methods: { handleUpdate() { @@ -95,6 +94,9 @@ this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note); }, }, + mixins: [ + issuableStateMixin, + ], mounted() { this.$refs.textarea.focus(); }, @@ -125,7 +127,13 @@ <div class="flash-container timeline-content"></div> <form class="edit-note common-note-form js-quick-submit gfm-form"> - <confidentialIssue v-if="isConfidentialIssue" /> + + <issue-warning + v-if="hasWarning(getIssueData)" + :is-locked="isLocked(getIssueData)" + :is-confidential="isConfidential(getIssueData)" + /> + <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js new file mode 100644 index 00000000000..97f3ea0d5de --- /dev/null +++ b/app/assets/javascripts/notes/mixins/issuable_state.js @@ -0,0 +1,15 @@ +export default { + methods: { + isConfidential(issue) { + return !!issue.confidential; + }, + + isLocked(issue) { + return !!issue.discussion_locked; + }, + + hasWarning(issue) { + return this.isConfidential(issue) || this.isLocked(issue); + }, + }, +}; diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index b874e484d45..c8a2f778ee8 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -1,13 +1,3 @@ -<template> - <div class="pdf-viewer" v-if="hasPDF"> - <page v-for="(page, index) in pages" - :key="index" - :v-if="!loading" - :page="page" - :number="index + 1" /> - </div> -</template> - <script> import pdfjsLib from 'vendor/pdf'; import workerSrc from 'vendor/pdf.worker.min'; @@ -64,6 +54,16 @@ }; </script> +<template> + <div class="pdf-viewer" v-if="hasPDF"> + <page v-for="(page, index) in pages" + :key="index" + :v-if="!loading" + :page="page" + :number="index + 1" /> + </div> +</template> + <style> .pdf-viewer { background: url('./assets/img/bg.gif'); diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue index 7b74ee4eb2e..be38f7cc129 100644 --- a/app/assets/javascripts/pdf/page/index.vue +++ b/app/assets/javascripts/pdf/page/index.vue @@ -1,10 +1,3 @@ -<template> - <canvas - class="pdf-page" - ref="canvas" - :data-page="number" /> -</template> - <script> export default { props: { @@ -48,6 +41,13 @@ }; </script> +<template> + <canvas + class="pdf-page" + ref="canvas" + :data-page="number" /> +</template> + <style> .pdf-page { margin: 8px auto 0 auto; diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 76b97af39f1..9da0aac50a1 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -72,6 +72,13 @@ :title="pipeline.yaml_errors"> yaml invalid </span> + <span + v-if="pipeline.flags.failure_reason" + v-tooltip + class="js-pipeline-url-failure label label-danger" + :title="pipeline.failure_reason"> + error + </span> <a v-if="pipeline.flags.auto_devops" tabindex="0" diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue new file mode 100644 index 00000000000..b2b34cb83e1 --- /dev/null +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -0,0 +1,146 @@ +<script> + import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; + import { __, s__, sprintf } from '../../../locale'; + import csrf from '../../../lib/utils/csrf'; + + export default { + props: { + actionUrl: { + type: String, + required: true, + }, + confirmWithPassword: { + type: Boolean, + required: true, + }, + username: { + type: String, + required: true, + }, + }, + data() { + return { + enteredPassword: '', + enteredUsername: '', + isOpen: false, + }; + }, + components: { + popupDialog, + }, + computed: { + csrfToken() { + return csrf.token; + }, + inputLabel() { + let confirmationValue; + if (this.confirmWithPassword) { + confirmationValue = __('password'); + } else { + confirmationValue = __('username'); + } + + confirmationValue = `<code>${confirmationValue}</code>`; + + return sprintf( + s__('Profiles|Type your %{confirmationValue} to confirm:'), + { confirmationValue }, + false, + ); + }, + text() { + return sprintf( + s__(`Profiles| +You are about to permanently delete %{yourAccount}, and all of the issues, merge requests, and groups linked to your account. +Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), + { + yourAccount: `<strong>${s__('Profiles|your account')}</strong>`, + deleteAccount: `<strong>${s__('Profiles|Delete Account')}</strong>`, + }, + false, + ); + }, + }, + methods: { + canSubmit() { + if (this.confirmWithPassword) { + return this.enteredPassword !== ''; + } + + return this.enteredUsername === this.username; + }, + onSubmit(status) { + if (status) { + if (!this.canSubmit()) { + return; + } + + this.$refs.form.submit(); + } + + this.toggleOpen(false); + }, + toggleOpen(isOpen) { + this.isOpen = isOpen; + }, + }, + }; +</script> + +<template> + <div> + <popup-dialog + v-if="isOpen" + :title="s__('Profiles|Delete your account?')" + :text="text" + :kind="`danger ${!canSubmit() && 'disabled'}`" + :primary-button-label="s__('Profiles|Delete account')" + @toggle="toggleOpen" + @submit="onSubmit"> + + <template slot="body" scope="props"> + <p v-html="props.text"></p> + + <form + ref="form" + :action="actionUrl" + method="post"> + + <input + type="hidden" + name="_method" + value="delete" /> + <input + type="hidden" + name="authenticity_token" + :value="csrfToken" /> + + <p id="input-label" v-html="inputLabel"></p> + + <input + v-if="confirmWithPassword" + name="password" + class="form-control" + type="password" + v-model="enteredPassword" + aria-labelledby="input-label" /> + <input + v-else + name="username" + class="form-control" + type="text" + v-model="enteredUsername" + aria-labelledby="input-label" /> + </form> + </template> + + </popup-dialog> + + <button + type="button" + class="btn btn-danger" + @click="toggleOpen(true)"> + {{ s__('Profiles|Delete account') }} + </button> + </div> +</template> diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js new file mode 100644 index 00000000000..635056e0eeb --- /dev/null +++ b/app/assets/javascripts/profile/account/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; + +import deleteAccountModal from './components/delete_account_modal.vue'; + +const deleteAccountModalEl = document.getElementById('delete-account-modal'); +// eslint-disable-next-line no-new +new Vue({ + el: deleteAccountModalEl, + components: { + deleteAccountModal, + }, + render(createElement) { + return createElement('delete-account-modal', { + props: { + actionUrl: deleteAccountModalEl.dataset.actionUrl, + confirmWithPassword: !!deleteAccountModalEl.dataset.confirmWithPassword, + username: deleteAccountModalEl.dataset.username, + }, + }); + }, +}); diff --git a/app/assets/javascripts/project_fork.js b/app/assets/javascripts/project_fork.js index 68cf47fd54e..65d46fa9a73 100644 --- a/app/assets/javascripts/project_fork.js +++ b/app/assets/javascripts/project_fork.js @@ -1,8 +1,7 @@ export default () => { - $('.fork-thumbnail a').on('click', function forkThumbnailClicked() { + $('.js-fork-thumbnail').on('click', function forkThumbnailClicked() { if ($(this).hasClass('disabled')) return false; - $('.fork-namespaces').hide(); - return $('.save-project-loader').show(); + return $('.js-fork-content').toggle(); }); }; diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 10da3783123..0a9fdb074e5 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -1,15 +1,22 @@ +import _ from 'underscore'; import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown'; import ProtectedBranchDropdown from './protected_branch_dropdown'; +import AccessorUtilities from '../lib/utils/accessor'; + +const PB_LOCAL_STORAGE_KEY = 'protected-branches-defaults'; export default class ProtectedBranchCreate { constructor() { this.$form = $('.js-new-protected-branch'); + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + this.currentProjectUserDefaults = {}; this.buildDropdowns(); } buildDropdowns() { const $allowedToMergeDropdown = this.$form.find('.js-allowed-to-merge'); const $allowedToPushDropdown = this.$form.find('.js-allowed-to-push'); + const $protectedBranchDropdown = this.$form.find('.js-protected-branch-select'); // Cache callback this.onSelectCallback = this.onSelect.bind(this); @@ -28,15 +35,13 @@ export default class ProtectedBranchCreate { onSelect: this.onSelectCallback, }); - // Select default - $allowedToPushDropdown.data('glDropdown').selectRowAtIndex(0); - $allowedToMergeDropdown.data('glDropdown').selectRowAtIndex(0); - // Protected branch dropdown this.protectedBranchDropdown = new ProtectedBranchDropdown({ - $dropdown: this.$form.find('.js-protected-branch-select'), + $dropdown: $protectedBranchDropdown, onSelect: this.onSelectCallback, }); + + this.loadPreviousSelection($allowedToMergeDropdown.data('glDropdown'), $allowedToPushDropdown.data('glDropdown')); } // This will run after clicked callback @@ -45,7 +50,41 @@ export default class ProtectedBranchCreate { const $branchInput = this.$form.find('input[name="protected_branch[name]"]'); const $allowedToMergeInput = this.$form.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]'); const $allowedToPushInput = this.$form.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]'); + const completedForm = !( + $branchInput.val() && + $allowedToMergeInput.length && + $allowedToPushInput.length + ); + + this.savePreviousSelection($allowedToMergeInput.val(), $allowedToPushInput.val()); + this.$form.find('input[type="submit"]').attr('disabled', completedForm); + } + + loadPreviousSelection(mergeDropdown, pushDropdown) { + let mergeIndex = 0; + let pushIndex = 0; + if (this.isLocalStorageAvailable) { + const savedDefaults = JSON.parse(window.localStorage.getItem(PB_LOCAL_STORAGE_KEY)); + if (savedDefaults != null) { + mergeIndex = _.findLastIndex(mergeDropdown.fullData.roles, { + id: parseInt(savedDefaults.mergeSelection, 0), + }); + pushIndex = _.findLastIndex(pushDropdown.fullData.roles, { + id: parseInt(savedDefaults.pushSelection, 0), + }); + } + } + mergeDropdown.selectRowAtIndex(mergeIndex); + pushDropdown.selectRowAtIndex(pushIndex); + } - this.$form.find('input[type="submit"]').attr('disabled', !($branchInput.val() && $allowedToMergeInput.length && $allowedToPushInput.length)); + savePreviousSelection(mergeSelection, pushSelection) { + if (this.isLocalStorageAvailable) { + const branchDefaults = { + mergeSelection, + pushSelection, + }; + window.localStorage.setItem(PB_LOCAL_STORAGE_KEY, JSON.stringify(branchDefaults)); + } } } diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue new file mode 100644 index 00000000000..2d8ca443ea7 --- /dev/null +++ b/app/assets/javascripts/registry/components/app.vue @@ -0,0 +1,62 @@ +<script> + /* globals Flash */ + import { mapGetters, mapActions } from 'vuex'; + import '../../flash'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import store from '../stores'; + import collapsibleContainer from './collapsible_container.vue'; + import { errorMessages, errorMessagesTypes } from '../constants'; + + export default { + name: 'registryListApp', + props: { + endpoint: { + type: String, + required: true, + }, + }, + store, + components: { + collapsibleContainer, + loadingIcon, + }, + computed: { + ...mapGetters([ + 'isLoading', + 'repos', + ]), + }, + methods: { + ...mapActions([ + 'setMainEndpoint', + 'fetchRepos', + ]), + }, + created() { + this.setMainEndpoint(this.endpoint); + }, + mounted() { + this.fetchRepos() + .catch(() => Flash(errorMessages[errorMessagesTypes.FETCH_REPOS])); + }, + }; +</script> +<template> + <div> + <loading-icon + v-if="isLoading" + size="3" + /> + + <collapsible-container + v-else-if="!isLoading && repos.length" + v-for="(item, index) in repos" + :key="index" + :repo="item" + /> + + <p v-else-if="!isLoading && !repos.length"> + {{__("No container images stored for this project. Add one by following the instructions above.")}} + </p> + </div> +</template> diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue new file mode 100644 index 00000000000..41ea9742406 --- /dev/null +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -0,0 +1,131 @@ +<script> + /* globals Flash */ + import { mapActions } from 'vuex'; + import '../../flash'; + import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import tooltip from '../../vue_shared/directives/tooltip'; + import tableRegistry from './table_registry.vue'; + import { errorMessages, errorMessagesTypes } from '../constants'; + + export default { + name: 'collapsibeContainerRegisty', + props: { + repo: { + type: Object, + required: true, + }, + }, + components: { + clipboardButton, + loadingIcon, + tableRegistry, + }, + directives: { + tooltip, + }, + data() { + return { + isOpen: false, + }; + }, + computed: { + clipboardText() { + return `docker pull ${this.repo.location}`; + }, + }, + methods: { + ...mapActions([ + 'fetchRepos', + 'fetchList', + 'deleteRepo', + ]), + + toggleRepo() { + this.isOpen = !this.isOpen; + + if (this.isOpen) { + this.fetchList({ repo: this.repo }) + .catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY)); + } + }, + + handleDeleteRepository() { + this.deleteRepo(this.repo) + .then(() => this.fetchRepos()) + .catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); + }, + + showError(message) { + Flash((errorMessages[message])); + }, + }, + }; +</script> + +<template> + <div class="container-image"> + <div + class="container-image-head"> + <button + type="button" + @click="toggleRepo" + class="js-toggle-repo btn-link"> + <i + class="fa" + :class="{ + 'fa-chevron-right': !isOpen, + 'fa-chevron-up': isOpen, + }" + aria-hidden="true"> + </i> + {{repo.name}} + </button> + + <clipboard-button + v-if="repo.location" + :text="clipboardText" + :title="repo.location" + /> + + <div class="controls hidden-xs pull-right"> + <button + v-if="repo.canDelete" + type="button" + class="js-remove-repo btn btn-danger" + :title="s__('ContainerRegistry|Remove repository')" + :aria-label="s__('ContainerRegistry|Remove repository')" + v-tooltip + @click="handleDeleteRepository"> + <i + class="fa fa-trash" + aria-hidden="true"> + </i> + </button> + </div> + + </div> + + <loading-icon + v-if="repo.isLoading" + class="append-bottom-20" + size="2" + /> + + <div + v-else-if="!repo.isLoading && isOpen" + class="container-image-tags"> + + <table-registry + v-if="repo.list.length" + :repo="repo" + /> + + <div + v-else + class="nothing-here-block"> + {{s__("ContainerRegistry|No tags in Container Registry for this container image.")}} + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue new file mode 100644 index 00000000000..4ce1571b0aa --- /dev/null +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -0,0 +1,137 @@ +<script> + /* globals Flash */ + import { mapActions } from 'vuex'; + import { n__ } from '../../locale'; + import '../../flash'; + import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; + import tablePagination from '../../vue_shared/components/table_pagination.vue'; + import tooltip from '../../vue_shared/directives/tooltip'; + import timeagoMixin from '../../vue_shared/mixins/timeago'; + import { errorMessages, errorMessagesTypes } from '../constants'; + + export default { + props: { + repo: { + type: Object, + required: true, + }, + }, + components: { + clipboardButton, + tablePagination, + }, + mixins: [ + timeagoMixin, + ], + directives: { + tooltip, + }, + computed: { + shouldRenderPagination() { + return this.repo.pagination.total > this.repo.pagination.perPage; + }, + }, + methods: { + ...mapActions([ + 'fetchList', + 'deleteRegistry', + ]), + + layers(item) { + return item.layers ? n__('%d layer', '%d layers', item.layers) : ''; + }, + + handleDeleteRegistry(registry) { + this.deleteRegistry(registry) + .then(() => this.fetchList({ repo: this.repo })) + .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + }, + + onPageChange(pageNumber) { + this.fetchList({ repo: this.repo, page: pageNumber }) + .catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY)); + }, + + clipboardText(text) { + return `docker pull ${text}`; + }, + + showError(message) { + Flash((errorMessages[message])); + }, + }, + }; +</script> +<template> +<div> + <table class="table tags"> + <thead> + <tr> + <th>{{s__('ContainerRegistry|Tag')}}</th> + <th>{{s__('ContainerRegistry|Tag ID')}}</th> + <th>{{s__("ContainerRegistry|Size")}}</th> + <th>{{s__("ContainerRegistry|Created")}}</th> + <th></th> + </tr> + </thead> + <tbody> + <tr + v-for="(item, i) in repo.list" + :key="i"> + <td> + + {{item.tag}} + + <clipboard-button + v-if="item.location" + :title="item.location" + :text="clipboardText(item.location)" + /> + </td> + <td> + <span + v-tooltip + :title="item.revision" + data-placement="bottom"> + {{item.shortRevision}} + </span> + </td> + <td> + {{item.size}} + <template v-if="item.size && item.layers"> + · + </template> + {{layers(item)}} + </td> + + <td> + {{timeFormated(item.createdAt)}} + </td> + + <td class="content"> + <button + v-if="item.canDelete" + type="button" + class="js-delete-registry btn btn-danger hidden-xs pull-right" + :title="s__('ContainerRegistry|Remove tag')" + :aria-label="s__('ContainerRegistry|Remove tag')" + data-container="body" + v-tooltip + @click="handleDeleteRegistry(item)"> + <i + class="fa fa-trash" + aria-hidden="true"> + </i> + </button> + </td> + </tr> + </tbody> + </table> + + <table-pagination + v-if="shouldRenderPagination" + :change="onPageChange" + :page-info="repo.pagination" + /> +</div> +</template> diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/constants.js new file mode 100644 index 00000000000..712b0fade3d --- /dev/null +++ b/app/assets/javascripts/registry/constants.js @@ -0,0 +1,15 @@ +import { __ } from '../locale'; + +export const errorMessagesTypes = { + FETCH_REGISTRY: 'FETCH_REGISTRY', + FETCH_REPOS: 'FETCH_REPOS', + DELETE_REPO: 'DELETE_REPO', + DELETE_REGISTRY: 'DELETE_REGISTRY', +}; + +export const errorMessages = { + [errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'), + [errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'), + [errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'), + [errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'), +}; diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js new file mode 100644 index 00000000000..d8edff73f72 --- /dev/null +++ b/app/assets/javascripts/registry/index.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import registryApp from './components/app.vue'; +import Translate from '../vue_shared/translate'; + +Vue.use(Translate); + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#js-vue-registry-images', + components: { + registryApp, + }, + data() { + const dataset = document.querySelector(this.$options.el).dataset; + return { + endpoint: dataset.endpoint, + }; + }, + render(createElement) { + return createElement('registry-app', { + props: { + endpoint: this.endpoint, + }, + }); + }, +})); diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js new file mode 100644 index 00000000000..34ed40b8b65 --- /dev/null +++ b/app/assets/javascripts/registry/stores/actions.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import * as types from './mutation_types'; + +Vue.use(VueResource); + +export const fetchRepos = ({ commit, state }) => { + commit(types.TOGGLE_MAIN_LOADING); + + return Vue.http.get(state.endpoint) + .then(res => res.json()) + .then((response) => { + commit(types.TOGGLE_MAIN_LOADING); + commit(types.SET_REPOS_LIST, response); + }); +}; + +export const fetchList = ({ commit }, { repo, page }) => { + commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); + + return Vue.http.get(repo.tagsPath, { params: { page } }) + .then((response) => { + const headers = response.headers; + + return response.json().then((resp) => { + commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); + commit(types.SET_REGISTRY_LIST, { repo, resp, headers }); + }); + }); +}; + +export const deleteRepo = ({ commit }, repo) => Vue.http.delete(repo.destroyPath) + .then(res => res.json()); + +export const deleteRegistry = ({ commit }, image) => Vue.http.delete(image.destroyPath) + .then(res => res.json()); + +export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data); +export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING); diff --git a/app/assets/javascripts/registry/stores/getters.js b/app/assets/javascripts/registry/stores/getters.js new file mode 100644 index 00000000000..588f479c492 --- /dev/null +++ b/app/assets/javascripts/registry/stores/getters.js @@ -0,0 +1,2 @@ +export const isLoading = state => state.isLoading; +export const repos = state => state.repos; diff --git a/app/assets/javascripts/registry/stores/index.js b/app/assets/javascripts/registry/stores/index.js new file mode 100644 index 00000000000..78b67881210 --- /dev/null +++ b/app/assets/javascripts/registry/stores/index.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: { + isLoading: false, + endpoint: '', // initial endpoint to fetch the repos list + /** + * Each object in `repos` has the following strucure: + * { + * name: String, + * isLoading: Boolean, + * tagsPath: String // endpoint to request the list + * destroyPath: String // endpoit to delete the repo + * list: Array // List of the registry images + * } + * + * Each registry image inside `list` has the following structure: + * { + * tag: String, + * revision: String + * shortRevision: String + * size: Number + * layers: Number + * createdAt: String + * destroyPath: String // endpoit to delete each image + * } + */ + repos: [], + }, + actions, + getters, + mutations, +}); diff --git a/app/assets/javascripts/registry/stores/mutation_types.js b/app/assets/javascripts/registry/stores/mutation_types.js new file mode 100644 index 00000000000..2c69bf11807 --- /dev/null +++ b/app/assets/javascripts/registry/stores/mutation_types.js @@ -0,0 +1,7 @@ +export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT'; + +export const SET_REPOS_LIST = 'SET_REPOS_LIST'; +export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING'; + +export const SET_REGISTRY_LIST = 'SET_REGISTRY_LIST'; +export const TOGGLE_REGISTRY_LIST_LOADING = 'TOGGLE_REGISTRY_LIST_LOADING'; diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js new file mode 100644 index 00000000000..e40382e7afc --- /dev/null +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -0,0 +1,54 @@ +import * as types from './mutation_types'; +import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; + +export default { + + [types.SET_MAIN_ENDPOINT](state, endpoint) { + Object.assign(state, { endpoint }); + }, + + [types.SET_REPOS_LIST](state, list) { + Object.assign(state, { + repos: list.map(el => ({ + canDelete: !!el.destroy_path, + destroyPath: el.destroy_path, + id: el.id, + isLoading: false, + list: [], + location: el.location, + name: el.path, + tagsPath: el.tags_path, + })), + }); + }, + + [types.TOGGLE_MAIN_LOADING](state) { + Object.assign(state, { isLoading: !state.isLoading }); + }, + + [types.SET_REGISTRY_LIST](state, { repo, resp, headers }) { + const listToUpdate = state.repos.find(el => el.id === repo.id); + + const normalizedHeaders = normalizeHeaders(headers); + const pagination = parseIntPagination(normalizedHeaders); + + listToUpdate.pagination = pagination; + + listToUpdate.list = resp.map(element => ({ + tag: element.name, + revision: element.revision, + shortRevision: element.short_revision, + size: element.size, + layers: element.layers, + location: element.location, + createdAt: element.created_at, + destroyPath: element.destroy_path, + canDelete: !!element.destroy_path, + })); + }, + + [types.TOGGLE_REGISTRY_LIST_LOADING](state, list) { + const listToUpdate = state.repos.find(el => el.id === list.id); + listToUpdate.isLoading = !listToUpdate.isLoading; + }, +}; diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index d6c864cb976..cc60aa5939c 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -62,7 +62,7 @@ export default { :primary-button-label="__('Discard changes')" kind="warning" :title="__('Are you sure?')" - :body="__('Are you sure you want to discard your changes?')" + :text="__('Are you sure you want to discard your changes?')" @toggle="toggleDialogOpen" @submit="dialogSubmitted" /> diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue index 96d6a75bb61..02d9c775046 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/repo/components/repo_editor.vue @@ -63,12 +63,7 @@ const RepoEditor = { const lineNumber = e.target.position.lineNumber; if (e.target.element.classList.contains('line-numbers')) { location.hash = `L${lineNumber}`; - Store.activeLine = lineNumber; - - Helper.monacoInstance.setPosition({ - lineNumber: this.activeLine, - column: 1, - }); + Store.setActiveLine(lineNumber); } }, }, @@ -101,6 +96,15 @@ const RepoEditor = { this.setupEditor(); } }, + + activeLine() { + if (Helper.monacoInstance) { + Helper.monacoInstance.setPosition({ + lineNumber: this.activeLine, + column: 1, + }); + } + }, }, computed: { shouldHideEditor() { diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index 2fe369a4693..a87bef6084a 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -14,6 +14,11 @@ export default { highlightFile() { $(this.$el).find('.file-content').syntaxHighlight(); }, + highlightLine() { + if (Store.activeLine > -1) { + this.lineHighlighter.highlightHash(`#L${Store.activeLine}`); + } + }, }, mounted() { this.highlightFile(); @@ -26,8 +31,12 @@ export default { html() { this.$nextTick(() => { this.highlightFile(); + this.highlightLine(); }); }, + activeLine() { + this.highlightLine(); + }, }, }; </script> diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index 1e40814b95f..e0f3c33003a 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -18,22 +18,40 @@ export default { }, created() { - this.addPopEventListener(); + window.addEventListener('popstate', this.checkHistory); + }, + destroyed() { + window.removeEventListener('popstate', this.checkHistory); }, data: () => Store, methods: { - addPopEventListener() { - window.addEventListener('popstate', () => { - if (location.href.indexOf('#') > -1) return; - this.linkClicked({ + checkHistory() { + let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1); + if (!selectedFile) { + // Maybe it is not in the current tree but in the opened tabs + selectedFile = Helper.getFileFromPath(location.pathname); + } + + let lineNumber = null; + if (location.hash.indexOf('#L') > -1) lineNumber = Number(location.hash.substr(2)); + + if (selectedFile) { + if (selectedFile.url !== this.activeFile.url) { + this.fileClicked(selectedFile, lineNumber); + } else { + Store.setActiveLine(lineNumber); + } + } else { + // Not opened at all lets open new tab + this.fileClicked({ url: location.href, - }); - }); + }, lineNumber); + } }, - fileClicked(clickedFile) { + fileClicked(clickedFile, lineNumber) { let file = clickedFile; if (file.loading) return; file.loading = true; @@ -41,17 +59,20 @@ export default { if (file.type === 'tree' && file.opened) { file = Store.removeChildFilesOfTree(file); file.loading = false; + Store.setActiveLine(lineNumber); } else { const openFile = Helper.getFileFromPath(file.url); if (openFile) { file.loading = false; Store.setActiveFiles(openFile); + Store.setActiveLine(lineNumber); } else { Service.url = file.url; Helper.getContent(file) .then(() => { file.loading = false; Helper.scrollTabsRight(); + Store.setActiveLine(lineNumber); }) .catch(Helper.loadingError); } @@ -74,8 +95,8 @@ export default { <thead v-if="!isMini"> <tr> <th class="name">Name</th> - <th class="hidden-sm hidden-xs last-commit">Last Commit</th> - <th class="hidden-xs last-update text-right">Last Update</th> + <th class="hidden-sm hidden-xs last-commit">Last commit</th> + <th class="hidden-xs last-update text-right">Last update</th> </tr> </thead> <tbody> diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js index ac59a2bed23..7483f8bc305 100644 --- a/app/assets/javascripts/repo/helpers/repo_helper.js +++ b/app/assets/javascripts/repo/helpers/repo_helper.js @@ -254,7 +254,9 @@ const RepoHelper = { RepoHelper.key = RepoHelper.genKey(); - history.pushState({ key: RepoHelper.key }, '', url); + if (document.location.pathname !== url) { + history.pushState({ key: RepoHelper.key }, '', url); + } if (title) { document.title = title; diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js index 9a4fc40bc69..93b39cff27e 100644 --- a/app/assets/javascripts/repo/stores/repo_store.js +++ b/app/assets/javascripts/repo/stores/repo_store.js @@ -26,7 +26,7 @@ const RepoStore = { }, activeFile: Helper.getDefaultActiveFile(), activeFileIndex: 0, - activeLine: 0, + activeLine: -1, activeFileLabel: 'Raw', files: [], isCommitable: false, @@ -85,6 +85,7 @@ const RepoStore = { if (!file.loading) Helper.updateHistoryEntry(file.url, file.pageTitle || file.name); RepoStore.binary = file.binary; + RepoStore.setActiveLine(-1); }, setFileActivity(file, openedFile, i) { @@ -101,6 +102,10 @@ const RepoStore = { RepoStore.activeFileIndex = i; }, + setActiveLine(activeLine) { + if (!isNaN(activeLine)) RepoStore.activeLine = activeLine; + }, + setActiveToRaw() { RepoStore.activeFile.raw = false; // can't get vue to listen to raw for some reason so RepoStore for now. 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 8e7abdbffef..f2b1099a678 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -47,9 +47,9 @@ export default { </script> <template> - <div class="block confidentiality"> + <div class="block issuable-sidebar-item confidentiality"> <div class="sidebar-collapsed-icon"> - <i class="fa" :class="faEye" aria-hidden="true" data-hidden="true"></i> + <i class="fa" :class="faEye" aria-hidden="true"></i> </div> <div class="title hide-collapsed"> Confidentiality @@ -62,19 +62,19 @@ export default { Edit </a> </div> - <div class="value confidential-value hide-collapsed"> + <div class="value sidebar-item-value hide-collapsed"> <editForm v-if="edit" :toggle-form="toggleForm" :is-confidential="isConfidential" :update-confidential-attribute="updateConfidentialAttribute" /> - <div v-if="!isConfidential" class="no-value confidential-value"> - <i class="fa fa-eye is-not-confidential"></i> + <div v-if="!isConfidential" class="no-value sidebar-item-value"> + <i class="fa fa-eye sidebar-item-icon"></i> Not confidential </div> - <div v-else class="value confidential-value hide-collapsed"> - <i aria-hidden="true" data-hidden="true" class="fa fa-eye-slash is-confidential"></i> + <div v-else class="value sidebar-item-value hide-collapsed"> + <i aria-hidden="true" class="fa fa-eye-slash sidebar-item-icon is-active"></i> This issue is confidential </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 d578b663a54..dd17b5abd46 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -2,9 +2,6 @@ import editFormButtons from './edit_form_buttons.vue'; export default { - components: { - editFormButtons, - }, props: { isConfidential: { required: true, @@ -19,12 +16,16 @@ export default { type: Function, }, }, + + components: { + editFormButtons, + }, }; </script> <template> <div class="dropdown open"> - <div class="dropdown-menu confidential-warning-message"> + <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 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 97af4a3f505..7ed0619ee6b 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -15,7 +15,7 @@ export default { }, }, computed: { - onOrOff() { + toggleButtonText() { return this.isConfidential ? 'Turn Off' : 'Turn On'; }, updateConfidentialBool() { @@ -26,7 +26,7 @@ export default { </script> <template> - <div class="confidential-warning-message-actions"> + <div class="sidebar-item-warning-message-actions"> <button type="button" class="btn btn-default append-right-10" @@ -39,7 +39,7 @@ export default { class="btn btn-close" @click.prevent="updateConfidentialAttribute(updateConfidentialBool)" > - {{ onOrOff }} + {{ toggleButtonText }} </button> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue new file mode 100644 index 00000000000..c7a6edc7c70 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -0,0 +1,61 @@ +<script> +import editFormButtons from './edit_form_buttons.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; + +export default { + props: { + isLocked: { + required: true, + type: Boolean, + }, + + toggleForm: { + required: true, + type: Function, + }, + + updateLockedAttribute: { + required: true, + type: Function, + }, + + issuableType: { + required: true, + type: String, + }, + }, + + mixins: [ + issuableMixin, + ], + + components: { + editFormButtons, + }, +}; +</script> + +<template> + <div class="dropdown open"> + <div class="dropdown-menu sidebar-item-warning-message"> + <p class="text" v-if="isLocked"> + Unlock this {{ issuableDisplayName(issuableType) }}? + <strong>Everyone</strong> + will be able to comment. + </p> + + <p class="text" v-else> + Lock this {{ issuableDisplayName(issuableType) }}? + Only + <strong>project members</strong> + will be able to comment. + </p> + + <edit-form-buttons + :is-locked="isLocked" + :toggle-form="toggleForm" + :update-locked-attribute="updateLockedAttribute" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue new file mode 100644 index 00000000000..c3a553a7605 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -0,0 +1,50 @@ +<script> +export default { + props: { + isLocked: { + required: true, + type: Boolean, + }, + + toggleForm: { + required: true, + type: Function, + }, + + updateLockedAttribute: { + required: true, + type: Function, + }, + }, + + computed: { + buttonText() { + return this.isLocked ? this.__('Unlock') : this.__('Lock'); + }, + + toggleLock() { + return !this.isLocked; + }, + }, +}; +</script> + +<template> + <div class="sidebar-item-warning-message-actions"> + <button + type="button" + class="btn btn-default append-right-10" + @click="toggleForm" + > + {{ __('Cancel') }} + </button> + + <button + type="button" + class="btn btn-close" + @click.prevent="updateLockedAttribute(toggleLock)" + > + {{ buttonText }} + </button> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue new file mode 100644 index 00000000000..c4b2900e020 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -0,0 +1,120 @@ +<script> +/* global Flash */ +import editForm from './edit_form.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; + +export default { + props: { + isLocked: { + required: true, + type: Boolean, + }, + + isEditable: { + required: true, + type: Boolean, + }, + + mediator: { + required: true, + type: Object, + validator(mediatorObject) { + return mediatorObject.service && mediatorObject.service.update && mediatorObject.store; + }, + }, + + issuableType: { + required: true, + type: String, + }, + }, + + mixins: [ + issuableMixin, + ], + + components: { + editForm, + }, + + computed: { + lockIconClass() { + return this.isLocked ? 'fa-lock' : 'fa-unlock'; + }, + + isLockDialogOpen() { + return this.mediator.store.isLockDialogOpen; + }, + }, + + methods: { + toggleForm() { + this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; + }, + + updateLockedAttribute(locked) { + this.mediator.service.update(this.issuableType, { + discussion_locked: locked, + }) + .then(() => location.reload()) + .catch(() => Flash(this.__(`Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}`))); + }, + }, +}; +</script> + +<template> + <div class="block issuable-sidebar-item lock"> + <div class="sidebar-collapsed-icon"> + <i + class="fa" + :class="lockIconClass" + aria-hidden="true" + ></i> + </div> + + <div class="title hide-collapsed"> + Lock {{issuableDisplayName(issuableType) }} + <button + v-if="isEditable" + class="pull-right lock-edit btn btn-blank" + type="button" + @click.prevent="toggleForm" + > + {{ __('Edit') }} + </button> + </div> + + <div class="value sidebar-item-value hide-collapsed"> + <edit-form + v-if="isLockDialogOpen" + :toggle-form="toggleForm" + :is-locked="isLocked" + :update-locked-attribute="updateLockedAttribute" + :issuable-type="issuableType" + /> + + <div + v-if="isLocked" + class="value sidebar-item-value" + > + <i + aria-hidden="true" + class="fa fa-lock sidebar-item-icon is-active" + ></i> + {{ __('Locked') }} + </div> + + <div + v-else + class="no-value sidebar-item-value hide-collapsed" + > + <i + aria-hidden="true" + class="fa fa-unlock sidebar-item-icon" + ></i> + {{ __('Unlocked') }} + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 3d8972050a9..09b9d75c02d 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -1,46 +1,76 @@ import Vue from 'vue'; -import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; -import sidebarAssignees from './components/assignees/sidebar_assignees'; -import confidential from './components/confidential/confidential_issue_sidebar.vue'; +import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; +import SidebarAssignees from './components/assignees/sidebar_assignees'; +import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; +import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; +import Translate from '../vue_shared/translate'; import Mediator from './sidebar_mediator'; +Vue.use(Translate); + +function mountConfidentialComponent(mediator) { + const el = document.getElementById('js-confidential-entry-point'); + + if (!el) return; + + const dataNode = document.getElementById('js-confidential-issue-data'); + const initialData = JSON.parse(dataNode.innerHTML); + + const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar); + + new ConfidentialComp({ + propsData: { + isConfidential: initialData.is_confidential, + isEditable: initialData.is_editable, + service: mediator.service, + }, + }).$mount(el); +} + +function mountLockComponent(mediator) { + const el = document.getElementById('js-lock-entry-point'); + + if (!el) return; + + const dataNode = document.getElementById('js-lock-issue-data'); + const initialData = JSON.parse(dataNode.innerHTML); + + const LockComp = Vue.extend(LockIssueSidebar); + + new LockComp({ + propsData: { + isLocked: initialData.is_locked, + isEditable: initialData.is_editable, + mediator, + issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', + }, + }).$mount(el); +} + function domContentLoaded() { const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); const mediator = new Mediator(sidebarOptions); mediator.fetch(); - const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); - const confidentialEl = document.querySelector('#js-confidential-entry-point'); + const sidebarAssigneesEl = document.getElementById('js-vue-sidebar-assignees'); // Only create the sidebarAssignees vue app if it is found in the DOM // We currently do not use sidebarAssignees for the MR page if (sidebarAssigneesEl) { - new Vue(sidebarAssignees).$mount(sidebarAssigneesEl); + new Vue(SidebarAssignees).$mount(sidebarAssigneesEl); } - if (confidentialEl) { - const dataNode = document.getElementById('js-confidential-issue-data'); - const initialData = JSON.parse(dataNode.innerHTML); + mountConfidentialComponent(mediator); + mountLockComponent(mediator); - const ConfidentialComp = Vue.extend(confidential); - - new ConfidentialComp({ - propsData: { - isConfidential: initialData.is_confidential, - isEditable: initialData.is_editable, - service: mediator.service, - }, - }).$mount(confidentialEl); - - new SidebarMoveIssue( - mediator, - $('.js-move-issue'), - $('.js-move-issue-confirmation-button'), - ).init(); - } + new SidebarMoveIssue( + mediator, + $('.js-move-issue'), + $('.js-move-issue-confirmation-button'), + ).init(); - new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); + new Vue(SidebarTimeTracking).$mount('#issuable-time-tracker'); } document.addEventListener('DOMContentLoaded', domContentLoaded); diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index cc04a2a3fcf..d5d04103f3f 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -15,6 +15,7 @@ export default class SidebarStore { }; this.autocompleteProjects = []; this.moveToProjectId = 0; + this.isLockDialogOpen = false; SidebarStore.singleton = this; } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js index aaf9d3304a4..09561694939 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js @@ -7,7 +7,7 @@ export default { }, template: ` <div class="mr-widget-body media"> - <status-icon status="loading" showDisabledButton /> + <status-icon status="loading" :show-disabled-button="true" /> <div class="media-body space-children"> <span class="bold"> Checking ability to merge automatically diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js index dc252f8a9b7..5d468a085cb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js @@ -12,7 +12,7 @@ export default { <div class="mr-widget-body media"> <status-icon status="failed" - showDisabledButton /> + :show-disabled-button="true" /> <div class="media-body space-children"> <span v-if="mr.shouldBeRebased" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js index 1cb24549d53..c25d6c359bb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js @@ -51,7 +51,7 @@ export default { </span> </template> <template v-else> - <status-icon status="failed" showDisabledButton /> + <status-icon status="failed" :show-disabled-button="true" /> <div class="media-body space-children"> <span class="bold"> <span 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 index 9f0a359d01a..1bc0b7e0819 100644 --- 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 @@ -24,7 +24,7 @@ export default { }, template: ` <div class="mr-widget-body media"> - <status-icon status="failed" showDisabledButton /> + <status-icon status="failed" :show-disabled-button="true" /> <div class="media-body space-children"> <span class="bold js-branch-text"> <span class="capitalize"> 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 index 797511d4e3a..00047718201 100644 --- 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 @@ -7,7 +7,7 @@ export default { }, template: ` <div class="mr-widget-body media"> - <status-icon status="success" showDisabledButton /> + <status-icon status="success" :show-disabled-button="true" /> <div class="media-body space-children"> <span class="bold"> Ready to be merged automatically. 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 index 167a0d4613a..1cedf86e811 100644 --- 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 @@ -7,7 +7,7 @@ export default { }, template: ` <div class="mr-widget-body media"> - <status-icon status="failed" showDisabledButton /> + <status-icon status="failed" :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 diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js index c5be9a0530a..6853ba4b9f8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js @@ -7,7 +7,7 @@ export default { }, template: ` <div class="mr-widget-body media"> - <status-icon status="failed" showDisabledButton /> + <status-icon status="failed" :show-disabled-button="true" /> <div class="media-body space-children"> <span class="bold"> The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js index f83d3ca00dd..61734163b6e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js @@ -38,24 +38,40 @@ export default { return this.useCommitMessageWithDescription ? withoutDesc : withDesc; }, - mergeButtonClass() { - const defaultClass = 'btn btn-sm btn-success accept-merge-request'; - const failedClass = `${defaultClass} btn-danger`; - const inActionClass = `${defaultClass} btn-info`; + status() { const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; if (hasCI && !ciStatus) { - return failedClass; + return 'failed'; } else if (!pipeline) { - return defaultClass; + return 'success'; } else if (isPipelineActive) { - return inActionClass; + return 'pending'; } else if (isPipelineFailed) { + return 'failed'; + } + + return 'success'; + }, + mergeButtonClass() { + const defaultClass = 'btn btn-sm btn-success accept-merge-request'; + const failedClass = `${defaultClass} btn-danger`; + const inActionClass = `${defaultClass} btn-info`; + + if (this.status === 'failed') { return failedClass; + } else if (this.status === 'pending') { + return inActionClass; } return defaultClass; }, + iconClass() { + if (this.status === 'failed' || !this.commitMessage.length || !this.mr.isMergeAllowed || this.mr.preventMerge) { + return 'failed'; + } + return 'success'; + }, mergeButtonText() { if (this.isMergingImmediately) { return 'Merge in progress'; @@ -84,13 +100,8 @@ export default { }, }, methods: { - isMergeAllowed() { - return !this.mr.onlyAllowMergeIfPipelineSucceeds || - this.mr.isPipelinePassing || - this.mr.isPipelineSkipped; - }, shouldShowMergeControls() { - return this.isMergeAllowed() || this.shouldShowMergeWhenPipelineSucceedsText; + return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText; }, updateCommitMessage() { const cmwd = this.mr.commitMessageWithDescription; @@ -156,6 +167,7 @@ export default { eventHub.$emit('FetchActionsContent'); if (window.mergeRequest) { window.mergeRequest.updateStatusText('status-box-open', 'status-box-merged', 'Merged'); + window.mergeRequest.hideCloseButton(); window.mergeRequest.decreaseCounter(); } stopPolling(); @@ -208,7 +220,7 @@ export default { }, template: ` <div class="mr-widget-body media"> - <status-icon status="success" /> + <status-icon :status="iconClass" /> <div class="media-body"> <div class="mr-widget-body-controls media space-children"> <span class="btn-group append-bottom-5"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js index 89f38e5bd2a..af19cf6ab87 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js @@ -7,7 +7,7 @@ export default { }, template: ` <div class="mr-widget-body media"> - <status-icon status="failed" showDisabledButton /> + <status-icon status="failed" :show-disabled-button="true" /> <div class="media-body space-children"> <span class="bold"> The source branch HEAD has recently changed. Please reload the page and review the changes before merging diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js index d762ca6e640..a119ecbbdfe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js @@ -10,7 +10,7 @@ export default { }, template: ` <div class="mr-widget-body media"> - <status-icon status="failed" showDisabledButton /> + <status-icon status="failed" :show-disabled-button="true" /> <div class="media-body space-children"> <span class="bold"> There are unresolved discussions. Please resolve these discussions diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js index b11a06899cf..54be1fbe675 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js @@ -38,7 +38,7 @@ export default { }, template: ` <div class="mr-widget-body media"> - <status-icon status="failed" :showDisabledButton="Boolean(mr.removeWIPPath)" /> + <status-icon status="failed" :show-disabled-button="Boolean(mr.removeWIPPath)" /> <div class="media-body space-children"> <span class="bold"> This is a Work in Progress diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index e554082149b..c1f7e64f580 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -73,6 +73,7 @@ export default class MergeRequestStore { this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; this.hasSHAChanged = this.sha !== data.diff_head_sha; this.canBeMerged = data.can_be_merged || false; + this.isMergeAllowed = data.mergeable || false; this.mergeOngoing = data.merge_ongoing; // Cherry-pick and Revert actions related diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue new file mode 100644 index 00000000000..3a7143c450e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -0,0 +1,32 @@ +<script> + /** + * Falls back to the code used in `copy_to_clipboard.js` + */ + + export default { + name: 'clipboardButton', + props: { + text: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + }, + }; +</script> + +<template> + <button + type="button" + class="btn btn-transparent btn-clipboard" + :data-title="title" + :data-clipboard-text="text"> + <i + aria-hidden="true" + class="fa fa-clipboard"> + </i> + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue deleted file mode 100644 index 397d16331d5..00000000000 --- a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue +++ /dev/null @@ -1,16 +0,0 @@ -<script> - export default { - name: 'confidentialIssueWarning', - }; -</script> -<template> - <div class="confidential-issue-warning"> - <i - aria-hidden="true" - class="fa fa-eye-slash"> - </i> - <span> - This is a confidential issue. Your comment will not be visible to the public. - </span> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue new file mode 100644 index 00000000000..16c0a8efcd2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -0,0 +1,55 @@ +<script> + export default { + props: { + isLocked: { + type: Boolean, + default: false, + required: false, + }, + + isConfidential: { + type: Boolean, + default: false, + required: false, + }, + }, + + computed: { + iconClass() { + return { + 'fa-eye-slash': this.isConfidential, + 'fa-lock': this.isLocked, + }; + }, + + isLockedAndConfidential() { + return this.isConfidential && this.isLocked; + }, + }, + }; +</script> +<template> + <div class="issuable-note-warning"> + <i + aria-hidden="true" + class="fa icon" + :class="iconClass" + v-if="!isLockedAndConfidential" + ></i> + + <span v-if="isLockedAndConfidential"> + {{ __('This issue is confidential and locked.') }} + {{ __('People without permission will never get a notification and won\'t be able to comment.') }} + </span> + + <span v-else-if="isConfidential"> + {{ __('This is a confidential issue.') }} + {{ __('Your comment will not be visible to the public.') }} + </span> + + <span v-else-if="isLocked"> + {{ __('This issue is locked.') }} + {{ __('Only project members can comment.') }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/popup_dialog.vue index 994b33bc1c9..9279b50cd55 100644 --- a/app/assets/javascripts/vue_shared/components/popup_dialog.vue +++ b/app/assets/javascripts/vue_shared/components/popup_dialog.vue @@ -7,7 +7,7 @@ export default { type: String, required: true, }, - body: { + text: { type: String, required: true, }, @@ -63,7 +63,9 @@ export default { <h4 class="modal-title">{{this.title}}</h4> </div> <div class="modal-body"> - <p>{{this.body}}</p> + <slot name="body" :text="text"> + <p>{{text}}</p> + </slot> </div> <div class="modal-footer"> <button diff --git a/app/assets/javascripts/vue_shared/mixins/issuable.js b/app/assets/javascripts/vue_shared/mixins/issuable.js new file mode 100644 index 00000000000..263361587e0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/issuable.js @@ -0,0 +1,9 @@ +export default { + methods: { + issuableDisplayName(issuableType) { + const displayName = issuableType.replace(/_/, ' '); + + return this.__ ? this.__(displayName) : displayName; + }, + }, +}; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 74b846217bb..e8037c77aab 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -40,6 +40,7 @@ @import "framework/tables"; @import "framework/notes"; @import "framework/timeline"; +@import "framework/tooltips"; @import "framework/typography"; @import "framework/zen"; @import "framework/blank"; diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index bdcbd4021b3..f1aedc227f3 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -23,6 +23,7 @@ &.s60 { @include avatar-size(60px, 12px); } &.s70 { @include avatar-size(70px, 14px); } &.s90 { @include avatar-size(90px, 15px); } + &.s100 { @include avatar-size(100px, 15px); } &.s110 { @include avatar-size(110px, 15px); } &.s140 { @include avatar-size(140px, 15px); } &.s160 { @include avatar-size(160px, 20px); } @@ -78,6 +79,7 @@ &.s60 { font-size: 32px; line-height: 58px; } &.s70 { font-size: 34px; line-height: 70px; } &.s90 { font-size: 36px; line-height: 88px; } + &.s100 { font-size: 36px; line-height: 98px; } &.s110 { font-size: 40px; line-height: 108px; font-weight: $gl-font-weight-normal; } &.s140 { font-size: 72px; line-height: 138px; } &.s160 { font-size: 96px; line-height: 158px; } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index d178bc17462..c77160a678b 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -381,7 +381,11 @@ background: transparent; border: 0; + &:hover, + &:active, &:focus { outline: 0; + background: transparent; + box-shadow: none; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 706a9cffe87..96f9dda26c4 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -11,6 +11,7 @@ .prepend-top-10 { margin-top: 10px; } .prepend-top-default { margin-top: $gl-padding !important; } .prepend-top-20 { margin-top: 20px; } +.prepend-left-4 { margin-left: 4px; } .prepend-left-5 { margin-left: 5px; } .prepend-left-10 { margin-left: 10px; } .prepend-left-default { margin-left: $gl-padding; } @@ -129,11 +130,6 @@ span.update-author { } } -.user-mention { - color: $user-mention-color; - font-weight: $gl-font-weight-bold; -} - .field_with_errors { display: inline; } diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index dbdd5a4464b..34a35734acc 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -6,3 +6,14 @@ .gfm-commit_range { @extend .commit-sha; } + +.gfm-project_member { + padding: 0 2px; + border-radius: #{$border-radius-default / 2}; + background-color: $user-mention-bg; + + &:hover { + background-color: $user-mention-bg-hover; + text-decoration: none; + } +} diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index 6b69e8018be..a6bdcf46aa7 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -95,7 +95,7 @@ } } - .title { + .navbar .title { > a { &:hover, &:focus { diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 5b581780447..1cebd02df48 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -1,10 +1,17 @@ +.modal-header { + padding: #{3 * $grid-size} #{2 * $grid-size}; + + .page-title { + margin-top: 0; + } +} + .modal-body { position: relative; - padding: 15px; + padding: #{3 * $grid-size} #{2 * $grid-size}; .form-actions { - margin: -$gl-padding + 1; - margin-top: 15px; + margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size}; } .text-danger { diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 6c14e8b97e0..50f1445bc2e 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -48,31 +48,24 @@ } &:hover { - background-color: $white-normal; - border-color: $border-white-normal; + border-color: $gray-darkest; color: $gl-text-color; } } } -.select2-drop { - box-shadow: $select2-drop-shadow1 0 0 1px 0, $select2-drop-shadow2 0 2px 18px 0; - border-radius: $border-radius-default; - border: none; +.select2-drop, +.select2-drop.select2-drop-above { + box-shadow: 0 2px 4px $dropdown-shadow-color; + border-radius: $border-radius-base; + border: 1px solid $dropdown-border-color; min-width: 175px; + color: $gl-text-color; } -.select2-results .select2-result-label, -.select2-more-results { - padding: 10px 15px; -} - -.select2-drop { - color: $gl-grayish-blue; -} - -.select2-highlighted { - background: $gl-link-color !important; +.select2-drop.select2-drop-above.select2-drop-active { + border-top: 1px solid $dropdown-border-color; + margin-top: -6px; } .select2-results li.select2-result-with-children > .select2-result-label { @@ -87,13 +80,11 @@ } } -.select2-dropdown-open { +.select2-dropdown-open, +.select2-dropdown-open.select2-drop-above { .select2-choice { - border-color: $border-white-normal; + border-color: $gray-darkest; outline: 0; - background-image: none; - background-color: $white-dark; - box-shadow: $gl-btn-active-gradient; } } @@ -131,28 +122,14 @@ } } } - - &.select2-container-active .select2-choices, - &.select2-dropdown-open .select2-choices { - border-color: $border-white-normal; - box-shadow: $gl-btn-active-gradient; - } } .select2-drop-active { - margin-top: 6px; + margin-top: $dropdown-vertical-offset; font-size: 14px; - &.select2-drop-above { - margin-bottom: 8px; - } - .select2-results { max-height: 350px; - - .select2-highlighted { - background: $gl-primary; - } } } @@ -186,19 +163,35 @@ background-size: 16px 16px !important; } -.select2-results .select2-no-results, -.select2-results .select2-searching, -.select2-results .select2-ajax-error, -.select2-results .select2-selection-limit { - background: $gray-light; - display: list-item; - padding: 10px 15px; -} - - .select2-results { margin: 0; - padding: 10px 0; + padding: #{$gl-padding / 2} 0; + + .select2-no-results, + .select2-searching, + .select2-ajax-error, + .select2-selection-limit { + background: transparent; + padding: #{$gl-padding / 2} $gl-padding; + } + + .select2-result-label, + .select2-more-results { + padding: #{$gl-padding / 2} $gl-padding; + } + + .select2-highlighted { + background: transparent; + color: $gl-text-color; + + .select2-result-label { + background: $dropdown-item-hover-bg; + } + } + + .select2-result { + padding: 0 1px; + } } .ajax-users-select { @@ -265,56 +258,10 @@ min-width: 250px !important; } -// TODO: change global style -.ajax-project-dropdown, -.ajax-users-dropdown, -body[data-page="projects:edit"] #select2-drop, -body[data-page="projects:new"] #select2-drop, -body[data-page="projects:merge_requests:edit"] #select2-drop, -body[data-page="projects:blob:new"] #select2-drop, -body[data-page="profiles:show"] #select2-drop, -body[data-page="admin:groups:show"] #select2-drop, -body[data-page="projects:issues:show"] #select2-drop, -body[data-page="projects:blob:edit"] #select2-drop { - &.select2-drop { - border: 1px solid $dropdown-border-color; - border-radius: $border-radius-base; - color: $gl-text-color; - } - - &.select2-drop-above { - border-top: none; - margin-top: -4px; - } - - .select2-results { - .select2-no-results, - .select2-searching, - .select2-ajax-error, - .select2-selection-limit { - background: transparent; - } - - .select2-result { - padding: 0 1px; - - .select2-match { - font-weight: $gl-font-weight-bold; - text-decoration: none; - } - - .select2-result-label { - padding: #{$gl-padding / 2} $gl-padding; - } - - &.select2-highlighted { - background-color: transparent !important; - color: $gl-text-color; - - .select2-result-label { - background-color: $dropdown-item-hover-bg; - } - } - } +.select2-result-selectable, +.select2-result-unselectable { + .select2-match { + font-weight: $gl-font-weight-bold; + text-decoration: none; } } diff --git a/app/assets/stylesheets/framework/tooltips.scss b/app/assets/stylesheets/framework/tooltips.scss new file mode 100644 index 00000000000..93baf73cb78 --- /dev/null +++ b/app/assets/stylesheets/framework/tooltips.scss @@ -0,0 +1,7 @@ +.tooltip-inner { + font-size: $tooltip-font-size; + border-radius: $border-radius-default; + line-height: 16px; + font-weight: $gl-font-weight-normal; + padding: $gl-btn-padding; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 9bbda87dec9..2f9def51d91 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -1,6 +1,7 @@ /* * Layout */ +$grid-size: 8px; $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 250px; @@ -203,6 +204,11 @@ $code_font_size: 12px; $code_line_height: 1.6; /* + * Tooltips + */ +$tooltip-font-size: 12px; + +/* * Padding */ $gl-padding: 16px; @@ -262,7 +268,8 @@ $well-pre-bg: #eee; $well-pre-color: #555; $loading-color: #555; $update-author-color: #999; -$user-mention-color: #2fa0bb; +$user-mention-bg: rgba($blue-500, 0.044); +$user-mention-bg-hover: rgba($blue-500, 0.15); $time-color: #999; $project-member-show-color: #aaa; $gl-promo-color: #aaa; @@ -699,3 +706,9 @@ Project Templates Icons $rails: #c00; $node: #353535; $java: #70ad51; + +/* +Issuable warning +*/ +$issuable-warning-size: 24px; +$issuable-warning-icon-margin: 4px; diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss new file mode 100644 index 00000000000..5538e46a6c4 --- /dev/null +++ b/app/assets/stylesheets/pages/clusters.scss @@ -0,0 +1,9 @@ +.edit-cluster-form { + .clipboard-addon { + background-color: $white-light; + } + + .alert-block { + margin-bottom: 20px; + } +} diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss index 3266714396e..dfff3e15556 100644 --- a/app/assets/stylesheets/pages/container_registry.scss +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -9,6 +9,14 @@ .container-image-head { padding: 0 16px; line-height: 4em; + + .btn-link { + padding: 0; + + &:focus { + outline: none; + } + } } .table.tags { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 9362d80d4e6..3b5e411e2c5 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -207,10 +207,13 @@ } .prometheus-state { - margin-top: 10px; + max-width: 430px; + margin: 10px auto; + text-align: center; - .state-button-section { - margin-top: 10px; + .state-svg { + max-width: 80vw; + margin: 0 auto; } } @@ -288,8 +291,14 @@ fill: $black; } - .tick > text { - font-size: 12px; + .tick { + > line { + stroke: $gray-darker; + } + + > text { + font-size: 12px; + } } .text-metric-title { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7eb28354e6d..db3b7e89d7b 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -5,27 +5,29 @@ margin-right: auto; } -.is-confidential { +.issuable-warning-icon { color: $orange-600; background-color: $orange-100; border-radius: $border-radius-default; padding: 5px; - margin: 0 3px 0 -4px; + margin: 0 $btn-side-margin 0 0; + width: $issuable-warning-size; + height: $issuable-warning-size; + text-align: center; + + &:first-of-type { + margin-right: $issuable-warning-icon-margin; + } } -.is-not-confidential { +.sidebar-item-icon { border-radius: $border-radius-default; padding: 5px; margin: 0 3px 0 -4px; -} - -.confidentiality { - .is-not-confidential { - margin: auto; - } - .is-confidential { - margin: auto; + &.is-active { + color: $orange-600; + background-color: $orange-50; } } diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 74d9acb5490..420bca9ece5 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -101,7 +101,7 @@ } } -.confidential-issue-warning { +.issuable-note-warning { color: $orange-600; background-color: $orange-100; border-radius: $border-radius-default $border-radius-default 0 0; @@ -110,28 +110,52 @@ padding: 3px 12px; margin: auto; align-items: center; + + .icon { + margin-right: $issuable-warning-icon-margin; + } +} + +.disabled-comment .issuable-note-warning { + border: none; + border-radius: $label-border-radius; + padding-top: $gl-vert-padding; + padding-bottom: $gl-vert-padding; + + .icon svg { + position: relative; + top: 2px; + margin-right: $btn-xs-side-margin; + width: $gl-font-size; + height: $gl-font-size; + fill: $orange-600; + } } -.confidential-value { +.sidebar-item-value { .fa { background-color: inherit; } } -.confidential-warning-message { +.sidebar-item-warning-message { line-height: 1.5; padding: 16px; - .confidential-warning-message-actions { + .text { + color: $text-color; + } + + .sidebar-item-warning-message-actions { display: flex; - button { + .btn { flex-grow: 1; } } } -.confidential-issue-warning + .md-area { +.issuable-note-warning + .md-area { border-top-left-radius: 0; border-top-right-radius: 0; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 46d31e41ada..925fe4513ee 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -703,6 +703,12 @@ ul.notes { color: $note-disabled-comment-color; padding: 90px 0; + &.discussion-locked { + border: none; + background-color: $white-light; + } + + a { color: $gl-link-color; } diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 67abe6e88ed..eab39f698c3 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -108,6 +108,15 @@ } } +.subkeys-list { + @include basic-list; + + li { + padding: 3px 0; + border: none; + } +} + .key-list-item { .key-list-item-info { @media (min-width: $screen-sm-min) { @@ -392,11 +401,11 @@ table.u2f-registrations { } } -.gpg-email-badge { +.email-badge { display: inline; margin-right: $gl-padding / 2; - .gpg-email-badge-email { + .email-badge-email { display: inline; margin-right: $gl-padding / 4; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 1f7b6703909..a086c11324d 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -499,73 +499,56 @@ a.deploy-project-label { } } -.fork-namespaces { - .row { - -webkit-flex-wrap: wrap; - display: -webkit-flex; - display: flex; - flex-wrap: wrap; - justify-content: flex-start; +.fork-thumbnail { + height: 200px; + width: calc((100% / 2) - #{$gl-padding * 2}); - .fork-thumbnail { - border-radius: $border-radius-base; - background-color: $white-light; - border: 1px solid $border-white-light; - height: 202px; - margin: $gl-padding; - text-align: center; - width: 169px; + @media (min-width: $screen-md-min) { + width: calc((100% / 4) - #{$gl-padding * 2}); + } - &:hover:not(.disabled), - &.forked { - background-color: $row-hover; - border-color: $row-hover-border; - } + @media (min-width: $screen-lg-min) { + width: calc((100% / 5) - #{$gl-padding * 2}); + } - .no-avatar { - width: 100px; - height: 100px; - background-color: $gray-light; - border: 1px solid $white-normal; - margin: 0 auto; - border-radius: 50%; - - i { - font-size: 100px; - color: $white-normal; - } - } + &:hover:not(.disabled), + &.forked { + background-color: $row-hover; + border-color: $row-hover-border; + } - a { - display: block; - width: 100%; - height: 100%; - padding-top: $gl-padding; - color: $gl-text-color; - - &.disabled { - opacity: .3; - cursor: not-allowed; - - &:hover { - text-decoration: none; - } - } - - .caption { - min-height: 30px; - padding: $gl-padding 0; - } - } + .avatar-container, + .identicon { + float: none; + margin-left: auto; + margin-right: auto; + } - img { - border-radius: 50%; - max-width: 100px; - } + a { + display: block; + width: 100%; + height: 100%; + padding-top: $gl-padding; + text-decoration: none; + + &.disabled { + opacity: .3; + cursor: not-allowed; } } } +.fork-thumbnail-container { + display: flex; + flex-wrap: wrap; + margin-left: -$gl-padding; + margin-right: -$gl-padding; + + > h5 { + width: 100%; + } +} + .project-template, .project-import { .form-group { diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss index fe22d186af1..a355e2dee24 100644 --- a/app/assets/stylesheets/pages/settings_ci_cd.scss +++ b/app/assets/stylesheets/pages/settings_ci_cd.scss @@ -12,3 +12,7 @@ margin-left: 10px; } } + +.registry-placeholder { + min-height: 60px; +} diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 224eee90a3f..e2f6e511c86 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -169,6 +169,14 @@ } } + .tree-item-file-external-link { + margin-right: 4px; + + span { + text-decoration: inherit; + } + } + .tree_commit { max-width: 320px; |