summaryrefslogtreecommitdiff
path: root/app/assets
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/javascripts/api.js3
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/commons/vue.js (renamed from app/assets/javascripts/vue_shared/common_vue.js)1
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js8
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js4
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight.js61
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_helper.js57
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_options.js12
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js2
-rw-r--r--app/assets/javascripts/gl_dropdown.js14
-rw-r--r--app/assets/javascripts/main.js7
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue10
-rw-r--r--app/assets/javascripts/monitoring/components/graph.vue127
-rw-r--r--app/assets/javascripts/monitoring/components/graph/flag.vue15
-rw-r--r--app/assets/javascripts/monitoring/components/graph/legend.vue83
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/graph_row.vue41
-rw-r--r--app/assets/javascripts/monitoring/components/monitoring_paths.vue40
-rw-r--r--app/assets/javascripts/monitoring/mixins/monitoring_mixins.js4
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js70
-rw-r--r--app/assets/javascripts/monitoring/utils/measurements.js12
-rw-r--r--app/assets/javascripts/monitoring/utils/multiple_time_series.js80
-rw-r--r--app/assets/javascripts/notes.js8
-rw-r--r--app/assets/javascripts/project.js4
-rw-r--r--app/assets/javascripts/project_select.js42
-rw-r--r--app/assets/javascripts/projects_dropdown/components/app.vue157
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue57
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_item.vue96
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_search.vue63
-rw-r--r--app/assets/javascripts/projects_dropdown/components/search.vue64
-rw-r--r--app/assets/javascripts/projects_dropdown/constants.js10
-rw-r--r--app/assets/javascripts/projects_dropdown/event_hub.js3
-rw-r--r--app/assets/javascripts/projects_dropdown/index.js68
-rw-r--r--app/assets/javascripts/projects_dropdown/service/projects_service.js132
-rw-r--r--app/assets/javascripts/projects_dropdown/store/projects_store.js33
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue8
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/buttons.scss17
-rw-r--r--app/assets/stylesheets/framework/common.scss1
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss159
-rw-r--r--app/assets/stylesheets/framework/feature_highlight.scss94
-rw-r--r--app/assets/stylesheets/framework/header.scss13
-rw-r--r--app/assets/stylesheets/framework/selects.scss12
-rw-r--r--app/assets/stylesheets/framework/variables.scss3
-rw-r--r--app/assets/stylesheets/new_nav.scss270
-rw-r--r--app/assets/stylesheets/new_sidebar.scss10
-rw-r--r--app/assets/stylesheets/pages/environments.scss38
-rw-r--r--app/assets/stylesheets/pages/issuable.scss2
-rw-r--r--app/assets/stylesheets/pages/issues.scss8
-rw-r--r--app/assets/stylesheets/pages/note_form.scss2
-rw-r--r--app/assets/stylesheets/pages/projects.scss18
-rw-r--r--app/assets/stylesheets/pages/search.scss2
53 files changed, 1667 insertions, 392 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 78cb3def879..8acddd6194c 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -5,7 +5,7 @@ const Api = {
groupPath: '/api/:version/groups/:id.json',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
- projectsPath: '/api/:version/projects.json?simple=true',
+ projectsPath: '/api/:version/projects.json',
labelsPath: '/:namespace_path/:project_path/labels',
licensePath: '/api/:version/templates/licenses/:key',
gitignorePath: '/api/:version/templates/gitignores/:key',
@@ -58,6 +58,7 @@ const Api = {
const defaults = {
search: query,
per_page: 20,
+ simple: true,
};
if (gon.current_user_id) {
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index 6db8b3afbef..768453b28f1 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -2,3 +2,4 @@ import 'underscore';
import './polyfills';
import './jquery';
import './bootstrap';
+import './vue';
diff --git a/app/assets/javascripts/vue_shared/common_vue.js b/app/assets/javascripts/commons/vue.js
index eb2a6071fda..8b62d78c043 100644
--- a/app/assets/javascripts/vue_shared/common_vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import './vue_resource_interceptor';
if (process.env.NODE_ENV !== 'production') {
Vue.config.productionTip = false;
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
index c37249c060a..06ce84d7599 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -21,11 +21,13 @@ const DiffNoteAvatars = Vue.extend({
},
template: `
<div class="diff-comment-avatar-holders"
+ :class="discussionClassName"
v-show="notesCount !== 0">
<div v-if="!isVisible">
<!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image
v-for="note in notesSubset"
+ :key="note.id"
class="diff-comment-avatar js-diff-comment-avatar"
@click.native="clickedAvatar($event)"
:img-src="note.authorAvatar"
@@ -68,7 +70,8 @@ const DiffNoteAvatars = Vue.extend({
});
});
},
- destroyed() {
+ beforeDestroy() {
+ this.addNoCommentClass();
$(document).off('toggle.comments');
},
watch: {
@@ -85,6 +88,9 @@ const DiffNoteAvatars = Vue.extend({
},
},
computed: {
+ discussionClassName() {
+ return `js-diff-avatars-${this.discussionId}`;
+ },
notesSubset() {
let notes = [];
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
index 5decfc1dc01..0863c3406bd 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js
@@ -32,6 +32,10 @@ $(() => {
const tmpApp = new tmp().$mount();
$(this).replaceWith(tmpApp.$el);
+ $(tmpApp.$el).one('remove.vue', () => {
+ tmpApp.$destroy();
+ tmpApp.$el.remove();
+ });
});
const $components = $(COMPONENT_SELECTOR).filter(function () {
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js
new file mode 100644
index 00000000000..800ca05cd11
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight.js
@@ -0,0 +1,61 @@
+import Cookies from 'js-cookie';
+import _ from 'underscore';
+import {
+ getCookieName,
+ getSelector,
+ hidePopover,
+ setupDismissButton,
+ mouseenter,
+ mouseleave,
+} from './feature_highlight_helper';
+
+export const setupFeatureHighlightPopover = (id, debounceTimeout = 300) => {
+ const $selector = $(getSelector(id));
+ const $parent = $selector.parent();
+ const $popoverContent = $parent.siblings('.feature-highlight-popover-content');
+ const hideOnScroll = hidePopover.bind($selector);
+ const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout);
+
+ $selector
+ // Setup popover
+ .data('content', $popoverContent.prop('outerHTML'))
+ .popover({
+ html: true,
+ // Override the existing template to add custom CSS classes
+ template: `
+ <div class="popover feature-highlight-popover" role="tooltip">
+ <div class="arrow"></div>
+ <div class="popover-content"></div>
+ </div>
+ `,
+ })
+ .on('mouseenter', mouseenter)
+ .on('mouseleave', debouncedMouseleave)
+ .on('inserted.bs.popover', setupDismissButton)
+ .on('show.bs.popover', () => {
+ window.addEventListener('scroll', hideOnScroll);
+ })
+ .on('hide.bs.popover', () => {
+ window.removeEventListener('scroll', hideOnScroll);
+ })
+ // Display feature highlight
+ .removeAttr('disabled');
+};
+
+export const shouldHighlightFeature = (id) => {
+ const element = document.querySelector(getSelector(id));
+ const previouslyDismissed = Cookies.get(getCookieName(id)) === 'true';
+
+ return element && !previouslyDismissed;
+};
+
+export const highlightFeatures = (highlightOrder) => {
+ const featureId = highlightOrder.find(shouldHighlightFeature);
+
+ if (featureId) {
+ setupFeatureHighlightPopover(featureId);
+ return true;
+ }
+
+ return false;
+};
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
new file mode 100644
index 00000000000..9f741355cd7
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js
@@ -0,0 +1,57 @@
+import Cookies from 'js-cookie';
+
+export const getCookieName = cookieId => `feature-highlighted-${cookieId}`;
+export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`;
+
+export const showPopover = function showPopover() {
+ if (this.hasClass('js-popover-show')) {
+ return false;
+ }
+ this.popover('show');
+ this.addClass('disable-animation js-popover-show');
+
+ return true;
+};
+
+export const hidePopover = function hidePopover() {
+ if (!this.hasClass('js-popover-show')) {
+ return false;
+ }
+ this.popover('hide');
+ this.removeClass('disable-animation js-popover-show');
+
+ return true;
+};
+
+export const dismiss = function dismiss(cookieId) {
+ Cookies.set(getCookieName(cookieId), true);
+ hidePopover.call(this);
+ this.hide();
+};
+
+export const mouseleave = function mouseleave() {
+ if (!$('.popover:hover').length > 0) {
+ const $featureHighlight = $(this);
+ hidePopover.call($featureHighlight);
+ }
+};
+
+export const mouseenter = function mouseenter() {
+ const $featureHighlight = $(this);
+
+ const showedPopover = showPopover.call($featureHighlight);
+ if (showedPopover) {
+ $('.popover')
+ .on('mouseleave', mouseleave.bind($featureHighlight));
+ }
+};
+
+export const setupDismissButton = function setupDismissButton() {
+ const popoverId = this.getAttribute('aria-describedby');
+ const cookieId = this.dataset.highlight;
+ const $popover = $(this);
+ const dismissWrapper = dismiss.bind($popover, cookieId);
+
+ $(`#${popoverId} .dismiss-feature-highlight`)
+ .on('click', dismissWrapper);
+};
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_options.js b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
new file mode 100644
index 00000000000..fd48f2e87cc
--- /dev/null
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
@@ -0,0 +1,12 @@
+import { highlightFeatures } from './feature_highlight';
+import bp from '../breakpoints';
+
+const highlightOrder = ['issue-boards'];
+
+export default function domContentLoaded(order) {
+ if (bp.getBreakpointSize() === 'lg') {
+ highlightFeatures(order);
+ }
+}
+
+document.addEventListener('DOMContentLoaded', domContentLoaded.bind(this, highlightOrder));
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 1c5ca1d3cf9..23040cd9eb8 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -61,7 +61,7 @@ class DropdownHint extends gl.FilteredSearchDropdown {
.map(tokenKey => ({
icon: `fa-${tokenKey.icon}`,
hint: tokenKey.key,
- tag: `<${tokenKey.tag}>`,
+ tag: `:${tokenKey.tag}`,
type: tokenKey.type,
}));
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index d65bbc0d808..6f7671aa6fe 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -637,11 +637,15 @@ GitLabDropdown = (function() {
value = this.options.id ? this.options.id(data) : data.id;
fieldName = this.options.fieldName;
- if (value) { value = value.toString().replace(/'/g, '\\\''); }
-
- field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']");
- if (field.length) {
- selected = true;
+ if (value) {
+ value = value.toString().replace(/'/g, '\\\'');
+ field = this.dropdown.parent().find(`input[name='${fieldName}'][value='${value}']`);
+ if (field.length) {
+ selected = true;
+ }
+ } else {
+ field = this.dropdown.parent().find(`input[name='${fieldName}']`);
+ selected = !field.length;
}
}
// Set URL
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 6d7c7e3c930..f14458c8d41 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -102,6 +102,7 @@ import './label_manager';
import './labels';
import './labels_select';
import './layout_nav';
+import './feature_highlight/feature_highlight_options';
import LazyLoader from './lazy_loader';
import './line_highlighter';
import './logo';
@@ -131,6 +132,7 @@ import './project_new';
import './project_select';
import './project_show';
import './project_variables';
+import './projects_dropdown';
import './projects_list';
import './syntax_highlight';
import './render_math';
@@ -248,7 +250,10 @@ $(function () {
// Initialize popovers
$body.popover({
selector: '[data-toggle="popover"]',
- trigger: 'focus'
+ trigger: 'focus',
+ // set the viewport to the main content, excluding the navigation bar, so
+ // the navigation can't overlap the popover
+ viewport: '.page-with-sidebar'
});
$('.trigger-submit').on('change', function () {
return $(this).parents('form').submit();
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 74244faa5d9..b596c4f383f 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -4,7 +4,7 @@
import statusCodes from '../../lib/utils/http_status';
import MonitoringService from '../services/monitoring_service';
import GraphGroup from './graph_group.vue';
- import GraphRow from './graph_row.vue';
+ import Graph from './graph.vue';
import EmptyState from './empty_state.vue';
import MonitoringStore from '../stores/monitoring_store';
import eventHub from '../event_hub';
@@ -32,8 +32,8 @@
},
components: {
+ Graph,
GraphGroup,
- GraphRow,
EmptyState,
},
@@ -127,10 +127,10 @@
:key="index"
:name="groupData.group"
>
- <graph-row
- v-for="(row, index) in groupData.metrics"
+ <graph
+ v-for="(graphData, index) in groupData.metrics"
:key="index"
- :row-data="row"
+ :graph-data="graphData"
:update-aspect-ratio="updateAspectRatio"
:deployment-data="store.deploymentData"
/>
diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue
index 6f6da9e1463..cde2ff7ca2a 100644
--- a/app/assets/javascripts/monitoring/components/graph.vue
+++ b/app/assets/javascripts/monitoring/components/graph.vue
@@ -3,11 +3,12 @@
import GraphLegend from './graph/legend.vue';
import GraphFlag from './graph/flag.vue';
import GraphDeployment from './graph/deployment.vue';
+ import monitoringPaths from './monitoring_paths.vue';
import MonitoringMixin from '../mixins/monitoring_mixins';
import eventHub from '../event_hub';
import measurements from '../utils/measurements';
- import { formatRelevantDigits } from '../../lib/utils/number_utils';
import { timeScaleFormat } from '../utils/date_time_formatters';
+ import createTimeSeries from '../utils/multiple_time_series';
import bp from '../../breakpoints';
const bisectDate = d3.bisector(d => d.time).left;
@@ -18,10 +19,6 @@
type: Object,
required: true,
},
- classType: {
- type: String,
- required: true,
- },
updateAspectRatio: {
type: Boolean,
required: true,
@@ -36,32 +33,29 @@
data() {
return {
+ baseGraphHeight: 450,
+ baseGraphWidth: 600,
graphHeight: 450,
graphWidth: 600,
graphHeightOffset: 120,
- xScale: {},
- yScale: {},
margin: {},
- data: [],
unitOfDisplay: '',
areaColorRgb: '#8fbce8',
lineColorRgb: '#1f78d1',
yAxisLabel: '',
legendTitle: '',
reducedDeploymentData: [],
- area: '',
- line: '',
measurements: measurements.large,
currentData: {
time: new Date(),
value: 0,
},
- currentYCoordinate: 0,
+ currentDataIndex: 0,
currentXCoordinate: 0,
currentFlagPosition: 0,
- metricUsage: '',
showFlag: false,
showDeployInfo: true,
+ timeSeries: [],
};
},
@@ -69,16 +63,17 @@
GraphLegend,
GraphFlag,
GraphDeployment,
+ monitoringPaths,
},
computed: {
outterViewBox() {
- return `0 0 ${this.graphWidth} ${this.graphHeight}`;
+ return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`;
},
innerViewBox() {
- if ((this.graphWidth - 150) > 0) {
- return `0 0 ${this.graphWidth - 150} ${this.graphHeight}`;
+ if ((this.baseGraphWidth - 150) > 0) {
+ return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
}
return '0 0 0 0';
},
@@ -89,7 +84,7 @@
paddingBottomRootSvg() {
return {
- paddingBottom: `${(Math.ceil(this.graphHeight * 100) / this.graphWidth) || 0}%`,
+ paddingBottom: `${(Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth) || 0}%`,
};
},
},
@@ -104,17 +99,16 @@
this.margin = measurements.small.margin;
this.measurements = measurements.small;
}
- this.data = query.result[0].values;
this.unitOfDisplay = query.unit || '';
this.yAxisLabel = this.graphData.y_label || 'Values';
this.legendTitle = query.label || 'Average';
this.graphWidth = this.$refs.baseSvg.clientWidth -
this.margin.left - this.margin.right;
this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
- if (this.data !== undefined) {
- this.renderAxesPaths();
- this.formatDeployments();
- }
+ this.baseGraphHeight = this.graphHeight;
+ this.baseGraphWidth = this.graphWidth;
+ this.renderAxesPaths();
+ this.formatDeployments();
},
handleMouseOverGraph(e) {
@@ -123,16 +117,17 @@
point.y = e.clientY;
point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse());
point.x = point.x += 7;
- const timeValueOverlay = this.xScale.invert(point.x);
- const overlayIndex = bisectDate(this.data, timeValueOverlay, 1);
- const d0 = this.data[overlayIndex - 1];
- const d1 = this.data[overlayIndex];
+ const firstTimeSeries = this.timeSeries[0];
+ const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x);
+ const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1);
+ const d0 = firstTimeSeries.values[overlayIndex - 1];
+ 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.currentXCoordinate = Math.floor(this.xScale(this.currentData.time));
+ this.currentDataIndex = evalTime ? overlayIndex : (overlayIndex - 1);
+ this.currentXCoordinate = Math.floor(firstTimeSeries.timeSeriesScaleX(this.currentData.time));
const currentDeployXPos = this.mouseOverDeployInfo(point.x);
- this.currentYCoordinate = this.yScale(this.currentData.value);
if (this.currentXCoordinate > (this.graphWidth - 200)) {
this.currentFlagPosition = this.currentXCoordinate - 103;
@@ -145,17 +140,25 @@
} else {
this.showFlag = true;
}
-
- this.metricUsage = `${formatRelevantDigits(this.currentData.value)} ${this.unitOfDisplay}`;
},
renderAxesPaths() {
+ this.timeSeries = createTimeSeries(this.graphData.queries[0].result,
+ 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]);
- this.yScale = d3.scale.linear()
+ const axisYScale = d3.scale.linear()
.range([this.graphHeight - this.graphHeightOffset, 0]);
- axisXScale.domain(d3.extent(this.data, d => d.time));
- this.yScale.domain([0, d3.max(this.data.map(d => d.value))]);
+
+ axisXScale.domain(d3.extent(this.timeSeries[0].values, d => d.time));
+ axisYScale.domain([0, d3.max(this.timeSeries[0].values.map(d => d.value))]);
const xAxis = d3.svg.axis()
.scale(axisXScale)
@@ -164,7 +167,7 @@
.orient('bottom');
const yAxis = d3.svg.axis()
- .scale(this.yScale)
+ .scale(axisYScale)
.ticks(measurements.yTicks)
.orient('left');
@@ -180,25 +183,6 @@
.attr('class', 'axis-tick');
} // Avoid adding the class to the first tick, to prevent coloring
}); // This will select all of the ticks once they're rendered
-
- this.xScale = d3.time.scale()
- .range([0, this.graphWidth - 70]);
-
- this.xScale.domain(d3.extent(this.data, d => d.time));
-
- const areaFunction = d3.svg.area()
- .x(d => this.xScale(d.time))
- .y0(this.graphHeight - this.graphHeightOffset)
- .y1(d => this.yScale(d.value))
- .interpolate('linear');
-
- const lineFunction = d3.svg.line()
- .x(d => this.xScale(d.time))
- .y(d => this.yScale(d.value));
-
- this.line = lineFunction(this.data);
-
- this.area = areaFunction(this.data);
},
},
@@ -219,12 +203,11 @@
},
};
</script>
+
<template>
- <div
- :class="classType">
- <h5
- class="text-center graph-title">
- {{graphData.title}}
+ <div class="prometheus-graph">
+ <h5 class="text-center graph-title">
+ {{graphData.title}}
</h5>
<div
class="prometheus-svg-container"
@@ -245,30 +228,25 @@
:graph-height="graphHeight"
:margin="margin"
:measurements="measurements"
- :area-color-rgb="areaColorRgb"
:legend-title="legendTitle"
:y-axis-label="yAxisLabel"
- :metric-usage="metricUsage"
+ :time-series="timeSeries"
+ :unit-of-display="unitOfDisplay"
+ :current-data-index="currentDataIndex"
/>
<svg
class="graph-data"
:viewBox="innerViewBox"
ref="graphData">
- <path
- class="metric-area"
- :d="area"
- :fill="areaColorRgb"
- transform="translate(-5, 20)">
- </path>
- <path
- class="metric-line"
- :d="line"
- :stroke="lineColorRgb"
- fill="none"
- stroke-width="2"
- transform="translate(-5, 20)">
- </path>
- <graph-deployment
+ <monitoring-paths
+ v-for="(path, index) in timeSeries"
+ :key="index"
+ :generated-line-path="path.linePath"
+ :generated-area-path="path.areaPath"
+ :line-color="path.lineColor"
+ :area-color="path.areaColor"
+ />
+ <monitoring-deployment
:show-deploy-info="showDeployInfo"
:deployment-data="reducedDeploymentData"
:graph-height="graphHeight"
@@ -277,7 +255,6 @@
<graph-flag
v-if="showFlag"
:current-x-coordinate="currentXCoordinate"
- :current-y-coordinate="currentYCoordinate"
:current-data="currentData"
:current-flag-position="currentFlagPosition"
:graph-height="graphHeight"
diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue
index c4d4647d240..a98e3d06c18 100644
--- a/app/assets/javascripts/monitoring/components/graph/flag.vue
+++ b/app/assets/javascripts/monitoring/components/graph/flag.vue
@@ -7,10 +7,6 @@
type: Number,
required: true,
},
- currentYCoordinate: {
- type: Number,
- required: true,
- },
currentFlagPosition: {
type: Number,
required: true,
@@ -60,16 +56,7 @@
:y2="calculatedHeight"
transform="translate(-5, 20)">
</line>
- <circle
- class="circle-metric"
- :fill="circleColorRgb"
- stroke="#000"
- :cx="currentXCoordinate"
- :cy="currentYCoordinate"
- r="5"
- transform="translate(-5, 20)">
- </circle>
- <svg
+ <svg
class="rect-text-metric"
:x="currentFlagPosition"
y="0">
diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue
index d08f9cbffd4..a43dad8e601 100644
--- a/app/assets/javascripts/monitoring/components/graph/legend.vue
+++ b/app/assets/javascripts/monitoring/components/graph/legend.vue
@@ -1,4 +1,6 @@
<script>
+ import { formatRelevantDigits } from '../../../lib/utils/number_utils';
+
export default {
props: {
graphWidth: {
@@ -17,10 +19,6 @@
type: Object,
required: true,
},
- areaColorRgb: {
- type: String,
- required: true,
- },
legendTitle: {
type: String,
required: true,
@@ -29,15 +27,25 @@
type: String,
required: true,
},
- metricUsage: {
+ timeSeries: {
+ type: Array,
+ required: true,
+ },
+ unitOfDisplay: {
type: String,
required: true,
},
+ currentDataIndex: {
+ type: Number,
+ required: true,
+ },
},
data() {
return {
yLabelWidth: 0,
yLabelHeight: 0,
+ seriesXPosition: 0,
+ metricUsageXPosition: 0,
};
},
computed: {
@@ -63,10 +71,28 @@
yPosition() {
return ((this.graphHeight - this.margin.top) + this.measurements.axisLabelLineOffset) || 0;
},
+
+ },
+ methods: {
+ translateLegendGroup(index) {
+ return `translate(0, ${12 * (index)})`;
+ },
+
+ formatMetricUsage(series) {
+ return `${formatRelevantDigits(series.values[this.currentDataIndex].value)} ${this.unitOfDisplay}`;
+ },
},
mounted() {
this.$nextTick(() => {
const bbox = this.$refs.ylabel.getBBox();
+ this.metricUsageXPosition = 0;
+ this.seriesXPosition = 0;
+ if (this.$refs.legendTitleSvg != null) {
+ this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width;
+ }
+ if (this.$refs.seriesTitleSvg != null) {
+ this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width;
+ }
this.yLabelWidth = bbox.width + 10; // Added some padding
this.yLabelHeight = bbox.height + 5;
});
@@ -121,24 +147,33 @@
dy=".35em">
Time
</text>
- <rect
- :fill="areaColorRgb"
- :width="measurements.legends.width"
- :height="measurements.legends.height"
- x="20"
- :y="graphHeight - measurements.legendOffset">
- </rect>
- <text
- class="text-metric-title"
- x="50"
- :y="graphHeight - 25">
- {{legendTitle}}
- </text>
- <text
- class="text-metric-usage"
- x="50"
- :y="graphHeight - 10">
- {{metricUsage}}
- </text>
+ <g class="legend-group"
+ v-for="(series, index) in timeSeries"
+ :key="index"
+ :transform="translateLegendGroup(index)">
+ <rect
+ :fill="series.areaColor"
+ :width="measurements.legends.width"
+ :height="measurements.legends.height"
+ x="20"
+ :y="graphHeight - measurements.legendOffset">
+ </rect>
+ <text
+ v-if="timeSeries.length > 1"
+ class="legend-metric-title"
+ ref="legendTitleSvg"
+ x="38"
+ :y="graphHeight - 30">
+ {{legendTitle}} Series {{index + 1}} {{formatMetricUsage(series)}}
+ </text>
+ <text
+ v-else
+ class="legend-metric-title"
+ ref="legendTitleSvg"
+ x="38"
+ :y="graphHeight - 30">
+ {{legendTitle}} {{formatMetricUsage(series)}}
+ </text>
+ </g>
</g>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index 32c90fda8cc..958f537d31b 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -14,7 +14,7 @@ export default {
<div class="panel-heading">
<h4>{{name}}</h4>
</div>
- <div class="panel-body">
+ <div class="panel-body prometheus-graph-group">
<slot />
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/components/graph_row.vue b/app/assets/javascripts/monitoring/components/graph_row.vue
deleted file mode 100644
index bdb9149c3b4..00000000000
--- a/app/assets/javascripts/monitoring/components/graph_row.vue
+++ /dev/null
@@ -1,41 +0,0 @@
-<script>
- import Graph from './graph.vue';
-
- export default {
- props: {
- rowData: {
- type: Array,
- required: true,
- },
- updateAspectRatio: {
- type: Boolean,
- required: true,
- },
- deploymentData: {
- type: Array,
- required: true,
- },
- },
- components: {
- Graph,
- },
- computed: {
- bootstrapClass() {
- return this.rowData.length >= 2 ? 'col-md-6' : 'col-md-12';
- },
- },
- };
-</script>
-
-<template>
- <div class="prometheus-row row">
- <graph
- v-for="(graphData, index) in rowData"
- :graph-data="graphData"
- :class-type="bootstrapClass"
- :key="index"
- :update-aspect-ratio="updateAspectRatio"
- :deployment-data="deploymentData"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/monitoring_paths.vue b/app/assets/javascripts/monitoring/components/monitoring_paths.vue
new file mode 100644
index 00000000000..043f1bf66bb
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/monitoring_paths.vue
@@ -0,0 +1,40 @@
+<script>
+ export default {
+ props: {
+ generatedLinePath: {
+ type: String,
+ required: true,
+ },
+ generatedAreaPath: {
+ type: String,
+ required: true,
+ },
+ lineColor: {
+ type: String,
+ required: true,
+ },
+ areaColor: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+<template>
+ <g>
+ <path
+ class="metric-area"
+ :d="generatedAreaPath"
+ :fill="areaColor"
+ transform="translate(-5, 20)">
+ </path>
+ <path
+ class="metric-line"
+ :d="generatedLinePath"
+ :stroke="lineColor"
+ fill="none"
+ stroke-width="1"
+ transform="translate(-5, 20)">
+ </path>
+ </g>
+</template>
diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
index 8e62fa63f13..345a0b37a76 100644
--- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
+++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
@@ -21,9 +21,9 @@ const mixins = {
formatDeployments() {
this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => {
const time = new Date(deployment.created_at);
- const xPos = Math.floor(this.xScale(time));
+ const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time));
- time.setSeconds(this.data[0].time.getSeconds());
+ time.setSeconds(this.timeSeries[0].values[0].time.getSeconds());
if (xPos >= 0) {
deploymentDataArray.push({
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
index 737c964f12e..7592af5878e 100644
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js
@@ -1,46 +1,36 @@
import _ from 'underscore';
-class MonitoringStore {
+function sortMetrics(metrics) {
+ return _.chain(metrics).sortBy('weight').sortBy('title').value();
+}
+
+function normalizeMetrics(metrics) {
+ return metrics.map(metric => ({
+ ...metric,
+ queries: metric.queries.map(query => ({
+ ...query,
+ result: query.result.map(result => ({
+ ...result,
+ values: result.values.map(([timestamp, value]) => ({
+ time: new Date(timestamp * 1000),
+ value,
+ })),
+ })),
+ })),
+ }));
+}
+
+export default class MonitoringStore {
constructor() {
this.groups = [];
this.deploymentData = [];
}
- // eslint-disable-next-line class-methods-use-this
- createArrayRows(metrics = []) {
- const currentMetrics = metrics;
- const availableMetrics = [];
- let metricsRow = [];
- let index = 1;
- Object.keys(currentMetrics).forEach((key) => {
- const metricValues = currentMetrics[key].queries[0].result[0].values;
- if (metricValues != null) {
- const literalMetrics = metricValues.map(metric => ({
- time: new Date(metric[0] * 1000),
- value: metric[1],
- }));
- currentMetrics[key].queries[0].result[0].values = literalMetrics;
- metricsRow.push(currentMetrics[key]);
- if (index % 2 === 0) {
- availableMetrics.push(metricsRow);
- metricsRow = [];
- }
- index = index += 1;
- }
- });
- if (metricsRow.length > 0) {
- availableMetrics.push(metricsRow);
- }
- return availableMetrics;
- }
-
storeMetrics(groups = []) {
- this.groups = groups.map((group) => {
- const currentGroup = group;
- currentGroup.metrics = _.chain(group.metrics).sortBy('weight').sortBy('title').value();
- currentGroup.metrics = this.createArrayRows(currentGroup.metrics);
- return currentGroup;
- });
+ this.groups = groups.map(group => ({
+ ...group,
+ metrics: normalizeMetrics(sortMetrics(group.metrics)),
+ }));
}
storeDeploymentData(deploymentData = []) {
@@ -48,14 +38,6 @@ class MonitoringStore {
}
getMetricsCount() {
- let metricsCount = 0;
- this.groups.forEach((group) => {
- group.metrics.forEach((metric) => {
- metricsCount = metricsCount += metric.length;
- });
- });
- return metricsCount;
+ return this.groups.reduce((count, group) => count + group.metrics.length, 0);
}
}
-
-export default MonitoringStore;
diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js
index 62cd19c86e1..ee3c45efacc 100644
--- a/app/assets/javascripts/monitoring/utils/measurements.js
+++ b/app/assets/javascripts/monitoring/utils/measurements.js
@@ -7,15 +7,15 @@ export default {
left: 40,
},
legends: {
- width: 15,
- height: 25,
+ width: 10,
+ height: 3,
},
backgroundLegend: {
width: 30,
height: 50,
},
axisLabelLineOffset: -20,
- legendOffset: 35,
+ legendOffset: 33,
},
large: { // This covers both md and lg screen sizes
margin: {
@@ -25,15 +25,15 @@ export default {
left: 80,
},
legends: {
- width: 20,
- height: 30,
+ width: 15,
+ height: 3,
},
backgroundLegend: {
width: 30,
height: 150,
},
axisLabelLineOffset: 20,
- legendOffset: 38,
+ legendOffset: 36,
},
xTicks: 8,
yTicks: 3,
diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
new file mode 100644
index 00000000000..05d551e917c
--- /dev/null
+++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
@@ -0,0 +1,80 @@
+import d3 from 'd3';
+import _ from 'underscore';
+
+export default function createTimeSeries(seriesData, graphWidth, graphHeight, graphHeightOffset) {
+ const maxValues = seriesData.map((timeSeries, index) => {
+ const maxValue = d3.max(timeSeries.values.map(d => d.value));
+ return {
+ maxValue,
+ index,
+ };
+ });
+
+ const maxValueFromSeries = _.max(maxValues, val => val.maxValue);
+
+ let timeSeriesNumber = 1;
+ let lineColor = '#1f78d1';
+ let areaColor = '#8fbce8';
+ return seriesData.map((timeSeries) => {
+ const timeSeriesScaleX = d3.time.scale()
+ .range([0, graphWidth - 70]);
+
+ const timeSeriesScaleY = d3.scale.linear()
+ .range([graphHeight - graphHeightOffset, 0]);
+
+ timeSeriesScaleX.domain(d3.extent(timeSeries.values, d => d.time));
+ timeSeriesScaleY.domain([0, maxValueFromSeries.maxValue]);
+
+ const lineFunction = d3.svg.line()
+ .x(d => timeSeriesScaleX(d.time))
+ .y(d => timeSeriesScaleY(d.value));
+
+ const areaFunction = d3.svg.area()
+ .x(d => timeSeriesScaleX(d.time))
+ .y0(graphHeight - graphHeightOffset)
+ .y1(d => timeSeriesScaleY(d.value))
+ .interpolate('linear');
+
+ switch (timeSeriesNumber) {
+ case 1:
+ lineColor = '#1f78d1';
+ areaColor = '#8fbce8';
+ break;
+ case 2:
+ lineColor = '#fc9403';
+ areaColor = '#feca81';
+ break;
+ case 3:
+ lineColor = '#db3b21';
+ areaColor = '#ed9d90';
+ break;
+ case 4:
+ lineColor = '#1aaa55';
+ areaColor = '#8dd5aa';
+ break;
+ case 5:
+ lineColor = '#6666c4';
+ areaColor = '#d1d1f0';
+ break;
+ default:
+ lineColor = '#1f78d1';
+ areaColor = '#8fbce8';
+ break;
+ }
+
+ if (timeSeriesNumber <= 5) {
+ timeSeriesNumber = timeSeriesNumber += 1;
+ } else {
+ timeSeriesNumber = 1;
+ }
+
+ return {
+ linePath: lineFunction(timeSeries.values),
+ areaPath: areaFunction(timeSeries.values),
+ timeSeriesScaleX,
+ values: timeSeries.values,
+ lineColor,
+ areaColor,
+ };
+ });
+}
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index b38a6abc8d1..a09270d6d24 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -464,7 +464,6 @@ export default class Notes {
}
renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
- var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
if (!avatarHolder.length) {
@@ -475,10 +474,6 @@ export default class Notes {
gl.diffNotesCompileComponents();
}
-
- if (commentButton.length) {
- commentButton.remove();
- }
}
/**
@@ -767,6 +762,7 @@ export default class Notes {
var $note, $notes;
$note = $(el);
$notes = $note.closest('.discussion-notes');
+ const discussionId = $('.notes', $notes).data('discussion-id');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
@@ -783,6 +779,8 @@ export default class Notes {
// "Discussions" tab
$notes.closest('.timeline-entry').remove();
+ $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
+
// The notes tr can contain multiple lists of notes, like on the parallel diff
if (notesTr.find('.discussion-notes').length > 1) {
$notes.remove();
diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js
index d7e3ab42f00..fe6602259e2 100644
--- a/app/assets/javascripts/project.js
+++ b/app/assets/javascripts/project.js
@@ -53,10 +53,6 @@ import Cookies from 'js-cookie';
return _this.changeProject($(e.currentTarget).val());
};
})(this));
- return $('.js-projects-dropdown-toggle').on('click', function(e) {
- e.preventDefault();
- return $('.js-projects-dropdown').select2('open');
- });
};
Project.prototype.changeProject = function(url) {
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 1b4ed6be90a..fb01390f91c 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -5,48 +5,6 @@ import ProjectSelectComboButton from './project_select_combo_button';
(function() {
this.ProjectSelect = (function() {
function ProjectSelect() {
- $('.js-projects-dropdown-toggle').each(function(i, dropdown) {
- var $dropdown;
- $dropdown = $(dropdown);
- return $dropdown.glDropdown({
- filterable: true,
- filterRemote: true,
- search: {
- fields: ['name_with_namespace']
- },
- data: function(term, callback) {
- var finalCallback, projectsCallback;
- var orderBy = $dropdown.data('order-by');
- finalCallback = function(projects) {
- return callback(projects);
- };
- if (this.includeGroups) {
- projectsCallback = function(projects) {
- var groupsCallback;
- groupsCallback = function(groups) {
- var data;
- data = groups.concat(projects);
- return finalCallback(data);
- };
- return Api.groups(term, {}, groupsCallback);
- };
- } else {
- projectsCallback = finalCallback;
- }
- if (this.groupId) {
- return Api.groupProjects(this.groupId, term, projectsCallback);
- } else {
- return Api.projects(term, { order_by: orderBy }, projectsCallback);
- }
- },
- url: function(project) {
- return project.web_url;
- },
- text: function(project) {
- return project.name_with_namespace;
- }
- });
- });
$('.ajax-project-select').each(function(i, select) {
var placeholder;
this.groupId = $(select).data('group-id');
diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue
new file mode 100644
index 00000000000..7606605be32
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/app.vue
@@ -0,0 +1,157 @@
+<script>
+import bs from '../../breakpoints';
+import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+import projectsListFrequent from './projects_list_frequent.vue';
+import projectsListSearch from './projects_list_search.vue';
+
+import search from './search.vue';
+
+export default {
+ components: {
+ search,
+ loadingIcon,
+ projectsListFrequent,
+ projectsListSearch,
+ },
+ props: {
+ currentProject: {
+ type: Object,
+ required: true,
+ },
+ store: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoadingProjects: false,
+ isFrequentsListVisible: false,
+ isSearchListVisible: false,
+ isLocalStorageFailed: false,
+ isSearchFailed: false,
+ searchQuery: '',
+ };
+ },
+ computed: {
+ frequentProjects() {
+ return this.store.getFrequentProjects();
+ },
+ searchProjects() {
+ return this.store.getSearchedProjects();
+ },
+ },
+ methods: {
+ toggleFrequentProjectsList(state) {
+ this.isLoadingProjects = !state;
+ this.isSearchListVisible = !state;
+ this.isFrequentsListVisible = state;
+ },
+ toggleSearchProjectsList(state) {
+ this.isLoadingProjects = !state;
+ this.isFrequentsListVisible = !state;
+ this.isSearchListVisible = state;
+ },
+ toggleLoader(state) {
+ this.isFrequentsListVisible = !state;
+ this.isSearchListVisible = !state;
+ this.isLoadingProjects = state;
+ },
+ fetchFrequentProjects() {
+ const screenSize = bs.getBreakpointSize();
+ if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) {
+ this.toggleSearchProjectsList(true);
+ } else {
+ this.toggleLoader(true);
+ this.isLocalStorageFailed = false;
+ const projects = this.service.getFrequentProjects();
+ if (projects) {
+ this.toggleFrequentProjectsList(true);
+ this.store.setFrequentProjects(projects);
+ } else {
+ this.isLocalStorageFailed = true;
+ this.toggleFrequentProjectsList(true);
+ this.store.setFrequentProjects([]);
+ }
+ }
+ },
+ fetchSearchedProjects(searchQuery) {
+ this.searchQuery = searchQuery;
+ this.toggleLoader(true);
+ this.service.getSearchedProjects(this.searchQuery)
+ .then(res => res.json())
+ .then((results) => {
+ this.toggleSearchProjectsList(true);
+ this.store.setSearchedProjects(results);
+ })
+ .catch(() => {
+ this.isSearchFailed = true;
+ this.toggleSearchProjectsList(true);
+ });
+ },
+ logCurrentProjectAccess() {
+ this.service.logProjectAccess(this.currentProject);
+ },
+ handleSearchClear() {
+ this.searchQuery = '';
+ this.toggleFrequentProjectsList(true);
+ this.store.clearSearchedProjects();
+ },
+ handleSearchFailure() {
+ this.isSearchFailed = true;
+ this.toggleSearchProjectsList(true);
+ },
+ },
+ created() {
+ if (this.currentProject.id) {
+ this.logCurrentProjectAccess();
+ }
+
+ eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
+ eventHub.$on('searchProjects', this.fetchSearchedProjects);
+ eventHub.$on('searchCleared', this.handleSearchClear);
+ eventHub.$on('searchFailed', this.handleSearchFailure);
+ },
+ beforeDestroy() {
+ eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
+ eventHub.$off('searchProjects', this.fetchSearchedProjects);
+ eventHub.$off('searchCleared', this.handleSearchClear);
+ eventHub.$off('searchFailed', this.handleSearchFailure);
+ },
+};
+</script>
+
+<template>
+ <div>
+ <search/>
+ <loading-icon
+ class="loading-animation prepend-top-20"
+ size="2"
+ v-if="isLoadingProjects"
+ :label="s__('ProjectsDropdown|Loading projects')"
+ />
+ <div
+ class="section-header"
+ v-if="isFrequentsListVisible"
+ >
+ {{ s__('ProjectsDropdown|Frequently visited') }}
+ </div>
+ <projects-list-frequent
+ v-if="isFrequentsListVisible"
+ :local-storage-failed="isLocalStorageFailed"
+ :projects="frequentProjects"
+ />
+ <projects-list-search
+ v-if="isSearchListVisible"
+ :search-failed="isSearchFailed"
+ :matcher="searchQuery"
+ :projects="searchProjects"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
new file mode 100644
index 00000000000..093554cd0bc
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
@@ -0,0 +1,57 @@
+<script>
+import { s__ } from '../../locale';
+import projectsListItem from './projects_list_item.vue';
+
+export default {
+ components: {
+ projectsListItem,
+ },
+ props: {
+ projects: {
+ type: Array,
+ required: true,
+ },
+ localStorageFailed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ isListEmpty() {
+ return this.projects.length === 0;
+ },
+ listEmptyMessage() {
+ return this.localStorageFailed ?
+ s__('ProjectsDropdown|This feature requires browser localStorage support') :
+ s__('ProjectsDropdown|Projects you visit often will appear here');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="projects-list-frequent-container"
+ >
+ <ul
+ class="list-unstyled"
+ >
+ <li
+ class="section-empty"
+ v-if="isListEmpty"
+ >
+ {{listEmptyMessage}}
+ </li>
+ <projects-list-item
+ v-else
+ v-for="(project, index) in projects"
+ :key="index"
+ :project-id="project.id"
+ :project-name="project.name"
+ :namespace="project.namespace"
+ :web-url="project.webUrl"
+ :avatar-url="project.avatarUrl"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
new file mode 100644
index 00000000000..fe5179de206
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
@@ -0,0 +1,96 @@
+<script>
+import identicon from '../../vue_shared/components/identicon.vue';
+
+export default {
+ components: {
+ identicon,
+ },
+ props: {
+ matcher: {
+ type: String,
+ required: false,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ projectName: {
+ type: String,
+ required: true,
+ },
+ namespace: {
+ type: String,
+ required: true,
+ },
+ webUrl: {
+ type: String,
+ required: true,
+ },
+ avatarUrl: {
+ required: true,
+ validator(value) {
+ return value === null || typeof value === 'string';
+ },
+ },
+ },
+ computed: {
+ hasAvatar() {
+ return this.avatarUrl !== null;
+ },
+ highlightedProjectName() {
+ if (this.matcher) {
+ const matcherRegEx = new RegExp(this.matcher, 'gi');
+ const matches = this.projectName.match(matcherRegEx);
+
+ if (matches && matches.length > 0) {
+ return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
+ }
+ }
+ return this.projectName;
+ },
+ },
+};
+</script>
+
+<template>
+ <li
+ class="projects-list-item-container"
+ >
+ <a
+ class="clearfix"
+ :href="webUrl"
+ >
+ <div
+ class="project-item-avatar-container"
+ >
+ <img
+ v-if="hasAvatar"
+ class="avatar s32"
+ :src="avatarUrl"
+ />
+ <identicon
+ v-else
+ size-class="s32"
+ :entity-id=projectId
+ :entity-name="projectName"
+ />
+ </div>
+ <div
+ class="project-item-metadata-container"
+ >
+ <div
+ class="project-title"
+ :title="projectName"
+ v-html="highlightedProjectName"
+ >
+ </div>
+ <div
+ class="project-namespace"
+ :title="namespace"
+ >
+ {{namespace}}
+ </div>
+ </div>
+ </a>
+ </li>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue
new file mode 100644
index 00000000000..fa5efef2919
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue
@@ -0,0 +1,63 @@
+<script>
+import { s__ } from '../../locale';
+import projectsListItem from './projects_list_item.vue';
+
+export default {
+ components: {
+ projectsListItem,
+ },
+ props: {
+ matcher: {
+ type: String,
+ required: true,
+ },
+ projects: {
+ type: Array,
+ required: true,
+ },
+ searchFailed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ isListEmpty() {
+ return this.projects.length === 0;
+ },
+ listEmptyMessage() {
+ return this.searchFailed ?
+ s__('ProjectsDropdown|Something went wrong on our end.') :
+ s__('ProjectsDropdown|No projects matched your query');
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="projects-list-search-container"
+ >
+ <ul
+ class="list-unstyled"
+ >
+ <li
+ v-if="isListEmpty"
+ :class="{ 'section-failure': searchFailed }"
+ class="section-empty"
+ >
+ {{ listEmptyMessage }}
+ </li>
+ <projects-list-item
+ v-else
+ v-for="(project, index) in projects"
+ :key="index"
+ :project-id="project.id"
+ :project-name="project.name"
+ :namespace="project.namespace"
+ :web-url="project.webUrl"
+ :avatar-url="project.avatarUrl"
+ :matcher="matcher"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue
new file mode 100644
index 00000000000..b71997234e5
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/components/search.vue
@@ -0,0 +1,64 @@
+<script>
+import _ from 'underscore';
+import eventHub from '../event_hub';
+
+export default {
+ data() {
+ return {
+ searchQuery: '',
+ };
+ },
+ watch: {
+ searchQuery() {
+ this.handleInput();
+ },
+ },
+ methods: {
+ setFocus() {
+ this.$refs.search.focus();
+ },
+ emitSearchEvents() {
+ if (this.searchQuery) {
+ eventHub.$emit('searchProjects', this.searchQuery);
+ } else {
+ eventHub.$emit('searchCleared');
+ }
+ },
+ /**
+ * Callback function within _.debounce is intentionally
+ * kept as ES5 `function() {}` instead of ES6 `() => {}`
+ * as it otherwise messes up function context
+ * and component reference is no longer accessible via `this`
+ */
+ // eslint-disable-next-line func-names
+ handleInput: _.debounce(function () {
+ this.emitSearchEvents();
+ }, 500),
+ },
+ mounted() {
+ eventHub.$on('dropdownOpen', this.setFocus);
+ },
+ beforeDestroy() {
+ eventHub.$off('dropdownOpen', this.setFocus);
+ },
+};
+</script>
+
+<template>
+ <div
+ class="search-input-container hidden-xs"
+ >
+ <input
+ type="search"
+ class="form-control"
+ ref="search"
+ v-model="searchQuery"
+ :placeholder="s__('ProjectsDropdown|Search projects')"
+ />
+ <i
+ v-if="!searchQuery"
+ class="search-icon fa fa-fw fa-search"
+ aria-hidden="true"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects_dropdown/constants.js b/app/assets/javascripts/projects_dropdown/constants.js
new file mode 100644
index 00000000000..8937097184c
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/constants.js
@@ -0,0 +1,10 @@
+export const FREQUENT_PROJECTS = {
+ MAX_COUNT: 20,
+ LIST_COUNT_DESKTOP: 5,
+ LIST_COUNT_MOBILE: 3,
+ ELIGIBLE_FREQUENCY: 3,
+};
+
+export const HOUR_IN_MS = 3600000;
+
+export const STORAGE_KEY = 'frequent-projects';
diff --git a/app/assets/javascripts/projects_dropdown/event_hub.js b/app/assets/javascripts/projects_dropdown/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js
new file mode 100644
index 00000000000..2660da3c558
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/index.js
@@ -0,0 +1,68 @@
+import Vue from 'vue';
+
+import Translate from '../vue_shared/translate';
+import eventHub from './event_hub';
+import ProjectsService from './service/projects_service';
+import ProjectsStore from './store/projects_store';
+
+import projectsDropdownApp from './components/app.vue';
+
+Vue.use(Translate);
+
+document.addEventListener('DOMContentLoaded', () => {
+ const el = document.getElementById('js-projects-dropdown');
+ const navEl = document.getElementById('nav-projects-dropdown');
+
+ // Don't do anything if element doesn't exist (No projects dropdown)
+ // This is for when the user accesses GitLab without logging in
+ if (!el || !navEl) {
+ return;
+ }
+
+ $(navEl).on('show.bs.dropdown', (e) => {
+ const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu');
+ dropdownEl.one('transitionend', () => {
+ eventHub.$emit('dropdownOpen');
+ });
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ projectsDropdownApp,
+ },
+ data() {
+ const dataset = this.$options.el.dataset;
+ const store = new ProjectsStore();
+ const service = new ProjectsService(dataset.userName);
+
+ const project = {
+ id: Number(dataset.projectId),
+ name: dataset.projectName,
+ namespace: dataset.projectNamespace,
+ webUrl: dataset.projectWebUrl,
+ avatarUrl: dataset.projectAvatarUrl || null,
+ lastAccessedOn: Date.now(),
+ };
+
+ return {
+ store,
+ service,
+ state: store.state,
+ currentUserName: dataset.userName,
+ currentProject: project,
+ };
+ },
+ render(createElement) {
+ return createElement('projects-dropdown-app', {
+ props: {
+ currentUserName: this.currentUserName,
+ currentProject: this.currentProject,
+ store: this.store,
+ service: this.service,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js
new file mode 100644
index 00000000000..fad956b4c26
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js
@@ -0,0 +1,132 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+import bp from '../../breakpoints';
+import Api from '../../api';
+import AccessorUtilities from '../../lib/utils/accessor';
+
+import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants';
+
+Vue.use(VueResource);
+
+export default class ProjectsService {
+ constructor(currentUserName) {
+ this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ this.currentUserName = currentUserName;
+ this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`;
+ this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath));
+ }
+
+ getSearchedProjects(searchQuery) {
+ return this.projectsPath.get({
+ simple: false,
+ per_page: 20,
+ membership: !!gon.current_user_id,
+ order_by: 'last_activity_at',
+ search: searchQuery,
+ });
+ }
+
+ getFrequentProjects() {
+ if (this.isLocalStorageAvailable) {
+ return this.getTopFrequentProjects();
+ }
+ return null;
+ }
+
+ logProjectAccess(project) {
+ let matchFound = false;
+ let storedFrequentProjects;
+
+ if (this.isLocalStorageAvailable) {
+ const storedRawProjects = localStorage.getItem(this.storageKey);
+
+ // Check if there's any frequent projects list set
+ if (!storedRawProjects) {
+ // No frequent projects list set, set one up.
+ storedFrequentProjects = [];
+ storedFrequentProjects.push({ ...project, frequency: 1 });
+ } else {
+ // Check if project is already present in frequents list
+ // When found, update metadata of it.
+ storedFrequentProjects = JSON.parse(storedRawProjects).map((projectItem) => {
+ if (projectItem.id === project.id) {
+ matchFound = true;
+ const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS;
+ const updatedProject = {
+ ...project,
+ frequency: projectItem.frequency,
+ lastAccessedOn: projectItem.lastAccessedOn,
+ };
+
+ // Check if duration since last access of this project
+ // is over an hour
+ if (diff > 1) {
+ return {
+ ...updatedProject,
+ frequency: updatedProject.frequency + 1,
+ lastAccessedOn: Date.now(),
+ };
+ }
+
+ return {
+ ...updatedProject,
+ };
+ }
+
+ return projectItem;
+ });
+
+ // Check whether currently logged project is present in frequents list
+ if (!matchFound) {
+ // We always keep size of frequents collection to 20 projects
+ // out of which only 5 projects with
+ // highest value of `frequency` and most recent `lastAccessedOn`
+ // are shown in projects dropdown
+ if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) {
+ storedFrequentProjects.shift(); // Remove an item from head of array
+ }
+
+ storedFrequentProjects.push({ ...project, frequency: 1 });
+ }
+ }
+
+ localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects));
+ }
+ }
+
+ getTopFrequentProjects() {
+ const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey));
+ let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP;
+
+ if (!storedFrequentProjects) {
+ return [];
+ }
+
+ if (bp.getBreakpointSize() === 'sm' ||
+ bp.getBreakpointSize() === 'xs') {
+ frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE;
+ }
+
+ const frequentProjects = storedFrequentProjects
+ .filter(project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY);
+
+ // Sort all frequent projects in decending order of frequency
+ // and then by lastAccessedOn with recent most first
+ frequentProjects.sort((projectA, projectB) => {
+ if (projectA.frequency < projectB.frequency) {
+ return 1;
+ } else if (projectA.frequency > projectB.frequency) {
+ return -1;
+ } else if (projectA.lastAccessedOn < projectB.lastAccessedOn) {
+ return 1;
+ } else if (projectA.lastAccessedOn > projectB.lastAccessedOn) {
+ return -1;
+ }
+
+ return 0;
+ });
+
+ return _.first(frequentProjects, frequentProjectsCount);
+ }
+}
diff --git a/app/assets/javascripts/projects_dropdown/store/projects_store.js b/app/assets/javascripts/projects_dropdown/store/projects_store.js
new file mode 100644
index 00000000000..ffefbe693f4
--- /dev/null
+++ b/app/assets/javascripts/projects_dropdown/store/projects_store.js
@@ -0,0 +1,33 @@
+export default class ProjectsStore {
+ constructor() {
+ this.state = {};
+ this.state.frequentProjects = [];
+ this.state.searchedProjects = [];
+ }
+
+ setFrequentProjects(rawProjects) {
+ this.state.frequentProjects = rawProjects;
+ }
+
+ getFrequentProjects() {
+ return this.state.frequentProjects;
+ }
+
+ setSearchedProjects(rawProjects) {
+ this.state.searchedProjects = rawProjects.map(rawProject => ({
+ id: rawProject.id,
+ name: rawProject.name,
+ namespace: rawProject.name_with_namespace,
+ webUrl: rawProject.web_url,
+ avatarUrl: rawProject.avatar_url,
+ }));
+ }
+
+ getSearchedProjects() {
+ return this.state.searchedProjects;
+ }
+
+ clearSearchedProjects() {
+ this.state.searchedProjects = [];
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
index c05a76a3b4a..aaca42e3ebc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js
@@ -75,18 +75,20 @@ export default {
class="btn btn-small inline">
Check out branch
</a>
- <span class="dropdown inline prepend-left-10">
+ <span class="dropdown prepend-left-10">
<a
- class="btn btn-xs dropdown-toggle"
+ class="btn btn-small inline dropdown-toggle"
data-toggle="dropdown"
aria-label="Download as"
role="button">
<i
class="fa fa-download"
- aria-hidden="true" />
+ aria-hidden="true">
+ </i>
<i
class="fa fa-caret-down"
- aria-hidden="true" />
+ aria-hidden="true">
+ </i>
</a>
<ul class="dropdown-menu dropdown-menu-align-right">
<li>
diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue
index 0edd820743f..7cf2e029cf6 100644
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ b/app/assets/javascripts/vue_shared/components/identicon.vue
@@ -9,6 +9,11 @@ export default {
type: String,
required: true,
},
+ sizeClass: {
+ type: String,
+ required: false,
+ default: 's40',
+ },
},
computed: {
/**
@@ -38,7 +43,8 @@ export default {
<template>
<div
- class="avatar s40 identicon"
+ class="avatar identicon"
+ :class="sizeClass"
:style="identiconStyles">
{{identiconTitle}}
</div>
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index b2b3297e880..c0524bf6aa3 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -51,3 +51,4 @@
@import "framework/snippets";
@import "framework/memory_graph";
@import "framework/responsive-tables";
+@import "framework/feature_highlight";
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index b4a6b214e98..82350c36df0 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -46,6 +46,15 @@
}
}
+@mixin btn-svg {
+ svg {
+ height: 15px;
+ width: 15px;
+ position: relative;
+ top: 2px;
+ }
+}
+
@mixin btn-color($light, $border-light, $normal, $border-normal, $dark, $border-dark, $color) {
background-color: $light;
border-color: $border-light;
@@ -123,6 +132,7 @@
.btn {
@include btn-default;
@include btn-white;
+ @include btn-svg;
color: $gl-text-color;
@@ -222,13 +232,6 @@
}
}
- svg {
- height: 15px;
- width: 15px;
- position: relative;
- top: 2px;
- }
-
svg,
.fa {
&:not(:last-child) {
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 68a51c5a461..a85051642dd 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -21,6 +21,7 @@
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; }
+.append-bottom-5 { margin-bottom: 5px; }
.append-bottom-10 { margin-bottom: 10px; }
.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index fad991f2c49..6b21def33a6 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -737,6 +737,8 @@
@mixin new-style-dropdown($selector: '') {
#{$selector}.dropdown-menu,
#{$selector}.dropdown-menu-nav {
+ margin-bottom: 24px;
+
li {
display: block;
padding: 0 1px;
@@ -764,11 +766,12 @@
box-shadow: none;
padding: 8px 16px;
text-align: left;
+ white-space: normal;
width: 100%;
// make sure the text color is not overriden
&.text-danger {
- @extend .text-danger;
+ color: $brand-danger;
}
&.is-focused,
@@ -777,6 +780,11 @@
&:focus {
background-color: $dropdown-item-hover-bg;
color: $gl-text-color;
+
+ // make sure the text color is not overriden
+ &.text-danger {
+ color: $brand-danger;
+ }
}
&.is-active {
@@ -822,3 +830,152 @@
}
@include new-style-dropdown('.js-namespace-select + ');
+
+header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu {
+ padding: 0;
+
+ @media (max-width: $screen-xs-max) {
+ display: table;
+ left: -50px;
+ min-width: 300px;
+ }
+}
+
+.projects-dropdown-container {
+ display: flex;
+ flex-direction: row;
+ width: 500px;
+ height: 334px;
+
+ .project-dropdown-sidebar,
+ .project-dropdown-content {
+ padding: 8px 0;
+ }
+
+ .loading-animation {
+ color: $almost-black;
+ }
+
+ .project-dropdown-sidebar {
+ width: 30%;
+ border-right: 1px solid $border-color;
+ }
+
+ .project-dropdown-content {
+ position: relative;
+ width: 70%;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ flex-direction: column;
+ width: 100%;
+ height: auto;
+ flex: 1;
+
+ .project-dropdown-sidebar,
+ .project-dropdown-content {
+ width: 100%;
+ }
+
+ .project-dropdown-sidebar {
+ border-bottom: 1px solid $border-color;
+ border-right: 0;
+ }
+ }
+}
+
+.projects-dropdown-container {
+ .projects-list-frequent-container,
+ .projects-list-search-container, {
+ padding: 8px 0;
+ overflow-y: auto;
+ }
+
+ .section-header,
+ .projects-list-frequent-container li.section-empty,
+ .projects-list-search-container li.section-empty {
+ padding: 0 15px;
+ }
+
+ .section-header,
+ .projects-list-frequent-container li.section-empty,
+ .projects-list-search-container li.section-empty {
+ color: $gl-text-color-secondary;
+ font-size: $gl-font-size;
+ }
+
+ .projects-list-frequent-container,
+ .projects-list-search-container {
+ li.section-empty.section-failure {
+ color: $callout-danger-color;
+ }
+ }
+
+ .search-input-container {
+ position: relative;
+ padding: 4px $gl-padding;
+
+ .search-icon {
+ position: absolute;
+ top: 13px;
+ right: 25px;
+ color: $md-area-border;
+ }
+ }
+
+ .section-header {
+ font-weight: 700;
+ margin-top: 8px;
+ }
+
+ .projects-list-search-container {
+ height: 284px;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .projects-list-frequent-container {
+ width: auto;
+ height: auto;
+ padding-bottom: 0;
+ }
+ }
+}
+
+.projects-list-item-container {
+ .project-item-avatar-container
+ .project-item-metadata-container {
+ float: left;
+ }
+
+ .project-title,
+ .project-namespace {
+ max-width: 250px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &:hover {
+ .project-item-avatar-container .avatar {
+ border-color: $md-area-border;
+ }
+ }
+
+ .project-title {
+ font-size: $gl-font-size;
+ font-weight: 400;
+ line-height: 16px;
+ }
+
+ .project-namespace {
+ margin-top: 4px;
+ font-size: 12px;
+ line-height: 12px;
+ color: $gl-text-color-secondary;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ .project-item-metadata-container {
+ float: none;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/feature_highlight.scss b/app/assets/stylesheets/framework/feature_highlight.scss
new file mode 100644
index 00000000000..ebae473df50
--- /dev/null
+++ b/app/assets/stylesheets/framework/feature_highlight.scss
@@ -0,0 +1,94 @@
+.feature-highlight {
+ position: relative;
+ margin-left: $gl-padding;
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ width: 8px;
+ height: 8px;
+ background-color: $blue-500;
+ border-radius: 50%;
+ box-shadow: 0 0 0 rgba($blue-500, 0.4);
+ animation: pulse-highlight 2s infinite;
+ }
+
+ &:hover::before,
+ &.disable-animation::before {
+ animation: none;
+ }
+
+ &[disabled]::before {
+ display: none;
+ }
+}
+
+.is-showing-fly-out {
+ .feature-highlight {
+ display: none;
+ }
+}
+
+.feature-highlight-popover-content {
+ display: none;
+
+ hr {
+ margin: $gl-padding * 0.5 0;
+ }
+
+ .btn-link {
+ @include btn-svg;
+
+ svg path {
+ fill: currentColor;
+ }
+ }
+
+ .dismiss-feature-highlight {
+ padding: 0;
+ }
+
+ svg:first-child {
+ width: 100%;
+ background-color: $indigo-50;
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ border-bottom: 1px solid darken($gray-normal, 8%);
+ }
+}
+
+.popover .feature-highlight-popover-content {
+ display: block;
+}
+
+.feature-highlight-popover {
+ padding: 0;
+
+ .popover-content {
+ padding: 0;
+ }
+}
+
+.feature-highlight-popover-sub-content {
+ padding: 9px 14px;
+}
+
+@include keyframes(pulse-highlight) {
+ 0% {
+ box-shadow: 0 0 0 0 rgba($blue-200, 0.4);
+ }
+
+ 70% {
+ box-shadow: 0 0 0 10px transparent;
+ }
+
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 35bd97980e2..b00a2d053e2 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -105,12 +105,11 @@ header {
top: -3px;
font-size: 10px;
}
+ }
+ .user-counter {
svg {
- position: relative;
- top: 2px;
- height: 17px;
- // hack to get SVG to line up with FA icons
+ height: 16px;
width: 23px;
fill: currentColor;
}
@@ -325,12 +324,12 @@ header {
li {
.badge {
position: inherit;
- top: -8px;
font-weight: $gl-font-weight-normal;
- margin-left: -11px;
+ margin-left: -6px;
font-size: 11px;
color: $white-light;
- padding: 1px 5px 2px;
+ padding: 0 5px;
+ line-height: 12px;
border-radius: 7px;
box-shadow: 0 1px 0 rgba($gl-header-color, .2);
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index a39927eb0df..6c14e8b97e0 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -267,14 +267,26 @@
// 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,
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 01fffa717e9..88b08998dfd 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -177,13 +177,14 @@ $row-hover: $blue-25;
$row-hover-border: $blue-100;
$progress-color: #c0392b;
$header-height: 50px;
+$new-navbar-height: 40px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
$limited-layout-width-sm: 790px;
$container-text-max-width: 540px;
$gl-avatar-size: 40px;
$error-exclamation-point: $red-500;
-$border-radius-default: 3px;
+$border-radius-default: 4px;
$settings-icon-size: 18px;
$provider-btn-not-active-color: $blue-500;
$link-underline-blue: $blue-500;
diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss
index b711bd12c73..4deb7431284 100644
--- a/app/assets/stylesheets/new_nav.scss
+++ b/app/assets/stylesheets/new_nav.scss
@@ -2,15 +2,21 @@
@import 'framework/tw_bootstrap_variables';
@import "bootstrap/variables";
+.content-wrapper.page-with-new-nav {
+ margin-top: $new-navbar-height;
+}
+
header.navbar-gitlab-new {
color: $white-light;
background: linear-gradient(to right, $indigo-900, $indigo-800);
border-bottom: 0;
+ min-height: $new-navbar-height;
.header-content {
display: -webkit-flex;
display: flex;
padding-left: 0;
+ min-height: $new-navbar-height;
.title-container {
display: -webkit-flex;
@@ -38,20 +44,13 @@ header.navbar-gitlab-new {
display: -webkit-flex;
display: flex;
align-items: center;
- padding-right: $gl-padding;
- padding-left: $gl-padding;
- margin-left: -$gl-padding;
-
- @media (min-width: $screen-sm-min) {
- padding-right: $gl-padding;
- padding-left: $gl-padding;
- }
+ padding: 2px 8px;
+ margin: 5px 2px 5px -8px;
+ border-radius: $border-radius-default;
svg {
- margin-top: -3px;
-
@media (min-width: $screen-sm-min) {
- margin-right: 10px;
+ margin-right: 8px;
}
}
@@ -60,7 +59,7 @@ header.navbar-gitlab-new {
svg {
width: 55px;
- height: 15px;
+ height: 14px;
margin: 0;
fill: $white-light;
}
@@ -68,9 +67,7 @@ header.navbar-gitlab-new {
&:hover,
&:focus {
- .logo-text svg {
- fill: $tanuki-yellow;
- }
+ background-color: rgba($indigo-200, .2);
}
}
}
@@ -90,6 +87,20 @@ header.navbar-gitlab-new {
right: 0;
}
}
+
+ &.menu-expanded {
+ @media (max-width: $screen-xs-max) {
+ .title-container,
+ .header-logo, {
+ display: none;
+ }
+ }
+ }
+ }
+
+ .dropdown-bold-header {
+ color: $gl-text-color-secondary;
+ font-size: 12px;
}
.navbar-collapse {
@@ -98,14 +109,10 @@ header.navbar-gitlab-new {
box-shadow: 0;
@media (max-width: $screen-xs-max) {
- margin-left: -$gl-padding;
+ margin-left: -8px;
margin-right: -10px;
}
- .dropdown-bold-header {
- color: initial;
- }
-
.nav {
> li:not(.hidden-xs) a {
@media (max-width: $screen-xs-max) {
@@ -119,7 +126,7 @@ header.navbar-gitlab-new {
.container-fluid {
.navbar-toggle {
min-width: 45px;
- padding: 6px $gl-padding;
+ padding: 4px $gl-padding;
margin-right: -7px;
font-size: 14px;
text-align: center;
@@ -156,31 +163,90 @@ header.navbar-gitlab-new {
}
> a {
- background: none;
will-change: color;
+ margin: 4px 2px;
+ padding: 6px 8px;
+ color: $indigo-200;
+ height: 32px;
+
+ @media (max-width: $screen-xs-max) {
+ padding: 0;
+ }
+
+ svg {
+ fill: $indigo-200;
+ }
&.header-user-dropdown-toggle {
+ margin-left: 2px;
+
.header-user-avatar {
border-color: $indigo-200;
+ margin-right: 0;
}
}
+ }
- &:hover,
- &:focus {
- color: $white-light;
- opacity: 1;
+ .header-new-dropdown-toggle {
+ margin-right: 0;
+ }
- > svg {
- fill: $white-light;
- }
+ > a:hover,
+ > a:focus {
+ text-decoration: none;
+ outline: 0;
+ opacity: 1;
+ color: $white-light;
+
+ @media (min-width: $screen-sm-min) {
+ background-color: rgba($indigo-200, .2);
+ }
+
+ svg {
+ fill: currentColor;
+ }
- &.header-user-dropdown-toggle {
- .header-user-avatar {
- border-color: $white-light;
- }
+ &.header-user-dropdown-toggle {
+ .header-user-avatar {
+ border-color: $white-light;
}
}
}
+
+ .impersonated-user,
+ .impersonated-user:hover {
+ margin-right: 1px;
+ background-color: $white-light;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+
+ svg {
+ fill: $indigo-900;
+ }
+ }
+
+ .impersonation-btn,
+ .impersonation-btn:hover {
+ background-color: $white-light;
+ margin-left: 0;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+
+ i {
+ color: $orange-500;
+ font-size: 20px;
+ }
+ }
+
+ &.active > a,
+ &.dropdown.open > a {
+ color: $indigo-900;
+ background-color: $white-light;
+
+ svg {
+ fill: currentColor;
+ }
+ }
}
}
}
@@ -188,45 +254,76 @@ header.navbar-gitlab-new {
.navbar-sub-nav {
display: -webkit-flex;
display: flex;
- margin-bottom: 0;
+ margin: 0 0 0 6px;
color: $indigo-200;
- > li {
- > a:hover,
- > a:focus {
- box-shadow: inset 0 -3px 0 rgba($indigo-200, .4);
- text-decoration: none;
- outline: 0;
- color: $white-light;
- }
+ .dropdown-chevron {
+ position: relative;
+ top: -1px;
+ font-size: 10px;
+ }
+}
- &.active > a {
- box-shadow: inset 0 -3px 0 $indigo-500;
- color: $white-light;
- font-weight: $gl-font-weight-bold;
- }
+.navbar-gitlab-new {
+ .navbar-sub-nav,
+ .navbar-nav {
+ > li {
+ > a:hover,
+ > a:focus {
+ text-decoration: none;
+ outline: 0;
+ color: $white-light;
+ background-color: rgba($indigo-200, .2);
- > a {
- display: block;
- padding: 16px 10px;
- font-size: 13px;
- color: currentColor;
- box-shadow: inset 0 0 0 transparent;
- will-change: box-shadow;
- transition: box-shadow 0.15s;
+ svg {
+ fill: currentColor;
+ }
+ }
- @media (min-width: $screen-sm-min) {
- padding: 15px $gl-padding;
- font-size: 14px;
+ &.active > a,
+ &.dropdown.open > a {
+ color: $indigo-900;
+ background-color: $white-light;
+
+ svg {
+ fill: currentColor;
+ }
+ }
+
+ > a {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 8px;
+ margin: 4px 2px;
+ font-size: 12px;
+ color: currentColor;
+ border-radius: $border-radius-default;
+ height: 32px;
+ font-weight: $gl-font-weight-bold;
+
+ svg {
+ fill: currentColor;
+ }
+ }
+
+ &.line-separator {
+ border-left: 1px solid rgba($indigo-200, .2);
+ margin: 8px;
}
}
}
+}
- .dropdown-chevron {
- position: relative;
- top: -1px;
- font-size: 10px;
- }
+.admin-icon i {
+ font-size: 18px;
+}
+
+.caret-down {
+ height: 11px;
+ width: 11px;
+ margin-left: 4px;
+ fill: currentColor;
}
.header-user .dropdown-menu-nav,
@@ -235,10 +332,14 @@ header.navbar-gitlab-new {
}
.search {
+ margin: 4px 8px 0;
+
form {
+ height: 32px;
border: 0;
+ border-radius: $border-radius-default;
background-color: rgba($indigo-200, .2);
- transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s, background-color ease-in-out 0.15s;
+ transition: border-color ease-in-out 0.15s, background-color ease-in-out 0.15s;
&:hover {
background-color: rgba($indigo-200, .3);
@@ -247,31 +348,50 @@ header.navbar-gitlab-new {
}
&.search-active form {
- background-color: rgba($indigo-200, .3);
+ background-color: $white-light;
box-shadow: none;
+
+ .search-input {
+ color: $gl-text-color;
+ transition: color ease-in-out 0.15s;
+ }
+
+ .search-input::placeholder {
+ color: $gl-text-color-tertiary;
+ }
+
+ .search-input-wrap {
+ .search-icon,
+ .clear-icon {
+ color: $gl-text-color-tertiary;
+ transition: color ease-in-out 0.15s;
+ }
+ }
}
.search-input {
color: $white-light;
background: none;
+ transition: color ease-in-out 0.15s;
}
.search-input::placeholder {
color: rgba($indigo-200, .8);
+ transition: color ease-in-out 0.15s;
}
.location-badge {
font-size: 12px;
color: $indigo-100;
background-color: rgba($indigo-200, .1);
- transition: color 0.15s;
will-change: color;
margin: -4px 4px -4px -4px;
line-height: 25px;
padding: 4px 8px;
border-radius: 2px 0 0 2px;
border-right: 1px solid $indigo-800;
- height: 34px;
+ height: 32px;
+ transition: border-color ease-in-out 0.15s;
}
.search-input-wrap {
@@ -283,8 +403,9 @@ header.navbar-gitlab-new {
&.search-active {
.location-badge {
- color: $white-light;
- background-color: rgba($indigo-200, .2);
+ color: $gl-text-color;
+ background-color: $nav-badge-bg;
+ border-color: $border-color;
}
.search-input-wrap {
@@ -458,3 +579,14 @@ header.navbar-gitlab-new {
}
}
}
+
+.btn-sign-in {
+ margin-top: 3px;
+ background-color: $indigo-100;
+ color: $indigo-900;
+ font-weight: $gl-font-weight-bold;
+
+ &:hover {
+ background-color: $white-light;
+ }
+}
diff --git a/app/assets/stylesheets/new_sidebar.scss b/app/assets/stylesheets/new_sidebar.scss
index f624b130e19..90b0a543c5c 100644
--- a/app/assets/stylesheets/new_sidebar.scss
+++ b/app/assets/stylesheets/new_sidebar.scss
@@ -26,7 +26,7 @@ $new-sidebar-collapsed-width: 50px;
// Override position: absolute
.right-sidebar {
position: fixed;
- height: calc(100% - #{$header-height});
+ height: calc(100% - #{$new-navbar-height});
}
.issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header {
@@ -93,7 +93,7 @@ $new-sidebar-collapsed-width: 50px;
z-index: 400;
width: $new-sidebar-width;
transition: left $sidebar-transition-duration;
- top: $header-height;
+ top: $new-navbar-height;
bottom: 0;
left: 0;
background-color: $gray-normal;
@@ -189,7 +189,7 @@ $new-sidebar-collapsed-width: 50px;
}
.with-performance-bar .nav-sidebar {
- top: $header-height + $performance-bar-height;
+ top: $new-navbar-height + $performance-bar-height;
}
.sidebar-sub-level-items {
@@ -453,7 +453,7 @@ $new-sidebar-collapsed-width: 50px;
// Make issue boards full-height now that sub-nav is gone
.boards-list {
- height: calc(100vh - #{$header-height});
+ height: calc(100vh - #{$new-navbar-height});
@media (min-width: $screen-sm-min) {
height: 475px; // Needed for PhantomJS
@@ -464,7 +464,7 @@ $new-sidebar-collapsed-width: 50px;
}
.with-performance-bar .boards-list {
- height: calc(100vh - #{$header-height} - #{$performance-bar-height});
+ height: calc(100vh - #{$new-navbar-height} - #{$performance-bar-height});
}
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index e7c830cbc69..9362d80d4e6 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -169,7 +169,7 @@
}
.metric-area {
- opacity: 0.8;
+ opacity: 0.25;
}
.prometheus-graph-overlay {
@@ -227,6 +227,26 @@
margin-top: 20px;
}
+.prometheus-graph-group {
+ display: flex;
+ flex-wrap: wrap;
+ padding: $gl-padding / 2;
+}
+
+.prometheus-graph {
+ flex: 1 0 auto;
+ min-width: 450px;
+ padding: $gl-padding / 2;
+
+ h5 {
+ font-size: 16px;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ min-width: 100%;
+ }
+}
+
.prometheus-svg-container {
position: relative;
height: 0;
@@ -251,8 +271,14 @@
font-weight: $gl-font-weight-bold;
}
- .label-axis-text,
- .text-metric-usage {
+ .label-axis-text {
+ fill: $black;
+ font-weight: $gl-font-weight-normal;
+ font-size: 10px;
+ }
+
+ .text-metric-usage,
+ .legend-metric-title {
fill: $black;
font-weight: $gl-font-weight-normal;
font-size: 12px;
@@ -291,9 +317,3 @@
}
}
}
-
-.prometheus-row {
- h5 {
- font-size: 16px;
- }
-}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 6523376ccc3..9f2cb979518 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -617,6 +617,8 @@
}
.issuable-actions {
+ @include new-style-dropdown;
+
padding-top: 10px;
@media (min-width: $screen-sm-min) {
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 0213e7aa9d9..e8ca5cedaee 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -143,8 +143,12 @@ ul.related-merge-requests > li {
}
}
-.issue-form .select2-container {
- width: 250px !important;
+.issue-form {
+ @include new-style-dropdown;
+
+ .select2-container {
+ width: 250px !important;
+ }
}
.issues-footer {
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 8932cff22a8..5d7c85b16ef 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -23,6 +23,8 @@
.new-note,
.note-edit-form {
.note-form-actions {
+ @include new-style-dropdown;
+
position: relative;
margin: $gl-padding 0 0;
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 19caefa1961..dd600a27545 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -800,8 +800,10 @@ pre.light-well {
}
}
-.new_protected_branch,
+.new-protected-branch,
.new-protected-tag {
+ @include new-style-dropdown;
+
label {
margin-top: 6px;
font-weight: $gl-font-weight-normal;
@@ -821,19 +823,9 @@ pre.light-well {
.protected-branches-list,
.protected-tags-list {
- margin-bottom: 30px;
-
- a {
- color: $gl-text-color;
-
- &:hover {
- color: $gl-link-color;
- }
+ @include new-style-dropdown;
- &.is-active {
- font-weight: $gl-font-weight-bold;
- }
- }
+ margin-bottom: 30px;
.settings-message {
margin: 0;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 8d73246223d..615020ca856 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -190,6 +190,8 @@ input[type="checkbox"]:hover {
}
.search-holder {
+ @include new-style-dropdown;
+
@media (min-width: $screen-sm-min) {
display: -webkit-flex;
display: flex;