Commit d041e4f6 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'prettify-all-the-ee-things' into 'master'

Prettify all the things (EE)

See merge request gitlab-org/gitlab-ee!8114
parents fd633eed 83827b7d
<script> <script>
import Flash from '~/flash'; import Flash from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import GitlabSlackService from '../services/gitlab_slack_service'; import GitlabSlackService from '../services/gitlab_slack_service';
export default { export default {
props: { props: {
projects: { projects: {
type: Array, type: Array,
required: false, required: false,
default: () => [], default: () => [],
}, },
isSignedIn: { isSignedIn: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
gitlabForSlackGifPath: { gitlabForSlackGifPath: {
type: String, type: String,
required: true, required: true,
}, },
signInPath: { signInPath: {
type: String, type: String,
required: true, required: true,
},
slackLinkPath: {
type: String,
required: true,
},
gitlabLogoPath: {
type: String,
required: true,
},
slackLogoPath: {
type: String,
required: true,
},
docsPath: {
type: String,
required: true,
},
}, },
data() { slackLinkPath: {
return { type: String,
popupOpen: false, required: true,
selectedProjectId: this.projects && this.projects.length ? this.projects[0].id : 0,
};
}, },
computed: { gitlabLogoPath: {
doubleHeadedArrowSvg() { type: String,
return gl.utils.spriteIcon('double-headed-arrow'); required: true,
}, },
slackLogoPath: {
type: String,
required: true,
},
docsPath: {
type: String,
required: true,
},
},
data() {
return {
popupOpen: false,
selectedProjectId: this.projects && this.projects.length ? this.projects[0].id : 0,
};
},
computed: {
doubleHeadedArrowSvg() {
return gl.utils.spriteIcon('double-headed-arrow');
},
arrowRightSvg() { arrowRightSvg() {
return gl.utils.spriteIcon('arrow-right'); return gl.utils.spriteIcon('arrow-right');
}, },
hasProjects() { hasProjects() {
return this.projects.length > 0; return this.projects.length > 0;
},
}, },
},
methods: { methods: {
togglePopup() { togglePopup() {
this.popupOpen = !this.popupOpen; this.popupOpen = !this.popupOpen;
}, },
addToSlack() { addToSlack() {
GitlabSlackService.addToSlack(this.slackLinkPath, this.selectedProjectId) GitlabSlackService.addToSlack(this.slackLinkPath, this.selectedProjectId)
.then(response => redirectTo(response.data.add_to_slack_link)) .then(response => redirectTo(response.data.add_to_slack_link))
.catch(() => Flash('Unable to build Slack link.')); .catch(() => Flash('Unable to build Slack link.'));
},
}, },
}; },
};
</script> </script>
<template> <template>
......
...@@ -2,48 +2,60 @@ import $ from 'jquery'; ...@@ -2,48 +2,60 @@ import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
export default () => { export default () => {
$('.approver-list').on('click', '.unsaved-approvers.approver .btn-remove', function approverListClickCallback(ev) { $('.approver-list').on(
const removeElement = $(this).closest('li'); 'click',
const approverId = parseInt(removeElement.attr('id').replace('user_', ''), 10); '.unsaved-approvers.approver .btn-remove',
const approverIds = $('input#merge_request_approver_ids'); function approverListClickCallback(ev) {
const skipUsers = approverIds.data('skipUsers') || []; const removeElement = $(this).closest('li');
const approverIndex = skipUsers.indexOf(approverId); const approverId = parseInt(removeElement.attr('id').replace('user_', ''), 10);
const approverIds = $('input#merge_request_approver_ids');
const skipUsers = approverIds.data('skipUsers') || [];
const approverIndex = skipUsers.indexOf(approverId);
removeElement.remove(); removeElement.remove();
if (approverIndex > -1) { if (approverIndex > -1) {
approverIds.data('skipUsers', skipUsers.splice(approverIndex, 1)); approverIds.data('skipUsers', skipUsers.splice(approverIndex, 1));
} }
ev.preventDefault(); ev.preventDefault();
}); },
);
$('.approver-list').on('click', '.unsaved-approvers.approver-group .btn-remove', function approverListRemoveClickCallback(ev) { $('.approver-list').on(
const removeElement = $(this).closest('li'); 'click',
const approverGroupId = parseInt(removeElement.attr('id').replace('group_', ''), 10); '.unsaved-approvers.approver-group .btn-remove',
const approverGroupIds = $('input#merge_request_approver_group_ids'); function approverListRemoveClickCallback(ev) {
const skipGroups = approverGroupIds.data('skipGroups') || []; const removeElement = $(this).closest('li');
const approverGroupIndex = skipGroups.indexOf(approverGroupId); const approverGroupId = parseInt(removeElement.attr('id').replace('group_', ''), 10);
const approverGroupIds = $('input#merge_request_approver_group_ids');
const skipGroups = approverGroupIds.data('skipGroups') || [];
const approverGroupIndex = skipGroups.indexOf(approverGroupId);
removeElement.remove(); removeElement.remove();
if (approverGroupIndex > -1) { if (approverGroupIndex > -1) {
approverGroupIds.data('skipGroups', skipGroups.splice(approverGroupIndex, 1)); approverGroupIds.data('skipGroups', skipGroups.splice(approverGroupIndex, 1));
} }
ev.preventDefault(); ev.preventDefault();
}); },
);
$('form.merge-request-form').on('submit', function mergeRequestFormSubmitCallback() { $('form.merge-request-form').on('submit', function mergeRequestFormSubmitCallback() {
if ($('input#merge_request_approver_ids').length) { if ($('input#merge_request_approver_ids').length) {
let approverIds = $.map($('li.unsaved-approvers.approver').not('.approver-template'), li => li.id.replace('user_', '')); let approverIds = $.map($('li.unsaved-approvers.approver').not('.approver-template'), li =>
li.id.replace('user_', ''),
);
const approversInput = $(this).find('input#merge_request_approver_ids'); const approversInput = $(this).find('input#merge_request_approver_ids');
approverIds = approverIds.concat(approversInput.val().split(',')); approverIds = approverIds.concat(approversInput.val().split(','));
approversInput.val(_.compact(approverIds).join(',')); approversInput.val(_.compact(approverIds).join(','));
} }
if ($('input#merge_request_approver_group_ids').length) { if ($('input#merge_request_approver_group_ids').length) {
let approverGroupIds = $.map($('li.unsaved-approvers.approver-group'), li => li.id.replace('group_', '')); let approverGroupIds = $.map($('li.unsaved-approvers.approver-group'), li =>
li.id.replace('group_', ''),
);
const approverGroupsInput = $(this).find('input#merge_request_approver_group_ids'); const approverGroupsInput = $(this).find('input#merge_request_approver_group_ids');
approverGroupIds = approverGroupIds.concat(approverGroupsInput.val().split(',')); approverGroupIds = approverGroupIds.concat(approverGroupsInput.val().split(','));
approverGroupsInput.val(_.compact(approverGroupIds).join(',')); approverGroupsInput.val(_.compact(approverGroupIds).join(','));
...@@ -58,9 +70,11 @@ export default () => { ...@@ -58,9 +70,11 @@ export default () => {
return false; return false;
} }
const approverItemHTML = $('.unsaved-approvers.approver-template').clone() const approverItemHTML = $('.unsaved-approvers.approver-template')
.clone()
.removeClass('hide approver-template')[0] .removeClass('hide approver-template')[0]
.outerHTML.replace(/\{approver_name\}/g, username).replace(/\{user_id\}/g, userId); .outerHTML.replace(/\{approver_name\}/g, username)
.replace(/\{user_id\}/g, userId);
$('.no-approvers').remove(); $('.no-approvers').remove();
$('.approver-list').append(approverItemHTML); $('.approver-list').append(approverItemHTML);
......
import BoardsListSelector from './boards_list_selector/index'; import BoardsListSelector from './boards_list_selector/index';
export default function () { export default function() {
const $addListEl = document.querySelector('#js-add-list'); const $addListEl = document.querySelector('#js-add-list');
return new BoardsListSelector({ return new BoardsListSelector({
propsData: { propsData: {
......
...@@ -20,8 +20,8 @@ export default Board.extend({ ...@@ -20,8 +20,8 @@ export default Board.extend({
} }
const { issuesSize, totalWeight } = this.list; const { issuesSize, totalWeight } = this.list;
return sprintf(__( return sprintf(
`${n__('%d issue', '%d issues', issuesSize)} with %{totalWeight} total weight`), __(`${n__('%d issue', '%d issues', issuesSize)} with %{totalWeight} total weight`),
{ {
totalWeight, totalWeight,
}, },
......
...@@ -43,15 +43,17 @@ export default Vue.extend({ ...@@ -43,15 +43,17 @@ export default Vue.extend({
}) })
.catch(() => { .catch(() => {
this.loading = false; this.loading = false;
Flash(sprintf(__('Something went wrong while fetching %{listType} list'), { Flash(
listType: this.listType, sprintf(__('Something went wrong while fetching %{listType} list'), {
})); listType: this.listType,
}),
);
}); });
}, },
filterItems(term, items) { filterItems(term, items) {
const query = term.toLowerCase(); const query = term.toLowerCase();
return items.filter((item) => { return items.filter(item => {
const name = item.name ? item.name.toLowerCase() : item.title.toLowerCase(); const name = item.name ? item.name.toLowerCase() : item.title.toLowerCase();
const foundName = name.indexOf(query) > -1; const foundName = name.indexOf(query) > -1;
......
...@@ -31,7 +31,7 @@ export default { ...@@ -31,7 +31,7 @@ export default {
if (!this.query) return this.items; if (!this.query) return this.items;
const query = this.query.toLowerCase(); const query = this.query.toLowerCase();
return this.items.filter((item) => { return this.items.filter(item => {
const name = item.name ? item.name.toLowerCase() : item.title.toLowerCase(); const name = item.name ? item.name.toLowerCase() : item.title.toLowerCase();
if (this.listType === 'milestones') { if (this.listType === 'milestones') {
......
...@@ -140,8 +140,7 @@ export default Vue.extend({ ...@@ -140,8 +140,7 @@ export default Vue.extend({
handleDropdownTabClick(e) { handleDropdownTabClick(e) {
const $addListEl = $('#js-add-list'); const $addListEl = $('#js-add-list');
$addListEl.data('preventClose', true); $addListEl.data('preventClose', true);
if (e.target.dataset.action === 'tab-assignees' && if (e.target.dataset.action === 'tab-assignees' && !this.hasAssigneesListMounted) {
!this.hasAssigneesListMounted) {
this.assigneeList = AssigneeList(); this.assigneeList = AssigneeList();
this.hasAssigneesListMounted = true; this.hasAssigneesListMounted = true;
} }
......
import BoardsListSelector from './boards_list_selector'; import BoardsListSelector from './boards_list_selector';
export default function () { export default function() {
const $addListEl = document.querySelector('#js-add-list'); const $addListEl = document.querySelector('#js-add-list');
return new BoardsListSelector({ return new BoardsListSelector({
propsData: { propsData: {
......
<script> <script>
import MilestoneSelect from '~/milestone_select'; import MilestoneSelect from '~/milestone_select';
const ANY_MILESTONE = 'Any Milestone'; const ANY_MILESTONE = 'Any Milestone';
const NO_MILESTONE = 'No Milestone'; const NO_MILESTONE = 'No Milestone';
export default { export default {
props: { props: {
board: { board: {
type: Object, type: Object,
required: true, required: true,
},
milestonePath: {
type: String,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
}, },
milestonePath: {
type: String,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
},
computed: { computed: {
milestoneTitle() { milestoneTitle() {
if (this.noMilestone) return NO_MILESTONE; if (this.noMilestone) return NO_MILESTONE;
return this.board.milestone ? this.board.milestone.title : ANY_MILESTONE; return this.board.milestone ? this.board.milestone.title : ANY_MILESTONE;
}, },
noMilestone() { noMilestone() {
return this.milestoneId === 0; return this.milestoneId === 0;
}, },
milestoneId() { milestoneId() {
return this.board.milestone_id; return this.board.milestone_id;
}, },
milestoneTitleClass() { milestoneTitleClass() {
return this.milestoneTitle === ANY_MILESTONE ? 'text-secondary' : 'bold'; return this.milestoneTitle === ANY_MILESTONE ? 'text-secondary' : 'bold';
},
selected() {
if (this.noMilestone) return NO_MILESTONE;
return this.board.milestone ? this.board.milestone.name : '';
},
}, },
mounted() { selected() {
this.milestoneDropdown = new MilestoneSelect(null, this.$refs.dropdownButton, { if (this.noMilestone) return NO_MILESTONE;
handleClick: this.selectMilestone, return this.board.milestone ? this.board.milestone.name : '';
});
}, },
methods: { },
selectMilestone(milestone) { mounted() {
let { id } = milestone; this.milestoneDropdown = new MilestoneSelect(null, this.$refs.dropdownButton, {
// swap the IDs of 'Any' and 'No' milestone to what backend requires handleClick: this.selectMilestone,
if (milestone.title === ANY_MILESTONE) { });
id = -1; },
} else if (milestone.title === NO_MILESTONE) { methods: {
id = 0; selectMilestone(milestone) {
} let { id } = milestone;
this.board.milestone_id = id; // swap the IDs of 'Any' and 'No' milestone to what backend requires
this.board.milestone = { if (milestone.title === ANY_MILESTONE) {
...milestone, id = -1;
id, } else if (milestone.title === NO_MILESTONE) {
}; id = 0;
}, }
this.board.milestone_id = id;
this.board.milestone = {
...milestone,
id,
};
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
/* eslint-disable vue/require-default-prop */ /* eslint-disable vue/require-default-prop */
import WeightSelect from 'ee/weight_select'; import WeightSelect from 'ee/weight_select';
const ANY_WEIGHT = 'Any Weight'; const ANY_WEIGHT = 'Any Weight';
const NO_WEIGHT = 'No Weight'; const NO_WEIGHT = 'No Weight';
export default { export default {
props: { props: {
board: { board: {
type: Object, type: Object,
required: true, required: true,
},
value: {
type: [Number, String],
required: false,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
weights: {
type: Array,
required: true,
},
}, },
data() { value: {
return { type: [Number, String],
fieldName: 'weight', required: false,
};
}, },
computed: { canEdit: {
valueClass() { type: Boolean,
if (this.valueText === ANY_WEIGHT) { required: false,
return 'text-secondary'; default: false,
}
return 'bold';
},
valueText() {
if (this.value > 0) return this.value;
if (this.value === 0) return NO_WEIGHT;
return ANY_WEIGHT;
},
}, },
mounted() { weights: {
this.weightDropdown = new WeightSelect(this.$refs.dropdownButton, { type: Array,
handleClick: this.selectWeight, required: true,
selected: this.value,
fieldName: this.fieldName,
});
}, },
methods: { },
selectWeight(weight) { data() {
this.board.weight = this.weightInt(weight); return {
}, fieldName: 'weight',
weightInt(weight) { };
if (weight > 0) { },
return weight; computed: {
} valueClass() {
if (weight === NO_WEIGHT) { if (this.valueText === ANY_WEIGHT) {
return 0; return 'text-secondary';
} }
return -1; return 'bold';
},
}, },
}; valueText() {
if (this.value > 0) return this.value;
if (this.value === 0) return NO_WEIGHT;
return ANY_WEIGHT;
},
},
mounted() {
this.weightDropdown = new WeightSelect(this.$refs.dropdownButton, {
handleClick: this.selectWeight,
selected: this.value,
fieldName: this.fieldName,
});
},
methods: {
selectWeight(weight) {
this.board.weight = this.weightInt(weight);
},
weightInt(weight) {
if (weight > 0) {
return weight;
}
if (weight === NO_WEIGHT) {
return 0;
}
return -1;
},
},
};
</script> </script>
<template> <template>
......
...@@ -19,7 +19,8 @@ const d3 = { ...@@ -19,7 +19,8 @@ const d3 = {
axisLeft, axisLeft,
line, line,
transition, transition,
easeLinear }; easeLinear,
};
const margin = { top: 5, right: 65, bottom: 30, left: 50 }; const margin = { top: 5, right: 65, bottom: 30, left: 50 };
// const parseDate = d3.timeFormat('%Y-%m-%d'); // const parseDate = d3.timeFormat('%Y-%m-%d');
const bisectDate = d3.bisector(d => d.date).left; const bisectDate = d3.bisector(d => d.date).left;
...@@ -28,7 +29,9 @@ const tooltipDistance = 15; ...@@ -28,7 +29,9 @@ const tooltipDistance = 15;
export default class BurndownChart { export default class BurndownChart {
constructor({ container, startDate, dueDate }) { constructor({ container, startDate, dueDate }) {
this.canvas = d3.select(container).append('svg') this.canvas = d3
.select(container)
.append('svg')
.attr('height', '100%') .attr('height', '100%')
.attr('width', '100%'); .attr('width', '100%');
...@@ -56,21 +59,35 @@ export default class BurndownChart { ...@@ -56,21 +59,35 @@ export default class BurndownChart {
this.chartLegendIdealKey = this.chartLegendGroup.append('g'); this.chartLegendIdealKey = this.chartLegendGroup.append('g');
this.chartLegendIdealKey.append('line').attr('class', 'ideal line'); this.chartLegendIdealKey.append('line').attr('class', 'ideal line');
this.chartLegendIdealKey.append('text').text('Guideline'); this.chartLegendIdealKey.append('text').text('Guideline');
this.chartLegendIdealKeyBBox = this.chartLegendIdealKey.select('text').node().getBBox(); this.chartLegendIdealKeyBBox = this.chartLegendIdealKey
.select('text')
.node()
.getBBox();
this.chartLegendActualKey = this.chartLegendGroup.append('g'); this.chartLegendActualKey = this.chartLegendGroup.append('g');
this.chartLegendActualKey.append('line').attr('class', 'actual line'); this.chartLegendActualKey.append('line').attr('class', 'actual line');
this.chartLegendActualKey.append('text').text('Progress'); this.chartLegendActualKey.append('text').text('Progress');
this.chartLegendActualKeyBBox = this.chartLegendActualKey.select('text').node().getBBox(); this.chartLegendActualKeyBBox = this.chartLegendActualKey
.select('text')
.node()
.getBBox();
// create tooltips // create tooltips
this.chartFocus = this.chartGroup.append('g').attr('class', 'focus').style('display', 'none'); this.chartFocus = this.chartGroup
.append('g')
.attr('class', 'focus')
.style('display', 'none');
this.chartFocus.append('circle').attr('r', 4); this.chartFocus.append('circle').attr('r', 4);
this.tooltipGroup = this.chartFocus.append('g').attr('class', 'chart-tooltip'); this.tooltipGroup = this.chartFocus.append('g').attr('class', 'chart-tooltip');
this.tooltipGroup.append('rect').attr('rx', 3).attr('ry', 3); this.tooltipGroup
.append('rect')
.attr('rx', 3)
.attr('ry', 3);
this.tooltipGroup.append('text'); this.tooltipGroup.append('text');
this.chartOverlay = this.chartGroup.append('rect').attr('class', 'overlay') this.chartOverlay = this.chartGroup
.append('rect')
.attr('class', 'overlay')
.on('mouseover', () => this.chartFocus.style('display', null)) .on('mouseover', () => this.chartFocus.style('display', null))
.on('mouseout', () => this.chartFocus.style('display', 'none')) .on('mouseout', () => this.chartFocus.style('display', 'none'))
.on('mousemove', () => this.handleMousemove()); .on('mousemove', () => this.handleMousemove());
...@@ -91,28 +108,33 @@ export default class BurndownChart { ...@@ -91,28 +108,33 @@ export default class BurndownChart {
this.yMax = 1; this.yMax = 1;
// create scales // create scales
this.xScale = d3.scaleTime() this.xScale = d3
.scaleTime()
.range([0, this.chartWidth]) .range([0, this.chartWidth])
.domain([this.startDate, this.xMax]); .domain([this.startDate, this.xMax]);
this.yScale = d3.scaleLinear() this.yScale = d3
.scaleLinear()
.range([this.chartHeight, 0]) .range([this.chartHeight, 0])
.domain([0, this.yMax]); .domain([0, this.yMax]);
// create axes // create axes
this.xAxis = d3.axisBottom() this.xAxis = d3
.axisBottom()
.scale(this.xScale) .scale(this.xScale)
.tickFormat(d3.timeFormat('%b %-d')) .tickFormat(d3.timeFormat('%b %-d'))
.tickPadding(6) .tickPadding(6)
.tickSize(4, 0); .tickSize(4, 0);
this.yAxis = d3.axisLeft() this.yAxis = d3
.axisLeft()
.scale(this.yScale) .scale(this.yScale)
.tickPadding(6) .tickPadding(6)
.tickSize(4, 0); .tickSize(4, 0);
// create lines // create lines
this.line = d3.line() this.line = d3
.line()
.x(d => this.xScale(new Date(d.date))) .x(d => this.xScale(new Date(d.date)))
.y(d => this.yScale(d.value)); .y(d => this.yScale(d.value));
...@@ -122,10 +144,12 @@ export default class BurndownChart { ...@@ -122,10 +144,12 @@ export default class BurndownChart {
// set data and force re-render // set data and force re-render
setData(data, { label = 'Remaining', animate } = {}) { setData(data, { label = 'Remaining', animate } = {}) {
this.data = data.map(datum => ({ this.data = data
date: new Date(datum[0]), .map(datum => ({
value: parseInt(datum[1], 10), date: new Date(datum[0]),
})).sort((a, b) => (a.date - b.date)); value: parseInt(datum[1], 10),
}))
.sort((a, b) => a.date - b.date);
// adjust axis domain to correspond with data // adjust axis domain to correspond with data
this.xMax = Math.max(d3.max(this.data, d => d.date) || 0, this.dueDate); this.xMax = Math.max(d3.max(this.data, d => d.date) || 0, this.dueDate);
...@@ -138,7 +162,10 @@ export default class BurndownChart { ...@@ -138,7 +162,10 @@ export default class BurndownChart {
// (this must be done here to prevent layout thrashing) // (this must be done here to prevent layout thrashing)
if (this.label !== label) { if (this.label !== label) {
this.label = label; this.label = label;
this.yAxisLabelBBox = this.yAxisLabelText.text(label).node().getBBox(); this.yAxisLabelBBox = this.yAxisLabelText
.text(label)
.node()
.getBBox();
} }
// set ideal line data // set ideal line data
...@@ -206,15 +233,18 @@ export default class BurndownChart { ...@@ -206,15 +233,18 @@ export default class BurndownChart {
// replace x-axis line with one which continues into the right margin // replace x-axis line with one which continues into the right margin
this.xAxisGroup.select('.domain').remove(); this.xAxisGroup.select('.domain').remove();
this.xAxisGroup.select('.domain-line').attr('x1', 0).attr('x2', this.chartWidth + margin.right); this.xAxisGroup
.select('.domain-line')
.attr('x1', 0)
.attr('x2', this.chartWidth + margin.right);
// update y-axis label // update y-axis label
const axisLabelOffset = (this.yAxisLabelBBox.height / 2) - margin.left; const axisLabelOffset = this.yAxisLabelBBox.height / 2 - margin.left;
const axisLabelPadding = (this.chartHeight - this.yAxisLabelBBox.width - 10) / 2; const axisLabelPadding = (this.chartHeight - this.yAxisLabelBBox.width - 10) / 2;
this.yAxisLabelText this.yAxisLabelText
.attr('y', 0 - margin.left) .attr('y', 0 - margin.left)
.attr('x', 0 - (this.chartHeight / 2)) .attr('x', 0 - this.chartHeight / 2)
.attr('dy', '1em') .attr('dy', '1em')
.style('text-anchor', 'middle') .style('text-anchor', 'middle')
.attr('transform', 'rotate(-90)'); .attr('transform', 'rotate(-90)');
...@@ -240,18 +270,21 @@ export default class BurndownChart { ...@@ -240,18 +270,21 @@ export default class BurndownChart {
const idealKeyOffset = legendPadding; const idealKeyOffset = legendPadding;
const actualKeyOffset = legendPadding + keyHeight + legendSpacing; const actualKeyOffset = legendPadding + keyHeight + legendSpacing;
const legendWidth = (legendPadding * 2) + 24 + keyWidth; const legendWidth = legendPadding * 2 + 24 + keyWidth;
const legendHeight = (legendPadding * 2) + (keyHeight * 2) + legendSpacing; const legendHeight = legendPadding * 2 + keyHeight * 2 + legendSpacing;
const legendOffset = (this.chartWidth + margin.right) - legendWidth - 1; const legendOffset = this.chartWidth + margin.right - legendWidth - 1;
this.chartLegendGroup.select('rect') this.chartLegendGroup
.select('rect')
.attr('width', legendWidth) .attr('width', legendWidth)
.attr('height', legendHeight); .attr('height', legendHeight);
this.chartLegendGroup.selectAll('text') this.chartLegendGroup
.selectAll('text')
.attr('x', 24) .attr('x', 24)
.attr('dy', '1em'); .attr('dy', '1em');
this.chartLegendGroup.selectAll('line') this.chartLegendGroup
.selectAll('line')
.attr('y1', keyHeight / 2) .attr('y1', keyHeight / 2)
.attr('y2', keyHeight / 2) .attr('y2', keyHeight / 2)
.attr('x1', 0) .attr('x1', 0)
...@@ -298,15 +331,19 @@ export default class BurndownChart { ...@@ -298,15 +331,19 @@ export default class BurndownChart {
const x = this.xScale(datum.date); const x = this.xScale(datum.date);
const y = this.yScale(datum.value); const y = this.yScale(datum.value);
const textSize = this.tooltipGroup.select('text').text(tooltip).node().getBBox(); const textSize = this.tooltipGroup
const width = textSize.width + (tooltipPadding.x * 2); .select('text')
const height = textSize.height + (tooltipPadding.y * 2); .text(tooltip)
.node()
.getBBox();
const width = textSize.width + tooltipPadding.x * 2;
const height = textSize.height + tooltipPadding.y * 2;
// calculate bounraries // calculate bounraries
const xMin = 0 - x - margin.left; const xMin = 0 - x - margin.left;
const yMin = 0 - y - margin.top; const yMin = 0 - y - margin.top;
const xMax = (this.chartWidth + margin.right) - x - width; const xMax = this.chartWidth + margin.right - x - width;
const yMax = (this.chartHeight + margin.bottom) - y - height; const yMax = this.chartHeight + margin.bottom - y - height;
// try to fit tooltip above point // try to fit tooltip above point
let xOffset = 0 - Math.floor(width / 2); let xOffset = 0 - Math.floor(width / 2);
...@@ -331,12 +368,14 @@ export default class BurndownChart { ...@@ -331,12 +368,14 @@ export default class BurndownChart {
this.chartFocus.attr('transform', `translate(${x}, ${y})`); this.chartFocus.attr('transform', `translate(${x}, ${y})`);
this.tooltipGroup.attr('transform', `translate(${xOffset}, ${yOffset})`); this.tooltipGroup.attr('transform', `translate(${xOffset}, ${yOffset})`);
this.tooltipGroup.select('text') this.tooltipGroup
.select('text')
.attr('dy', '1em') .attr('dy', '1em')
.attr('x', tooltipPadding.x) .attr('x', tooltipPadding.x)
.attr('y', tooltipPadding.y); .attr('y', tooltipPadding.y);
this.tooltipGroup.select('rect') this.tooltipGroup
.select('rect')
.attr('width', width) .attr('width', width)
.attr('height', height); .attr('height', height);
} }
...@@ -357,15 +396,18 @@ export default class BurndownChart { ...@@ -357,15 +396,18 @@ export default class BurndownChart {
static animateLinePath(path, duration = 1000, cb) { static animateLinePath(path, duration = 1000, cb) {
const lineLength = path.node().getTotalLength(); const lineLength = path.node().getTotalLength();
const linearTransition = d3.transition().duration(duration).ease(d3.easeLinear); const linearTransition = d3
.transition()
.duration(duration)
.ease(d3.easeLinear);
path path
.attr('stroke-dasharray', `${lineLength} ${lineLength}`) .attr('stroke-dasharray', `${lineLength} ${lineLength}`)
.attr('stroke-dashoffset', lineLength) .attr('stroke-dashoffset', lineLength)
.transition(linearTransition) .transition(linearTransition)
.attr('stroke-dashoffset', 0) .attr('stroke-dashoffset', 0)
.on('end', () => { .on('end', () => {
path.attr('stroke-dasharray', null); path.attr('stroke-dasharray', null);
if (cb) cb(); if (cb) cb();
}); });
} }
} }
...@@ -31,7 +31,10 @@ export default () => { ...@@ -31,7 +31,10 @@ export default () => {
const show = $this.data('show'); const show = $this.data('show');
if (currentView !== show) { if (currentView !== show) {
currentView = show; currentView = show;
$this.addClass('active').siblings().removeClass('active'); $this
.addClass('active')
.siblings()
.removeClass('active');
switch (show) { switch (show) {
case 'count': case 'count':
chart.setData(openIssuesCount, { label: 'Open issues', animate: true }); chart.setData(openIssuesCount, { label: 'Open issues', animate: true });
......
<script> <script>
/** /**
* Renders a deploy board. * Renders a deploy board.
* *
* A deploy board is composed by: * A deploy board is composed by:
* - Information area with percentage of completion. * - Information area with percentage of completion.
* - Instances with status. * - Instances with status.
* - Button Actions. * - Button Actions.
* [Mockup](https://gitlab.com/gitlab-org/gitlab-ce/uploads/2f655655c0eadf655d0ae7467b53002a/environments__deploy-graphic.png) * [Mockup](https://gitlab.com/gitlab-org/gitlab-ce/uploads/2f655655c0eadf655d0ae7467b53002a/environments__deploy-graphic.png)
*/ */
import _ from 'underscore'; import _ from 'underscore';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import deployBoardSvg from 'ee_empty_states/icons/_deploy_board.svg'; import deployBoardSvg from 'ee_empty_states/icons/_deploy_board.svg';
import instanceComponent from './deploy_board_instance_component.vue'; import instanceComponent from './deploy_board_instance_component.vue';
export default { export default {
components: { components: {
instanceComponent, instanceComponent,
},
directives: {
tooltip,
},
props: {
deployBoardData: {
type: Object,
required: true,
}, },
directives: { isLoading: {
tooltip, type: Boolean,
required: true,
}, },
props: { isEmpty: {
deployBoardData: { type: Boolean,
type: Object, required: true,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
isEmpty: {
type: Boolean,
required: true,
},
logsPath: {
type: String,
required: false,
default: '',
},
}, },
computed: { logsPath: {
canRenderDeployBoard() { type: String,
return !this.isLoading && !this.isEmpty && !_.isEmpty(this.deployBoardData); required: false,
}, default: '',
canRenderEmptyState() { },
return !this.isLoading && this.isEmpty; },
}, computed: {
instanceCount() { canRenderDeployBoard() {
const { instances } = this.deployBoardData; return !this.isLoading && !this.isEmpty && !_.isEmpty(this.deployBoardData);
},
canRenderEmptyState() {
return !this.isLoading && this.isEmpty;
},
instanceCount() {
const { instances } = this.deployBoardData;
return Array.isArray(instances) ? instances.length : 0; return Array.isArray(instances) ? instances.length : 0;
}, },
instanceIsCompletedCount() { instanceIsCompletedCount() {
const completionPercentage = this.deployBoardData.completion / 100; const completionPercentage = this.deployBoardData.completion / 100;
const completionCount = Math.floor(completionPercentage * this.instanceCount); const completionCount = Math.floor(completionPercentage * this.instanceCount);
return Number.isNaN(completionCount) ? 0 : completionCount; return Number.isNaN(completionCount) ? 0 : completionCount;
}, },
instanceIsCompletedText() { instanceIsCompletedText() {
const title = n__('instance completed', 'instances completed', this.instanceIsCompletedCount); const title = n__('instance completed', 'instances completed', this.instanceIsCompletedCount);
return `${this.instanceIsCompletedCount} ${title}`; return `${this.instanceIsCompletedCount} ${title}`;
}, },
instanceTitle() { instanceTitle() {
return n__('Instance', 'Instances', this.instanceCount); return n__('Instance', 'Instances', this.instanceCount);
}, },
projectName() { projectName() {
return '<projectname>'; return '<projectname>';
}, },
deployBoardSvg() { deployBoardSvg() {
return deployBoardSvg; return deployBoardSvg;
},
}, },
}; },
};
</script> </script>
<template> <template>
<div class="js-deploy-board deploy-board"> <div class="js-deploy-board deploy-board">
......
<script> <script>
/** /**
* An instance in deploy board is represented by a square in this mockup: * An instance in deploy board is represented by a square in this mockup:
* https://gitlab.com/gitlab-org/gitlab-ce/uploads/2f655655c0eadf655d0ae7467b53002a/environments__deploy-graphic.png * https://gitlab.com/gitlab-org/gitlab-ce/uploads/2f655655c0eadf655d0ae7467b53002a/environments__deploy-graphic.png
* *
* Each instance has a state and a tooltip. * Each instance has a state and a tooltip.
* The state needs to be represented in different colors, * The state needs to be represented in different colors,
* see more information about this in * see more information about this in
* https://gitlab.com/gitlab-org/gitlab-ee/uploads/5fff049fd88336d9ee0c6ef77b1ba7e3/monitoring__deployboard--key.png * https://gitlab.com/gitlab-org/gitlab-ee/uploads/5fff049fd88336d9ee0c6ef77b1ba7e3/monitoring__deployboard--key.png
* *
* An instance can represent a normal deploy or a canary deploy. In the latter we need to provide * An instance can represent a normal deploy or a canary deploy. In the latter we need to provide
* this information in the tooltip and the colors. * this information in the tooltip and the colors.
* Mockup is https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1551#note_26595150 * Mockup is https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1551#note_26595150
*/ */
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
export default { export default {
directives: { directives: {
tooltip, tooltip,
}, },
props: { props: {
/** /**
* Represents the status of the pod. Each state is represented with a different * Represents the status of the pod. Each state is represented with a different
* color. * color.
* It should be one of the following: * It should be one of the following:
* finished || deploying || failed || ready || preparing || waiting * finished || deploying || failed || ready || preparing || waiting
*/ */
status: { status: {
type: String, type: String,
required: true, required: true,
default: 'finished', default: 'finished',
}, },
tooltipText: { tooltipText: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
stable: { stable: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true, default: true,
}, },
podName: { podName: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
logsPath: { logsPath: {
type: String, type: String,
required: true, required: true,
},
}, },
},
computed: { computed: {
cssClass() { cssClass() {
let cssClassName = `deploy-board-instance-${this.status}`; let cssClassName = `deploy-board-instance-${this.status}`;
if (!this.stable) { if (!this.stable) {
cssClassName = `${cssClassName} deploy-board-instance-canary`; cssClassName = `${cssClassName} deploy-board-instance-canary`;
} }
return cssClassName; return cssClassName;
}, },
computedLogPath() { computedLogPath() {
return `${this.logsPath}?pod_name=${this.podName}`; return `${this.logsPath}?pod_name=${this.podName}`;
},
}, },
}; },
};
</script> </script>
<template> <template>
<a <a
......
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
import { stateEvent } from '../../constants'; import { stateEvent } from '../../constants';
export default { export default {
name: 'EpicHeader', name: 'EpicHeader',
directives: { directives: {
tooltip, tooltip,
},
components: {
Icon,
LoadingButton,
userAvatarLink,
timeagoTooltip,
},
props: {
author: {
type: Object,
required: true,
validator: value => value.url && value.username && value.name,
}, },
components: { created: {
Icon, type: String,
LoadingButton, required: true,
userAvatarLink,
timeagoTooltip,
}, },
props: { open: {
author: { type: Boolean,
type: Object, required: true,
required: true,
validator: value => value.url && value.username && value.name,
},
created: {
type: String,
required: true,
},
open: {
type: Boolean,
required: true,
},
canUpdate: {
required: true,
type: Boolean,
},
}, },
data() { canUpdate: {
return { required: true,
deleteLoading: false, type: Boolean,
statusUpdating: false,
isEpicOpen: this.open,
};
}, },
computed: { },
statusIcon() { data() {
return this.isEpicOpen ? 'issue-open-m' : 'mobile-issue-close'; return {
}, deleteLoading: false,
statusText() { statusUpdating: false,
return this.isEpicOpen ? __('Open') : __('Closed'); isEpicOpen: this.open,
}, };
actionButtonClass() { },
return `btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button ${this.isEpicOpen ? 'btn-close' : 'btn-open'}`; computed: {
}, statusIcon() {
actionButtonText() { return this.isEpicOpen ? 'issue-open-m' : 'mobile-issue-close';
return this.isEpicOpen ? __('Close epic') : __('Reopen epic');
},
}, },
mounted() { statusText() {
$(document).on('issuable_vue_app:change', (e, isClosed) => { return this.isEpicOpen ? __('Open') : __('Closed');
this.isEpicOpen = e.detail ? !e.detail.isClosed : !isClosed;
this.statusUpdating = false;
});
}, },
methods: { actionButtonClass() {
deleteEpic() { return `btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button ${
if (window.confirm(s__('Epic will be removed! Are you sure?'))) { // eslint-disable-line no-alert this.isEpicOpen ? 'btn-close' : 'btn-open'
this.deleteLoading = true; }`;
this.$emit('deleteEpic');
}
},
toggleSidebar() {
eventHub.$emit('toggleSidebar');
},
toggleStatus() {
this.statusUpdating = true;
this.$emit('toggleEpicStatus', this.isEpicOpen ? stateEvent.close : stateEvent.reopen);
},
}, },
}; actionButtonText() {
return this.isEpicOpen ? __('Close epic') : __('Reopen epic');
},
},
mounted() {
$(document).on('issuable_vue_app:change', (e, isClosed) => {
this.isEpicOpen = e.detail ? !e.detail.isClosed : !isClosed;
this.statusUpdating = false;
});
},
methods: {
deleteEpic() {
// eslint-disable-next-line no-alert
if (window.confirm(s__('Epic will be removed! Are you sure?'))) {
this.deleteLoading = true;
this.$emit('deleteEpic');
}
},
toggleSidebar() {
eventHub.$emit('toggleSidebar');
},
toggleStatus() {
this.statusUpdating = true;
this.$emit('toggleEpicStatus', this.isEpicOpen ? stateEvent.close : stateEvent.reopen);
},
},
};
</script> </script>
<template> <template>
......
...@@ -7,7 +7,9 @@ export default class SidebarContext { ...@@ -7,7 +7,9 @@ export default class SidebarContext {
constructor() { constructor() {
const $issuableSidebar = $('.js-issuable-update'); const $issuableSidebar = $('.js-issuable-update');
Mousetrap.bind('l', () => SidebarContext.openSidebarDropdown($issuableSidebar.find('.js-labels-block'))); Mousetrap.bind('l', () =>
SidebarContext.openSidebarDropdown($issuableSidebar.find('.js-labels-block')),
);
$issuableSidebar $issuableSidebar
.off('click', '.js-sidebar-dropdown-toggle') .off('click', '.js-sidebar-dropdown-toggle')
......
<script> <script>
import Flash from '~/flash'; import Flash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import loadingButton from '~/vue_shared/components/loading_button.vue'; import loadingButton from '~/vue_shared/components/loading_button.vue';
import NewEpicService from '../services/new_epic_service'; import NewEpicService from '../services/new_epic_service';
export default { export default {
name: 'NewEpic', name: 'NewEpic',
components: { components: {
loadingButton, loadingButton,
},
props: {
endpoint: {
type: String,
required: true,
}, },
props: { alignRight: {
endpoint: { type: Boolean,
type: String, required: false,
required: true, default: false,
},
alignRight: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { },
return { data() {
service: new NewEpicService(this.endpoint), return {
creating: false, service: new NewEpicService(this.endpoint),
title: '', creating: false,
}; title: '',
};
},
computed: {
buttonLabel() {
return this.creating ? s__('Creating epic') : s__('Create epic');
}, },
computed: { isCreatingDisabled() {
buttonLabel() { return this.title.length === 0;
return this.creating ? s__('Creating epic') : s__('Create epic');
},
isCreatingDisabled() {
return this.title.length === 0;
},
}, },
methods: { },
createEpic() { methods: {
this.creating = true; createEpic() {
this.service.createEpic(this.title) this.creating = true;
.then(({ data }) => { this.service
visitUrl(data.web_url); .createEpic(this.title)
}) .then(({ data }) => {
.catch(() => { visitUrl(data.web_url);
this.creating = false; })
Flash(s__('Error creating epic')); .catch(() => {
}); this.creating = false;
}, Flash(s__('Error creating epic'));
focusInput() { });
// Wait for dropdown to appear because of transition CSS
setTimeout(() => {
this.$refs.title.focus();
}, 25);
},
}, },
}; focusInput() {
// Wait for dropdown to appear because of transition CSS
setTimeout(() => {
this.$refs.title.focus();
}, 25);
},
},
};
</script> </script>
<template> <template>
......
...@@ -7,14 +7,16 @@ export default () => { ...@@ -7,14 +7,16 @@ export default () => {
if (el) { if (el) {
const props = el.dataset; const props = el.dataset;
new Vue({ // eslint-disable-line no-new // eslint-disable-next-line no-new
new Vue({
el, el,
components: { components: {
'new-epic-app': NewEpicApp, 'new-epic-app': NewEpicApp,
}, },
render: createElement => createElement('new-epic-app', { render: createElement =>
props, createElement('new-epic-app', {
}), props,
}),
}); });
} }
}; };
...@@ -119,7 +119,9 @@ export default { ...@@ -119,7 +119,9 @@ export default {
}, },
popoverOptions() { popoverOptions() {
return this.getPopoverConfig({ return this.getPopoverConfig({
title: s__('Epics|These dates affect how your epics appear in the roadmap. Dates from milestones come from the milestones assigned to issues in the epic. You can also set fixed dates or remove them entirely.'), title: s__(
'Epics|These dates affect how your epics appear in the roadmap. Dates from milestones come from the milestones assigned to issues in the epic. You can also set fixed dates or remove them entirely.',
),
content: ` content: `
<a <a
href="${gon.gitlab_url}/help/user/group/epics/index.md#start-date-and-due-date" href="${gon.gitlab_url}/help/user/group/epics/index.md#start-date-and-due-date"
...@@ -304,4 +306,3 @@ export default { ...@@ -304,4 +306,3 @@ export default {
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import Participants from '~/sidebar/components/participants/participants.vue'; import Participants from '~/sidebar/components/participants/participants.vue';
export default { export default {
components: { components: {
Participants, Participants,
},
props: {
participants: {
type: Array,
required: true,
}, },
props: { },
participants: { methods: {
type: Array, onToggleSidebar() {
required: true, this.$emit('toggleCollapse');
},
}, },
methods: { },
onToggleSidebar() { };
this.$emit('toggleCollapse');
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue'; import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
export default { export default {
components: { components: {
Subscriptions, Subscriptions,
},
props: {
loading: {
type: Boolean,
required: true,
}, },
props: { subscribed: {
loading: { type: Boolean,
type: Boolean, required: true,
required: true,
},
subscribed: {
type: Boolean,
required: true,
},
}, },
methods: { },
onToggleSubscription() { methods: {
this.$emit('toggleSubscription'); onToggleSubscription() {
}, this.$emit('toggleSubscription');
onToggleSidebar() {
this.$emit('toggleCollapse');
},
}, },
}; onToggleSidebar() {
this.$emit('toggleCollapse');
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { NODE_ACTIONS } from '../constants'; import { NODE_ACTIONS } from '../constants';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
export default { export default {
components: { components: {
Icon, Icon,
},
props: {
node: {
type: Object,
required: true,
}, },
props: { nodeActionsAllowed: {
node: { type: Boolean,
type: Object, required: true,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
nodeMissingOauth: {
type: Boolean,
required: true,
},
}, },
computed: { nodeEditAllowed: {
isToggleAllowed() { type: Boolean,
return !this.node.primary && this.nodeEditAllowed; required: true,
},
nodeToggleLabel() {
return this.node.enabled ? __('Disable') : __('Enable');
},
isSecondaryNode() {
return !this.node.primary;
},
}, },
methods: { nodeMissingOauth: {
onToggleNode() { type: Boolean,
eventHub.$emit('showNodeActionModal', { required: true,
actionType: NODE_ACTIONS.TOGGLE,
node: this.node,
modalMessage: s__('GeoNodes|Disabling a node stops the sync process. Are you sure?'),
modalActionLabel: this.nodeToggleLabel,
});
},
onRemoveNode() {
eventHub.$emit('showNodeActionModal', {
actionType: NODE_ACTIONS.REMOVE,
node: this.node,
modalKind: 'danger',
modalMessage: s__('GeoNodes|Removing a node stops the sync process. Are you sure?'),
modalActionLabel: __('Remove'),
});
},
onRepairNode() {
eventHub.$emit('repairNode', this.node);
},
}, },
}; },
computed: {
isToggleAllowed() {
return !this.node.primary && this.nodeEditAllowed;
},
nodeToggleLabel() {
return this.node.enabled ? __('Disable') : __('Enable');
},
isSecondaryNode() {
return !this.node.primary;
},
},
methods: {
onToggleNode() {
eventHub.$emit('showNodeActionModal', {
actionType: NODE_ACTIONS.TOGGLE,
node: this.node,
modalMessage: s__('GeoNodes|Disabling a node stops the sync process. Are you sure?'),
modalActionLabel: this.nodeToggleLabel,
});
},
onRemoveNode() {
eventHub.$emit('showNodeActionModal', {
actionType: NODE_ACTIONS.REMOVE,
node: this.node,
modalKind: 'danger',
modalMessage: s__('GeoNodes|Removing a node stops the sync process. Are you sure?'),
modalActionLabel: __('Remove'),
});
},
onRepairNode() {
eventHub.$emit('repairNode', this.node);
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import popover from '~/vue_shared/directives/popover'; import popover from '~/vue_shared/directives/popover';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import StackedProgressBar from '~/vue_shared/components/stacked_progress_bar.vue'; import StackedProgressBar from '~/vue_shared/components/stacked_progress_bar.vue';
import { VALUE_TYPE, CUSTOM_TYPE } from '../constants'; import { VALUE_TYPE, CUSTOM_TYPE } from '../constants';
import GeoNodeSyncSettings from './geo_node_sync_settings.vue'; import GeoNodeSyncSettings from './geo_node_sync_settings.vue';
import GeoNodeEventStatus from './geo_node_event_status.vue'; import GeoNodeEventStatus from './geo_node_event_status.vue';
export default { export default {
components: { components: {
Icon, Icon,
StackedProgressBar, StackedProgressBar,
GeoNodeSyncSettings, GeoNodeSyncSettings,
GeoNodeEventStatus, GeoNodeEventStatus,
}, },
directives: { directives: {
popover, popover,
tooltip, tooltip,
}, },
props: { props: {
itemTitle: { itemTitle: {
type: String, type: String,
required: true, required: true,
}, },
cssClass: { cssClass: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
itemValue: { itemValue: {
type: [Object, String, Number], type: [Object, String, Number],
required: true, required: true,
}, },
itemValueStale: { itemValueStale: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
itemValueStaleTooltip: { itemValueStaleTooltip: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
successLabel: { successLabel: {
type: String, type: String,
required: false, required: false,
default: s__('GeoNodes|Synced'), default: s__('GeoNodes|Synced'),
}, },
failureLabel: { failureLabel: {
type: String, type: String,
required: false, required: false,
default: s__('GeoNodes|Failed'), default: s__('GeoNodes|Failed'),
}, },
neutralLabel: { neutralLabel: {
type: String, type: String,
required: false, required: false,
default: s__('GeoNodes|Out of sync'), default: s__('GeoNodes|Out of sync'),
}, },
itemValueType: { itemValueType: {
type: String, type: String,
required: true, required: true,
}, },
customType: { customType: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
eventTypeLogStatus: { eventTypeLogStatus: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
helpInfo: { helpInfo: {
type: [Boolean, Object], type: [Boolean, Object],
required: false, required: false,
default: false, default: false,
}, },
}, },
computed: { computed: {
hasHelpInfo() { hasHelpInfo() {
return typeof this.helpInfo === 'object'; return typeof this.helpInfo === 'object';
}, },
isValueTypePlain() { isValueTypePlain() {
return this.itemValueType === VALUE_TYPE.PLAIN; return this.itemValueType === VALUE_TYPE.PLAIN;
}, },
isValueTypeGraph() { isValueTypeGraph() {
return this.itemValueType === VALUE_TYPE.GRAPH; return this.itemValueType === VALUE_TYPE.GRAPH;
}, },
isValueTypeCustom() { isValueTypeCustom() {
return this.itemValueType === VALUE_TYPE.CUSTOM; return this.itemValueType === VALUE_TYPE.CUSTOM;
}, },
isCustomTypeSync() { isCustomTypeSync() {
return this.customType === CUSTOM_TYPE.SYNC; return this.customType === CUSTOM_TYPE.SYNC;
}, },
popoverConfig() { popoverConfig() {
return { return {
html: true, html: true,
trigger: 'click', trigger: 'click',
placement: 'top', placement: 'top',
template: ` template: `
<div class="popover geo-node-detail-popover" role="tooltip"> <div class="popover geo-node-detail-popover" role="tooltip">
<div class="arrow"></div> <div class="arrow"></div>
<p class="popover-header"></p> <p class="popover-header"></p>
<div class="popover-body"></div> <div class="popover-body"></div>
</div> </div>
`, `,
title: this.helpInfo.title, title: this.helpInfo.title,
content: ` content: `
<a href="${this.helpInfo.url}"> <a href="${this.helpInfo.url}">
${this.helpInfo.urlText} ${this.helpInfo.urlText}
</a> </a>
`, `,
}; };
},
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
/* eslint-disable vue/no-side-effects-in-computed-properties */ /* eslint-disable vue/no-side-effects-in-computed-properties */
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import NodeDetailsSectionMain from './node_detail_sections/node_details_section_main.vue'; import NodeDetailsSectionMain from './node_detail_sections/node_details_section_main.vue';
import NodeDetailsSectionSync from './node_detail_sections/node_details_section_sync.vue'; import NodeDetailsSectionSync from './node_detail_sections/node_details_section_sync.vue';
import NodeDetailsSectionVerification from './node_detail_sections/node_details_section_verification.vue'; import NodeDetailsSectionVerification from './node_detail_sections/node_details_section_verification.vue';
import NodeDetailsSectionOther from './node_detail_sections/node_details_section_other.vue'; import NodeDetailsSectionOther from './node_detail_sections/node_details_section_other.vue';
export default { export default {
components: { components: {
NodeDetailsSectionMain, NodeDetailsSectionMain,
NodeDetailsSectionSync, NodeDetailsSectionSync,
NodeDetailsSectionVerification, NodeDetailsSectionVerification,
NodeDetailsSectionOther, NodeDetailsSectionOther,
},
props: {
node: {
type: Object,
required: true,
}, },
props: { nodeDetails: {
node: { type: Object,
type: Object, required: true,
required: true,
},
nodeDetails: {
type: Object,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
}, },
data() { nodeActionsAllowed: {
return { type: Boolean,
showAdvanceItems: false, required: true,
errorMessage: '',
};
}, },
computed: { nodeEditAllowed: {
hasError() { type: Boolean,
if (!this.nodeDetails.healthy) { required: true,
this.errorMessage = this.nodeDetails.health;
}
return !this.nodeDetails.healthy;
},
hasVersionMismatch() {
if (this.nodeDetails.version !== this.nodeDetails.primaryVersion ||
this.nodeDetails.revision !== this.nodeDetails.primaryRevision) {
this.errorMessage = s__('GeoNodes|GitLab version does not match the primary node version');
return true;
}
return false;
},
}, },
}; },
data() {
return {
showAdvanceItems: false,
errorMessage: '',
};
},
computed: {
hasError() {
if (!this.nodeDetails.healthy) {
this.errorMessage = this.nodeDetails.health;
}
return !this.nodeDetails.healthy;
},
hasVersionMismatch() {
if (
this.nodeDetails.version !== this.nodeDetails.primaryVersion ||
this.nodeDetails.revision !== this.nodeDetails.primaryRevision
) {
this.errorMessage = s__('GeoNodes|GitLab version does not match the primary node version');
return true;
}
return false;
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import timeAgoMixin from '~/vue_shared/mixins/timeago'; import timeAgoMixin from '~/vue_shared/mixins/timeago';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
export default { export default {
directives: { directives: {
tooltip, tooltip,
},
mixins: [timeAgoMixin],
props: {
eventId: {
type: Number,
required: true,
}, },
mixins: [ eventTimeStamp: {
timeAgoMixin, type: Number,
], required: true,
props: { default: 0,
eventId: {
type: Number,
required: true,
},
eventTimeStamp: {
type: Number,
required: true,
default: 0,
},
eventTypeLogStatus: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { eventTypeLogStatus: {
timeStamp() { type: Boolean,
return new Date(this.eventTimeStamp * 1000); required: false,
}, default: false,
timeStampString() {
return formatDate(this.timeStamp);
},
eventString() {
return this.eventId;
},
}, },
}; },
computed: {
timeStamp() {
return new Date(this.eventTimeStamp * 1000);
},
timeStampString() {
return formatDate(this.timeStamp);
},
eventString() {
return this.eventId;
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
export default { export default {
components: { components: {
icon, icon,
},
directives: {
tooltip,
},
props: {
node: {
type: Object,
required: true,
}, },
directives: { nodeDetails: {
tooltip, type: Object,
required: true,
}, },
props: { nodeDetailsLoading: {
node: { type: Boolean,
type: Object, required: true,
required: true,
},
nodeDetails: {
type: Object,
required: true,
},
nodeDetailsLoading: {
type: Boolean,
required: true,
},
nodeDetailsFailed: {
type: Boolean,
required: true,
},
}, },
computed: { nodeDetailsFailed: {
isNodeHTTP() { type: Boolean,
return this.node.url.startsWith('http://'); required: true,
}, },
showNodeStatusIcon() { },
if (this.nodeDetailsLoading) { computed: {
return false; isNodeHTTP() {
} return this.node.url.startsWith('http://');
},
showNodeStatusIcon() {
if (this.nodeDetailsLoading) {
return false;
}
return this.isNodeHTTP || this.nodeDetailsFailed; return this.isNodeHTTP || this.nodeDetailsFailed;
}, },
nodeStatusIconClass() { nodeStatusIconClass() {
const iconClasses = 'prepend-left-10 node-status-icon'; const iconClasses = 'prepend-left-10 node-status-icon';
if (this.nodeDetailsFailed) { if (this.nodeDetailsFailed) {
return `${iconClasses} status-icon-failure`; return `${iconClasses} status-icon-failure`;
} }
return `${iconClasses} status-icon-warning`; return `${iconClasses} status-icon-warning`;
}, },
nodeStatusIconName() { nodeStatusIconName() {
if (this.nodeDetailsFailed) { if (this.nodeDetailsFailed) {
return 'status_failed_borderless'; return 'status_failed_borderless';
} }
return 'warning'; return 'warning';
}, },
nodeStatusIconTooltip() { nodeStatusIconTooltip() {
if (this.nodeDetailsFailed) { if (this.nodeDetailsFailed) {
return ''; return '';
} }
return s__('GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.'); return s__(
}, 'GeoNodes|You have configured Geo nodes using an insecure HTTP connection. We recommend the use of HTTPS.',
);
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import { HEALTH_STATUS_ICON } from '../constants'; import { HEALTH_STATUS_ICON } from '../constants';
export default { export default {
components: { components: {
icon, icon,
},
props: {
status: {
type: String,
required: true,
}, },
props: { },
status: { computed: {
type: String, healthCssClass() {
required: true, return `geo-node-${this.status.toLowerCase()}`;
},
}, },
computed: { statusIconName() {
healthCssClass() { return HEALTH_STATUS_ICON[this.status.toLowerCase()];
return `geo-node-${this.status.toLowerCase()}`;
},
statusIconName() {
return HEALTH_STATUS_ICON[this.status.toLowerCase()];
},
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import { TIME_DIFF } from '../constants'; import { TIME_DIFF } from '../constants';
export default { export default {
directives: { directives: {
tooltip, tooltip,
},
components: {
icon,
},
props: {
syncStatusUnavailable: {
type: Boolean,
required: false,
default: false,
}, },
components: { selectiveSyncType: {
icon, type: String,
required: false,
default: null,
}, },
props: { lastEvent: {
syncStatusUnavailable: { type: Object,
type: Boolean, required: true,
required: false,
default: false,
},
selectiveSyncType: {
type: String,
required: false,
default: null,
},
lastEvent: {
type: Object,
required: true,
},
cursorLastEvent: {
type: Object,
required: true,
},
}, },
cursorLastEvent: {
type: Object,
required: true,
},
},
computed: { computed: {
syncType() { syncType() {
if (this.selectiveSyncType === null || this.selectiveSyncType === '') { if (this.selectiveSyncType === null || this.selectiveSyncType === '') {
return s__('GeoNodes|Full'); return s__('GeoNodes|Full');
} }
return `${s__('GeoNodes|Selective')} (${this.selectiveSyncType})`; return `${s__('GeoNodes|Selective')} (${this.selectiveSyncType})`;
}, },
eventTimestampEmpty() { eventTimestampEmpty() {
return this.lastEvent.timeStamp === 0 || this.cursorLastEvent.timeStamp === 0; return this.lastEvent.timeStamp === 0 || this.cursorLastEvent.timeStamp === 0;
}, },
syncLagInSeconds() { syncLagInSeconds() {
return this.lagInSeconds(this.lastEvent.timeStamp, this.cursorLastEvent.timeStamp); return this.lagInSeconds(this.lastEvent.timeStamp, this.cursorLastEvent.timeStamp);
}, },
syncStatusIcon() { syncStatusIcon() {
return this.statusIcon(this.syncLagInSeconds); return this.statusIcon(this.syncLagInSeconds);
}, },
syncStatusEventInfo() { syncStatusEventInfo() {
return this.statusEventInfo( return this.statusEventInfo(
this.lastEvent.id, this.lastEvent.id,
this.cursorLastEvent.id, this.cursorLastEvent.id,
this.syncLagInSeconds, this.syncLagInSeconds,
); );
},
syncStatusTooltip() {
return this.statusTooltip(this.syncLagInSeconds);
},
}, },
methods: { syncStatusTooltip() {
lagInSeconds(lastEventTimeStamp, cursorLastEventTimeStamp) { return this.statusTooltip(this.syncLagInSeconds);
let eventDateTime; },
let cursorDateTime; },
methods: {
lagInSeconds(lastEventTimeStamp, cursorLastEventTimeStamp) {
let eventDateTime;
let cursorDateTime;
if (lastEventTimeStamp && lastEventTimeStamp > 0) { if (lastEventTimeStamp && lastEventTimeStamp > 0) {
eventDateTime = new Date(lastEventTimeStamp * 1000); eventDateTime = new Date(lastEventTimeStamp * 1000);
} }
if (cursorLastEventTimeStamp && cursorLastEventTimeStamp > 0) { if (cursorLastEventTimeStamp && cursorLastEventTimeStamp > 0) {
cursorDateTime = new Date(cursorLastEventTimeStamp * 1000); cursorDateTime = new Date(cursorLastEventTimeStamp * 1000);
} }
return (cursorDateTime - eventDateTime) / 1000; return (cursorDateTime - eventDateTime) / 1000;
}, },
statusIcon(syncLag) { statusIcon(syncLag) {
if (syncLag <= TIME_DIFF.FIVE_MINS) { if (syncLag <= TIME_DIFF.FIVE_MINS) {
return 'retry'; return 'retry';
} else if (syncLag > TIME_DIFF.FIVE_MINS && } else if (syncLag > TIME_DIFF.FIVE_MINS && syncLag <= TIME_DIFF.HOUR) {
syncLag <= TIME_DIFF.HOUR) { return 'warning';
return 'warning'; }
} return 'status_failed';
return 'status_failed'; },
}, statusEventInfo(lastEventId, cursorLastEventId, lagInSeconds) {
statusEventInfo(lastEventId, cursorLastEventId, lagInSeconds) { const timeAgoStr = timeIntervalInWords(lagInSeconds);
const timeAgoStr = timeIntervalInWords(lagInSeconds); const pendingEvents = lastEventId - cursorLastEventId;
const pendingEvents = lastEventId - cursorLastEventId; return `${timeAgoStr} (${pendingEvents} events)`;
return `${timeAgoStr} (${pendingEvents} events)`; },
}, statusTooltip(lagInSeconds) {
statusTooltip(lagInSeconds) { if (this.eventTimestampEmpty || lagInSeconds <= TIME_DIFF.FIVE_MINS) {
if (this.eventTimestampEmpty || return '';
lagInSeconds <= TIME_DIFF.FIVE_MINS) { } else if (lagInSeconds > TIME_DIFF.FIVE_MINS && lagInSeconds <= TIME_DIFF.HOUR) {
return ''; return s__(
} else if (lagInSeconds > TIME_DIFF.FIVE_MINS && 'GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage.',
lagInSeconds <= TIME_DIFF.HOUR) { );
return s__('GeoNodeSyncStatus|Node is slow, overloaded, or it just recovered after an outage.'); }
} return s__('GeoNodeSyncStatus|Node is failing or broken.');
return s__('GeoNodeSyncStatus|Node is failing or broken.');
},
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import { __ } from '~/locale'; import { __ } from '~/locale';
import GeoNodeHealthStatus from '../geo_node_health_status.vue'; import GeoNodeHealthStatus from '../geo_node_health_status.vue';
import GeoNodeActions from '../geo_node_actions.vue'; import GeoNodeActions from '../geo_node_actions.vue';
export default { export default {
components: { components: {
GeoNodeHealthStatus, GeoNodeHealthStatus,
GeoNodeActions, GeoNodeActions,
},
props: {
node: {
type: Object,
required: true,
}, },
props: { nodeDetails: {
node: { type: Object,
type: Object, required: true,
required: true,
},
nodeDetails: {
type: Object,
required: true,
},
nodeActionsAllowed: {
type: Boolean,
required: true,
},
nodeEditAllowed: {
type: Boolean,
required: true,
},
versionMismatch: {
type: Boolean,
required: true,
},
}, },
computed: { nodeActionsAllowed: {
nodeVersion() { type: Boolean,
if (this.nodeDetails.version == null && required: true,
this.nodeDetails.revision == null) {
return __('Unknown');
}
return `${this.nodeDetails.version} (${this.nodeDetails.revision})`;
},
nodeHealthStatus() {
return this.nodeDetails.healthy ? this.nodeDetails.health : this.nodeDetails.healthStatus;
},
}, },
}; nodeEditAllowed: {
type: Boolean,
required: true,
},
versionMismatch: {
type: Boolean,
required: true,
},
},
computed: {
nodeVersion() {
if (this.nodeDetails.version == null && this.nodeDetails.revision == null) {
return __('Unknown');
}
return `${this.nodeDetails.version} (${this.nodeDetails.revision})`;
},
nodeHealthStatus() {
return this.nodeDetails.healthy ? this.nodeDetails.health : this.nodeDetails.healthStatus;
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { numberToHumanSize } from '~/lib/utils/number_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils';
import { VALUE_TYPE } from '../../constants'; import { VALUE_TYPE } from '../../constants';
import DetailsSectionMixin from '../../mixins/details_section_mixin'; import DetailsSectionMixin from '../../mixins/details_section_mixin';
import GeoNodeDetailItem from '../geo_node_detail_item.vue'; import GeoNodeDetailItem from '../geo_node_detail_item.vue';
import SectionRevealButton from './section_reveal_button.vue'; import SectionRevealButton from './section_reveal_button.vue';
export default { export default {
valueType: VALUE_TYPE, valueType: VALUE_TYPE,
components: { components: {
SectionRevealButton, SectionRevealButton,
GeoNodeDetailItem, GeoNodeDetailItem,
},
mixins: [DetailsSectionMixin],
props: {
nodeDetails: {
type: Object,
required: true,
}, },
mixins: [ nodeTypePrimary: {
DetailsSectionMixin, type: Boolean,
], required: true,
props: {
nodeDetails: {
type: Object,
required: true,
},
nodeTypePrimary: {
type: Boolean,
required: true,
},
}, },
data() { },
return { data() {
showSectionItems: false, return {
}; showSectionItems: false,
}, };
computed: { },
nodeDetailItems() { computed: {
if (this.nodeTypePrimary) { nodeDetailItems() {
// Return primary node detail items if (this.nodeTypePrimary) {
const primaryNodeDetailItems = [ // Return primary node detail items
{ const primaryNodeDetailItems = [
itemTitle: s__('GeoNodes|Replication slots'),
itemValue: this.nodeDetails.replicationSlots,
itemValueType: VALUE_TYPE.GRAPH,
successLabel: s__('GeoNodes|Used slots'),
neutraLabel: s__('GeoNodes|Unused slots'),
},
];
if (this.nodeDetails.replicationSlots.totalCount) {
primaryNodeDetailItems.push(
{
itemTitle: s__('GeoNodes|Replication slot WAL'),
itemValue: numberToHumanSize(this.nodeDetails.replicationSlotWAL),
itemValueType: VALUE_TYPE.PLAIN,
cssClass: 'node-detail-value-bold',
},
);
}
return primaryNodeDetailItems;
}
// Return secondary node detail items
return [
{ {
itemTitle: s__('GeoNodes|Storage config'), itemTitle: s__('GeoNodes|Replication slots'),
itemValue: this.storageShardsStatus, itemValue: this.nodeDetails.replicationSlots,
itemValueType: VALUE_TYPE.PLAIN, itemValueType: VALUE_TYPE.GRAPH,
cssClass: this.storageShardsCssClass, successLabel: s__('GeoNodes|Used slots'),
neutraLabel: s__('GeoNodes|Unused slots'),
}, },
]; ];
},
storageShardsStatus() { if (this.nodeDetails.replicationSlots.totalCount) {
if (this.nodeDetails.storageShardsMatch == null) { primaryNodeDetailItems.push({
return __('Unknown'); itemTitle: s__('GeoNodes|Replication slot WAL'),
itemValue: numberToHumanSize(this.nodeDetails.replicationSlotWAL),
itemValueType: VALUE_TYPE.PLAIN,
cssClass: 'node-detail-value-bold',
});
} }
return this.nodeDetails.storageShardsMatch ? __('OK') : s__('GeoNodes|Does not match the primary storage configuration');
}, return primaryNodeDetailItems;
storageShardsCssClass() { }
const cssClass = 'node-detail-value-bold';
return !this.nodeDetails.storageShardsMatch ? `${cssClass} node-detail-value-error` : cssClass; // Return secondary node detail items
}, return [
{
itemTitle: s__('GeoNodes|Storage config'),
itemValue: this.storageShardsStatus,
itemValueType: VALUE_TYPE.PLAIN,
cssClass: this.storageShardsCssClass,
},
];
},
storageShardsStatus() {
if (this.nodeDetails.storageShardsMatch == null) {
return __('Unknown');
}
return this.nodeDetails.storageShardsMatch
? __('OK')
: s__('GeoNodes|Does not match the primary storage configuration');
},
storageShardsCssClass() {
const cssClass = 'node-detail-value-bold';
return !this.nodeDetails.storageShardsMatch
? `${cssClass} node-detail-value-error`
: cssClass;
}, },
methods: { },
handleSectionToggle(toggleState) { methods: {
this.showSectionItems = toggleState; handleSectionToggle(toggleState) {
}, this.showSectionItems = toggleState;
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { VALUE_TYPE, HELP_INFO_URL } from '../../constants'; import { VALUE_TYPE, HELP_INFO_URL } from '../../constants';
import DetailsSectionMixin from '../../mixins/details_section_mixin'; import DetailsSectionMixin from '../../mixins/details_section_mixin';
import GeoNodeDetailItem from '../geo_node_detail_item.vue'; import GeoNodeDetailItem from '../geo_node_detail_item.vue';
import SectionRevealButton from './section_reveal_button.vue'; import SectionRevealButton from './section_reveal_button.vue';
export default { export default {
components: { components: {
GeoNodeDetailItem, GeoNodeDetailItem,
SectionRevealButton, SectionRevealButton,
},
mixins: [DetailsSectionMixin],
props: {
nodeDetails: {
type: Object,
required: true,
}, },
mixins: [ nodeTypePrimary: {
DetailsSectionMixin, type: Boolean,
], required: true,
props: {
nodeDetails: {
type: Object,
required: true,
},
nodeTypePrimary: {
type: Boolean,
required: true,
},
}, },
data() { },
return { data() {
showSectionItems: false, return {
primaryNodeDetailItems: this.getPrimaryNodeDetailItems(), showSectionItems: false,
secondaryNodeDetailItems: this.getSecondaryNodeDetailItems(), primaryNodeDetailItems: this.getPrimaryNodeDetailItems(),
}; secondaryNodeDetailItems: this.getSecondaryNodeDetailItems(),
};
},
computed: {
nodeDetailItems() {
return this.nodeTypePrimary
? this.getPrimaryNodeDetailItems()
: this.getSecondaryNodeDetailItems();
}, },
computed: { },
nodeDetailItems() { methods: {
return this.nodeTypePrimary ? getPrimaryNodeDetailItems() {
this.getPrimaryNodeDetailItems() : return [
this.getSecondaryNodeDetailItems(); {
}, itemTitle: s__('GeoNodes|Repository checksum progress'),
}, itemValue: this.nodeDetails.repositoriesChecksummed,
methods: { itemValueType: VALUE_TYPE.GRAPH,
getPrimaryNodeDetailItems() { successLabel: s__('GeoNodes|Checksummed'),
return [ neutraLabel: s__('GeoNodes|Not checksummed'),
{ failureLabel: s__('GeoNodes|Failed'),
itemTitle: s__('GeoNodes|Repository checksum progress'), helpInfo: {
itemValue: this.nodeDetails.repositoriesChecksummed, title: s__(
itemValueType: VALUE_TYPE.GRAPH, 'GeoNodes|Repositories checksummed for verification with their counterparts on Secondary nodes',
successLabel: s__('GeoNodes|Checksummed'), ),
neutraLabel: s__('GeoNodes|Not checksummed'), url: HELP_INFO_URL,
failureLabel: s__('GeoNodes|Failed'), urlText: s__('GeoNodes|Learn more about Repository checksum progress'),
helpInfo: {
title: s__('GeoNodes|Repositories checksummed for verification with their counterparts on Secondary nodes'),
url: HELP_INFO_URL,
urlText: s__('GeoNodes|Learn more about Repository checksum progress'),
},
}, },
{ },
itemTitle: s__('GeoNodes|Wiki checksum progress'), {
itemValue: this.nodeDetails.wikisChecksummed, itemTitle: s__('GeoNodes|Wiki checksum progress'),
itemValueType: VALUE_TYPE.GRAPH, itemValue: this.nodeDetails.wikisChecksummed,
successLabel: s__('GeoNodes|Checksummed'), itemValueType: VALUE_TYPE.GRAPH,
neutraLabel: s__('GeoNodes|Not checksummed'), successLabel: s__('GeoNodes|Checksummed'),
failureLabel: s__('GeoNodes|Failed'), neutraLabel: s__('GeoNodes|Not checksummed'),
helpInfo: { failureLabel: s__('GeoNodes|Failed'),
title: s__('GeoNodes|Wikis checksummed for verification with their counterparts on Secondary nodes'), helpInfo: {
url: HELP_INFO_URL, title: s__(
urlText: s__('GeoNodes|Learn more about Wiki checksum progress'), 'GeoNodes|Wikis checksummed for verification with their counterparts on Secondary nodes',
}, ),
url: HELP_INFO_URL,
urlText: s__('GeoNodes|Learn more about Wiki checksum progress'),
}, },
]; },
}, ];
getSecondaryNodeDetailItems() { },
return [ getSecondaryNodeDetailItems() {
{ return [
itemTitle: s__('GeoNodes|Repository verification progress'), {
itemValue: this.nodeDetails.verifiedRepositories, itemTitle: s__('GeoNodes|Repository verification progress'),
itemValueType: VALUE_TYPE.GRAPH, itemValue: this.nodeDetails.verifiedRepositories,
successLabel: s__('GeoNodes|Verified'), itemValueType: VALUE_TYPE.GRAPH,
neutraLabel: s__('GeoNodes|Unverified'), successLabel: s__('GeoNodes|Verified'),
failureLabel: s__('GeoNodes|Failed'), neutraLabel: s__('GeoNodes|Unverified'),
helpInfo: { failureLabel: s__('GeoNodes|Failed'),
title: s__('GeoNodes|Repositories verified with their counterparts on the Primary node'), helpInfo: {
url: HELP_INFO_URL, title: s__(
urlText: s__('GeoNodes|Learn more about Repository verification'), 'GeoNodes|Repositories verified with their counterparts on the Primary node',
}, ),
url: HELP_INFO_URL,
urlText: s__('GeoNodes|Learn more about Repository verification'),
}, },
{ },
itemTitle: s__('GeoNodes|Wiki verification progress'), {
itemValue: this.nodeDetails.verifiedWikis, itemTitle: s__('GeoNodes|Wiki verification progress'),
itemValueType: VALUE_TYPE.GRAPH, itemValue: this.nodeDetails.verifiedWikis,
successLabel: s__('GeoNodes|Verified'), itemValueType: VALUE_TYPE.GRAPH,
neutraLabel: s__('GeoNodes|Unverified'), successLabel: s__('GeoNodes|Verified'),
failureLabel: s__('GeoNodes|Failed'), neutraLabel: s__('GeoNodes|Unverified'),
helpInfo: { failureLabel: s__('GeoNodes|Failed'),
title: s__('GeoNodes|Wikis verified with their counterparts on the Primary node'), helpInfo: {
url: HELP_INFO_URL, title: s__('GeoNodes|Wikis verified with their counterparts on the Primary node'),
urlText: s__('GeoNodes|Learn more about Wiki verification'), url: HELP_INFO_URL,
}, urlText: s__('GeoNodes|Learn more about Wiki verification'),
}, },
]; },
}, ];
handleSectionToggle(toggleState) { },
this.showSectionItems = toggleState; handleSectionToggle(toggleState) {
}, this.showSectionItems = toggleState;
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
export default { export default {
components: { components: {
icon, icon,
},
props: {
buttonTitle: {
type: String,
required: true,
}, },
props: { },
buttonTitle: { data() {
type: String, return {
required: true, toggleState: false,
}, };
},
computed: {
toggleButtonIcon() {
return this.toggleState ? 'angle-up' : 'angle-down';
}, },
data() { },
return { methods: {
toggleState: false, onClickButton() {
}; this.toggleState = !this.toggleState;
this.$emit('toggleButton', this.toggleState);
}, },
computed: { },
toggleButtonIcon() { };
return this.toggleState ? 'angle-up' : 'angle-down';
},
},
methods: {
onClickButton() {
this.toggleState = !this.toggleState;
this.$emit('toggleButton', this.toggleState);
},
},
};
</script> </script>
<template> <template>
......
...@@ -30,4 +30,5 @@ export const TIME_DIFF = { ...@@ -30,4 +30,5 @@ export const TIME_DIFF = {
export const STATUS_DELAY_THRESHOLD_MS = 60000; export const STATUS_DELAY_THRESHOLD_MS = 60000;
export const HELP_INFO_URL = 'https://docs.gitlab.com/ee/administration/geo/disaster_recovery/background_verification.html#repository-verification'; export const HELP_INFO_URL =
'https://docs.gitlab.com/ee/administration/geo/disaster_recovery/background_verification.html#repository-verification';
...@@ -13,9 +13,7 @@ export default { ...@@ -13,9 +13,7 @@ export default {
}, },
statusInfoStaleMessage() { statusInfoStaleMessage() {
return sprintf(s__('GeoNodes|Data is out of date from %{timeago}'), { return sprintf(s__('GeoNodes|Data is out of date from %{timeago}'), {
timeago: this.timeFormated( timeago: this.timeFormated(this.nodeDetails.statusCheckTimestamp),
this.nodeDetails.statusCheckTimestamp,
),
}); });
}, },
}, },
......
...@@ -8,9 +8,7 @@ export default class GeoNodesStore { ...@@ -8,9 +8,7 @@ export default class GeoNodesStore {
} }
setNodes(nodes) { setNodes(nodes) {
this.state.nodes = nodes.map( this.state.nodes = nodes.map(node => GeoNodesStore.formatNode(node));
node => GeoNodesStore.formatNode(node),
);
} }
getNodes() { getNodes() {
......
...@@ -19,7 +19,7 @@ const setupAutoCompleteEpics = ($input, defaultCallbacks) => { ...@@ -19,7 +19,7 @@ const setupAutoCompleteEpics = ($input, defaultCallbacks) => {
callbacks: { callbacks: {
...defaultCallbacks, ...defaultCallbacks,
beforeSave(merges) { beforeSave(merges) {
return $.map(merges, (m) => { return $.map(merges, m => {
if (m.title == null) { if (m.title == null) {
return m; return m;
} }
......
...@@ -11,18 +11,16 @@ export default () => { ...@@ -11,18 +11,16 @@ export default () => {
modal: true, modal: true,
show: false, show: false,
}) })
.on('show.bs.modal', (e) => { .on('show.bs.modal', e => {
const { const { cloneUrlPrimary, cloneUrlSecondary } = $(e.currentTarget).data();
cloneUrlPrimary,
cloneUrlSecondary,
} = $(e.currentTarget).data();
$('#geo-info-1').val( $('#geo-info-1').val(
`git clone ${(cloneUrlSecondary || '<clone url for secondary repository>')}`, `git clone ${cloneUrlSecondary || '<clone url for secondary repository>'}`,
); );
$('#geo-info-2').val( $('#geo-info-2').val(
`git remote set-url --push origin ${(cloneUrlPrimary || '<clone url for primary repository>')}`, `git remote set-url --push origin ${cloneUrlPrimary ||
'<clone url for primary repository>'}`,
); );
}); });
}; };
...@@ -78,11 +78,15 @@ export default class KubernetesPodLogs extends LogOutputBehaviours { ...@@ -78,11 +78,15 @@ export default class KubernetesPodLogs extends LogOutputBehaviours {
this.$podDropdown this.$podDropdown
.find('.dropdown-menu-toggle') .find('.dropdown-menu-toggle')
.html(`<span class="dropdown-toggle-text">${this.podName}</span><i class="fa fa-chevron-down"></i>`); .html(
`<span class="dropdown-toggle-text">${
this.podName
}</span><i class="fa fa-chevron-down"></i>`,
);
$podDropdownMenu.off('click'); $podDropdownMenu.off('click');
$podDropdownMenu.empty(); $podDropdownMenu.empty();
pods.forEach((pod) => { pods.forEach(pod => {
$podDropdownMenu.append(` $podDropdownMenu.append(`
<button class='dropdown-item'> <button class='dropdown-item'>
${_.escape(pod)} ${_.escape(pod)}
......
...@@ -16,7 +16,7 @@ export default function initLDAPGroupsSelect() { ...@@ -16,7 +16,7 @@ export default function initLDAPGroupsSelect() {
id: function(group) { id: function(group) {
return group.cn; return group.cn;
}, },
placeholder: "Search for a LDAP group", placeholder: 'Search for a LDAP group',
minimumInputLength: 1, minimumInputLength: 1,
query: function(query) { query: function(query) {
var provider; var provider;
...@@ -24,7 +24,7 @@ export default function initLDAPGroupsSelect() { ...@@ -24,7 +24,7 @@ export default function initLDAPGroupsSelect() {
return Api.ldap_groups(query.term, provider, function(groups) { return Api.ldap_groups(query.term, provider, function(groups) {
var data; var data;
data = { data = {
results: groups results: groups,
}; };
return query.callback(data); return query.callback(data);
}); });
...@@ -32,18 +32,18 @@ export default function initLDAPGroupsSelect() { ...@@ -32,18 +32,18 @@ export default function initLDAPGroupsSelect() {
initSelection: function(element, callback) { initSelection: function(element, callback) {
var id; var id;
id = $(element).val(); id = $(element).val();
if (id !== "") { if (id !== '') {
return callback({ return callback({
cn: id cn: id,
}); });
} }
}, },
formatResult: ldapGroupResult, formatResult: ldapGroupResult,
formatSelection: groupFormatSelection, formatSelection: groupFormatSelection,
dropdownCssClass: "ajax-groups-dropdown", dropdownCssClass: 'ajax-groups-dropdown',
formatNoMatches: function(nomatch) { formatNoMatches: function(nomatch) {
return "Match not found; try refining your search query."; return 'Match not found; try refining your search query.';
} },
}); });
}); });
return $('#ldap_group_link_provider').on('change', function() { return $('#ldap_group_link_provider').on('change', function() {
......
...@@ -96,7 +96,11 @@ export default class MirrorPull { ...@@ -96,7 +96,11 @@ export default class MirrorPull {
// Make backOff polling to get data // Make backOff polling to get data
backOff((next, stop) => { backOff((next, stop) => {
axios axios
.get(`${projectMirrorSSHEndpoint}?ssh_url=${repositoryUrl}&compare_host_keys=${encodeURIComponent(currentKnownHosts)}`) .get(
`${projectMirrorSSHEndpoint}?ssh_url=${repositoryUrl}&compare_host_keys=${encodeURIComponent(
currentKnownHosts,
)}`,
)
.then(({ data, status }) => { .then(({ data, status }) => {
if (status === 204) { if (status === 204) {
this.backOffRequestCounter += 1; this.backOffRequestCounter += 1;
......
...@@ -97,14 +97,12 @@ export default { ...@@ -97,14 +97,12 @@ export default {
this.isLoading = true; this.isLoading = true;
return Promise.all( return Promise.all(
this.alerts.map(alertPath => this.alerts.map(alertPath =>
this.service this.service.readAlert(alertPath).then(alertData => {
.readAlert(alertPath) this.$emit('setAlerts', this.customMetricId, {
.then(alertData => { ...this.alertData,
this.$emit('setAlerts', this.customMetricId, { [alertPath]: alertData,
...this.alertData, });
[alertPath]: alertData, }),
});
}),
), ),
) )
.then(() => { .then(() => {
......
...@@ -26,17 +26,15 @@ export default { ...@@ -26,17 +26,15 @@ export default {
const [xMin, xMax] = this.graphDrawData.xDom; const [xMin, xMax] = this.graphDrawData.xDom;
const [yMin, yMax] = this.graphDrawData.yDom; const [yMin, yMax] = this.graphDrawData.yDom;
const outOfRange = (this.operator === '>' && this.threshold > yMax) || const outOfRange =
(this.operator === '>' && this.threshold > yMax) ||
(this.operator === '<' && this.threshold < yMin); (this.operator === '<' && this.threshold < yMin);
if (outOfRange) { if (outOfRange) {
return []; return [];
} }
return [ return [{ time: xMin, value: this.threshold }, { time: xMax, value: this.threshold }];
{ time: xMin, value: this.threshold },
{ time: xMax, value: this.threshold },
];
}, },
linePath() { linePath() {
if (!this.graphDrawData.lineFunction) { if (!this.graphDrawData.lineFunction) {
......
...@@ -4,60 +4,74 @@ import $ from 'jquery'; ...@@ -4,60 +4,74 @@ import $ from 'jquery';
import Api from '~/api'; import Api from '~/api';
function AdminEmailSelect() { function AdminEmailSelect() {
$('.ajax-admin-email-select').each((function(_this) { $('.ajax-admin-email-select').each(
return function(i, select) { (function(_this) {
var skip_ldap; return function(i, select) {
skip_ldap = $(select).hasClass('skip_ldap'); var skip_ldap;
return $(select).select2({ skip_ldap = $(select).hasClass('skip_ldap');
placeholder: "Select group or project", return $(select).select2({
multiple: $(select).hasClass('multiselect'), placeholder: 'Select group or project',
minimumInputLength: 0, multiple: $(select).hasClass('multiselect'),
query: function(query) { minimumInputLength: 0,
const groupsFetch = Api.groups(query.term, {}); query: function(query) {
const projectsFetch = Api.projects(query.term, { const groupsFetch = Api.groups(query.term, {});
order_by: 'id', const projectsFetch = Api.projects(query.term, {
membership: false order_by: 'id',
}); membership: false,
return Promise.all([projectsFetch, groupsFetch]).then(function([projects, groups]) {
var all, data;
all = {
id: "all"
};
data = [all].concat(groups, projects);
return query.callback({
results: data
}); });
}); return Promise.all([projectsFetch, groupsFetch]).then(function([projects, groups]) {
}, var all, data;
id: function(object) { all = {
if (object.path_with_namespace) { id: 'all',
return "project-" + object.id; };
} else if (object.path) { data = [all].concat(groups, projects);
return "group-" + object.id; return query.callback({
} else { results: data,
return "all"; });
} });
}, },
formatResult(...args) { id: function(object) {
return _this.formatResult(...args); if (object.path_with_namespace) {
}, return 'project-' + object.id;
formatSelection(...args) { } else if (object.path) {
return _this.formatSelection(...args); return 'group-' + object.id;
}, } else {
dropdownCssClass: "ajax-admin-email-dropdown", return 'all';
escapeMarkup: function(m) { }
return m; },
} formatResult(...args) {
}); return _this.formatResult(...args);
}; },
})(this)); formatSelection(...args) {
return _this.formatSelection(...args);
},
dropdownCssClass: 'ajax-admin-email-dropdown',
escapeMarkup: function(m) {
return m;
},
});
};
})(this),
);
} }
AdminEmailSelect.prototype.formatResult = function(object) { AdminEmailSelect.prototype.formatResult = function(object) {
if (object.path_with_namespace) { if (object.path_with_namespace) {
return "<div class='project-result'> <div class='project-name'>" + object.name + "</div> <div class='project-path'>" + object.path_with_namespace + "</div> </div>"; return (
"<div class='project-result'> <div class='project-name'>" +
object.name +
"</div> <div class='project-path'>" +
object.path_with_namespace +
'</div> </div>'
);
} else if (object.path) { } else if (object.path) {
return "<div class='group-result'> <div class='group-name'>" + object.name + "</div> <div class='group-path'>" + object.path + "</div> </div>"; return (
"<div class='group-result'> <div class='group-name'>" +
object.name +
"</div> <div class='group-path'>" +
object.path +
'</div> </div>'
);
} else { } else {
return "<div class='group-result'> <div class='group-name'>All</div> <div class='group-path'>All groups and projects</div> </div>"; return "<div class='group-result'> <div class='group-name'>All</div> <div class='group-path'>All groups and projects</div> </div>";
} }
...@@ -65,11 +79,11 @@ AdminEmailSelect.prototype.formatResult = function(object) { ...@@ -65,11 +79,11 @@ AdminEmailSelect.prototype.formatResult = function(object) {
AdminEmailSelect.prototype.formatSelection = function(object) { AdminEmailSelect.prototype.formatSelection = function(object) {
if (object.path_with_namespace) { if (object.path_with_namespace) {
return "Project: " + object.name; return 'Project: ' + object.name;
} else if (object.path) { } else if (object.path) {
return "Group: " + object.name; return 'Group: ' + object.name;
} else { } else {
return "All groups and projects"; return 'All groups and projects';
} }
}; };
......
...@@ -24,11 +24,11 @@ export default function geoNodeForm() { ...@@ -24,11 +24,11 @@ export default function geoNodeForm() {
const $syncByNamespaces = $('.js-sync-by-namespace', $container); const $syncByNamespaces = $('.js-sync-by-namespace', $container);
const $syncByShards = $('.js-sync-by-shard', $container); const $syncByShards = $('.js-sync-by-shard', $container);
$primaryCheckbox.on('change', e => $primaryCheckbox.on('change', e => onPrimaryCheckboxChange(e, $namespaces));
onPrimaryCheckboxChange(e, $namespaces));
$selectiveSyncTypeSelect.on('change', e => $selectiveSyncTypeSelect.on('change', e =>
onSelectiveSyncTypeChange(e, $syncByNamespaces, $syncByShards)); onSelectiveSyncTypeChange(e, $syncByNamespaces, $syncByShards),
);
$select2Dropdown.select2({ $select2Dropdown.select2({
placeholder: s__('Geo|Select groups to replicate.'), placeholder: s__('Geo|Select groups to replicate.'),
......
...@@ -25,7 +25,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -25,7 +25,7 @@ document.addEventListener('DOMContentLoaded', () => {
merge_requests_created: [], merge_requests_created: [],
}; };
outputElIds.forEach((id) => { outputElIds.forEach(id => {
data[id].data.forEach((d, index) => { data[id].data.forEach((d, index) => {
formattedData[id].push({ formattedData[id].push({
name: data.labels[index], name: data.labels[index],
......
...@@ -107,10 +107,9 @@ export default class EEMirrorRepos extends MirrorRepos { ...@@ -107,10 +107,9 @@ export default class EEMirrorRepos extends MirrorRepos {
}; };
} }
return super.deleteMirror(event, payload) return super.deleteMirror(event, payload).then(() => {
.then(() => { if (isPullMirror) this.$mirrorDirectionSelect.removeAttr('disabled');
if (isPullMirror) this.$mirrorDirectionSelect.removeAttr('disabled'); });
});
} }
removeRow($target) { removeRow($target) {
......
...@@ -4,10 +4,7 @@ export default () => { ...@@ -4,10 +4,7 @@ export default () => {
const dataEl = document.getElementById('js-file-lock'); const dataEl = document.getElementById('js-file-lock');
if (dataEl) { if (dataEl) {
const { const { toggle_path, path } = JSON.parse(dataEl.innerHTML);
toggle_path,
path,
} = JSON.parse(dataEl.innerHTML);
initPathLocks(toggle_path, path); initPathLocks(toggle_path, path);
} }
......
...@@ -4,13 +4,16 @@ import { __ } from '~/locale'; ...@@ -4,13 +4,16 @@ import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
export default function initPathLocks(url, path) { export default function initPathLocks(url, path) {
$('a.path-lock').on('click', (e) => { $('a.path-lock').on('click', e => {
e.preventDefault(); e.preventDefault();
axios.post(url, { axios
path, .post(url, {
}).then(() => { path,
window.location.reload(); })
}).catch(() => flash(__('An error occurred while initializing path locks'))); .then(() => {
window.location.reload();
})
.catch(() => flash(__('An error occurred while initializing path locks')));
}); });
} }
<script> <script>
import ciStatus from '~/vue_shared/components/ci_icon.vue'; import ciStatus from '~/vue_shared/components/ci_icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
export default { export default {
directives: { directives: {
tooltip, tooltip,
},
components: {
ciStatus,
},
props: {
pipelineId: {
type: Number,
required: true,
}, },
components: { pipelinePath: {
ciStatus, type: String,
required: true,
}, },
props: { pipelineStatus: {
pipelineId: { type: Object,
type: Number, required: true,
required: true,
},
pipelinePath: {
type: String,
required: true,
},
pipelineStatus: {
type: Object,
required: true,
},
projectName: {
type: String,
required: true,
},
}, },
computed: { projectName: {
tooltipText() { type: String,
return `${this.projectName} - ${this.pipelineStatus.label}`; required: true,
},
}, },
}; },
computed: {
tooltipText() {
return `${this.projectName} - ${this.pipelineStatus.label}`;
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import linkedPipeline from './linked_pipeline.vue'; import linkedPipeline from './linked_pipeline.vue';
export default { export default {
components: { components: {
linkedPipeline, linkedPipeline,
},
props: {
columnTitle: {
type: String,
required: true,
}, },
props: { linkedPipelines: {
columnTitle: { type: Array,
type: String, required: true,
required: true,
},
linkedPipelines: {
type: Array,
required: true,
},
graphPosition: {
type: String,
required: true,
},
}, },
graphPosition: {
type: String,
required: true,
},
},
computed: { computed: {
columnClass() { columnClass() {
return `graph-position-${this.graphPosition}`; return `graph-position-${this.graphPosition}`;
},
}, },
}; },
};
</script> </script>
<template> <template>
......
...@@ -46,8 +46,9 @@ const bindEvents = () => { ...@@ -46,8 +46,9 @@ const bindEvents = () => {
const $activeTabProjectName = $('.tab-pane.active #project_name'); const $activeTabProjectName = $('.tab-pane.active #project_name');
const $activeTabProjectPath = $('.tab-pane.active #project_path'); const $activeTabProjectPath = $('.tab-pane.active #project_path');
$activeTabProjectName.focus(); $activeTabProjectName.focus();
$activeTabProjectName $activeTabProjectName.keyup(() =>
.keyup(() => projectNew.onProjectNameChange($activeTabProjectName, $activeTabProjectPath)); projectNew.onProjectNameChange($activeTabProjectName, $activeTabProjectPath),
);
} }
$useCustomTemplateBtn.on('change', chooseTemplate); $useCustomTemplateBtn.on('change', chooseTemplate);
...@@ -60,7 +61,6 @@ const bindEvents = () => { ...@@ -60,7 +61,6 @@ const bindEvents = () => {
}; };
export default () => { export default () => {
const $navElement = $('.nav-link[href="#custom-templates"]'); const $navElement = $('.nav-link[href="#custom-templates"]');
const $tabContent = $('.project-templates-buttons#custom-templates'); const $tabContent = $('.project-templates-buttons#custom-templates');
......
<script> <script>
import Flash from '~/flash'; import Flash from '~/flash';
import serviceDeskSetting from './service_desk_setting.vue'; import serviceDeskSetting from './service_desk_setting.vue';
import ServiceDeskStore from '../stores/service_desk_store'; import ServiceDeskStore from '../stores/service_desk_store';
import ServiceDeskService from '../services/service_desk_service'; import ServiceDeskService from '../services/service_desk_service';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
name: 'ServiceDeskRoot', name: 'ServiceDeskRoot',
components: { components: {
serviceDeskSetting, serviceDeskSetting,
},
props: {
initialIsEnabled: {
type: Boolean,
required: true,
}, },
props: { endpoint: {
initialIsEnabled: { type: String,
type: Boolean, required: true,
required: true,
},
endpoint: {
type: String,
required: true,
},
incomingEmail: {
type: String,
required: false,
default: '',
},
}, },
incomingEmail: {
type: String,
required: false,
default: '',
},
},
data() { data() {
const store = new ServiceDeskStore({ const store = new ServiceDeskStore({
incomingEmail: this.incomingEmail, incomingEmail: this.incomingEmail,
}); });
return { return {
store, store,
state: store.state, state: store.state,
isEnabled: this.initialIsEnabled, isEnabled: this.initialIsEnabled,
}; };
}, },
created() { created() {
eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled);
this.service = new ServiceDeskService(this.endpoint); this.service = new ServiceDeskService(this.endpoint);
if (this.isEnabled && !this.store.state.incomingEmail) { if (this.isEnabled && !this.store.state.incomingEmail) {
this.fetchIncomingEmail(); this.fetchIncomingEmail();
} }
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); eventHub.$off('serviceDeskEnabledCheckboxToggled', this.onEnableToggled);
}, },
methods: { methods: {
fetchIncomingEmail() { fetchIncomingEmail() {
if (this.flash) { if (this.flash) {
this.flash.innerHTML = ''; this.flash.innerHTML = '';
} }
this.service.fetchIncomingEmail() this.service
.then(res => res.json()) .fetchIncomingEmail()
.then((data) => { .then(res => res.json())
const email = data.service_desk_address; .then(data => {
if (!email) { const email = data.service_desk_address;
throw new Error('Response didn\'t include `service_desk_address`'); if (!email) {
} throw new Error("Response didn't include `service_desk_address`");
}
this.store.setIncomingEmail(email); this.store.setIncomingEmail(email);
}) })
.catch(() => { .catch(() => {
this.flash = new Flash('An error occurred while fetching the Service Desk address.', 'alert', this.$el); this.flash = new Flash(
}); 'An error occurred while fetching the Service Desk address.',
}, 'alert',
this.$el,
);
});
},
onEnableToggled(isChecked) { onEnableToggled(isChecked) {
this.isEnabled = isChecked; this.isEnabled = isChecked;
this.store.resetIncomingEmail(); this.store.resetIncomingEmail();
if (this.flash) { if (this.flash) {
this.flash.destroy(); this.flash.destroy();
} }
this.service.toggleServiceDesk(isChecked) this.service
.then(res => res.json()) .toggleServiceDesk(isChecked)
.then((data) => { .then(res => res.json())
const email = data.service_desk_address; .then(data => {
if (isChecked && !email) { const email = data.service_desk_address;
throw new Error('Response didn\'t include `service_desk_address`'); if (isChecked && !email) {
} throw new Error("Response didn't include `service_desk_address`");
}
this.store.setIncomingEmail(email); this.store.setIncomingEmail(email);
}) })
.catch(() => { .catch(() => {
const verb = isChecked ? 'enabling' : 'disabling'; const verb = isChecked ? 'enabling' : 'disabling';
this.flash = new Flash(`An error occurred while ${verb} Service Desk.`, 'alert', this.$el); this.flash = new Flash(
}); `An error occurred while ${verb} Service Desk.`,
}, 'alert',
this.$el,
);
});
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
name: 'ServiceDeskSetting', name: 'ServiceDeskSetting',
directives: { directives: {
tooltip, tooltip,
}, },
components: { components: {
ClipboardButton, ClipboardButton,
}, },
props: { props: {
isEnabled: { isEnabled: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
incomingEmail: { incomingEmail: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
},
}, },
methods: { },
onCheckboxToggle(e) { methods: {
const isChecked = e.target.checked; onCheckboxToggle(e) {
eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked); const isChecked = e.target.checked;
}, eventHub.$emit('serviceDeskEnabledCheckboxToggled', isChecked);
}, },
}; },
};
</script> </script>
<template> <template>
......
...@@ -14,9 +14,7 @@ export default () => { ...@@ -14,9 +14,7 @@ export default () => {
data() { data() {
const { dataset } = serviceDeskRootElement; const { dataset } = serviceDeskRootElement;
return { return {
initialIsEnabled: convertPermissionToBoolean( initialIsEnabled: convertPermissionToBoolean(dataset.enabled),
dataset.enabled,
),
endpoint: dataset.endpoint, endpoint: dataset.endpoint,
incomingEmail: dataset.incomingEmail, incomingEmail: dataset.incomingEmail,
}; };
......
class ServiceDeskStore { class ServiceDeskStore {
constructor(initialState = {}) { constructor(initialState = {}) {
this.state = Object.assign({ this.state = Object.assign(
incomingEmail: '', {
}, initialState); incomingEmail: '',
},
initialState,
);
} }
setIncomingEmail(value) { setIncomingEmail(value) {
......
import Stats from 'ee/stats'; import Stats from 'ee/stats';
const bindTrackEvents = (container) => { const bindTrackEvents = container => {
Stats.bindTrackableContainer(container); Stats.bindTrackableContainer(container);
}; };
......
...@@ -11,18 +11,32 @@ export default class EEPrometheusMetrics extends PrometheusMetrics { ...@@ -11,18 +11,32 @@ export default class EEPrometheusMetrics extends PrometheusMetrics {
super(wrapperSelector); super(wrapperSelector);
this.$wrapperCustomMetrics = $(wrapperSelector); this.$wrapperCustomMetrics = $(wrapperSelector);
this.$monitoredCustomMetricsPanel = this.$wrapperCustomMetrics.find('.js-panel-custom-monitored-metrics'); this.$monitoredCustomMetricsPanel = this.$wrapperCustomMetrics.find(
this.$monitoredCustomMetricsCount = this.$monitoredCustomMetricsPanel.find('.js-custom-monitored-count'); '.js-panel-custom-monitored-metrics',
this.$monitoredCustomMetricsLoading = this.$monitoredCustomMetricsPanel.find('.js-loading-custom-metrics'); );
this.$monitoredCustomMetricsEmpty = this.$monitoredCustomMetricsPanel.find('.js-empty-custom-metrics'); this.$monitoredCustomMetricsCount = this.$monitoredCustomMetricsPanel.find(
this.$monitoredCustomMetricsList = this.$monitoredCustomMetricsPanel.find('.js-custom-metrics-list'); '.js-custom-monitored-count',
);
this.$monitoredCustomMetricsLoading = this.$monitoredCustomMetricsPanel.find(
'.js-loading-custom-metrics',
);
this.$monitoredCustomMetricsEmpty = this.$monitoredCustomMetricsPanel.find(
'.js-empty-custom-metrics',
);
this.$monitoredCustomMetricsList = this.$monitoredCustomMetricsPanel.find(
'.js-custom-metrics-list',
);
this.$newCustomMetricButton = this.$monitoredCustomMetricsPanel.find('.js-new-metric-button'); this.$newCustomMetricButton = this.$monitoredCustomMetricsPanel.find('.js-new-metric-button');
this.$flashCustomMetricsContainer = this.$wrapperCustomMetrics.find('.flash-container'); this.$flashCustomMetricsContainer = this.$wrapperCustomMetrics.find('.flash-container');
this.customMetrics = []; this.customMetrics = [];
this.environmentsData = []; this.environmentsData = [];
this.activeCustomMetricsEndpoint = this.$monitoredCustomMetricsPanel.data('active-custom-metrics'); this.activeCustomMetricsEndpoint = this.$monitoredCustomMetricsPanel.data(
this.environmentsDataEndpoint = this.$monitoredCustomMetricsPanel.data('environments-data-endpoint'); 'active-custom-metrics',
);
this.environmentsDataEndpoint = this.$monitoredCustomMetricsPanel.data(
'environments-data-endpoint',
);
} }
showMonitoringCustomMetricsPanelState(stateName) { showMonitoringCustomMetricsPanelState(stateName) {
...@@ -49,20 +63,25 @@ export default class EEPrometheusMetrics extends PrometheusMetrics { ...@@ -49,20 +63,25 @@ export default class EEPrometheusMetrics extends PrometheusMetrics {
} }
populateCustomMetrics() { populateCustomMetrics() {
const sortedMetrics = _(this.customMetrics).chain() const sortedMetrics = _(this.customMetrics)
.chain()
.map(metric => ({ ...metric, group: capitalizeFirstCharacter(metric.group) })) .map(metric => ({ ...metric, group: capitalizeFirstCharacter(metric.group) }))
.sortBy('title') .sortBy('title')
.sortBy('group') .sortBy('group')
.value(); .value();
sortedMetrics.forEach((metric) => { sortedMetrics.forEach(metric => {
this.$monitoredCustomMetricsList.append(EEPrometheusMetrics.customMetricTemplate(metric)); this.$monitoredCustomMetricsList.append(EEPrometheusMetrics.customMetricTemplate(metric));
}); });
this.$monitoredCustomMetricsCount.text(this.customMetrics.length); this.$monitoredCustomMetricsCount.text(this.customMetrics.length);
this.showMonitoringCustomMetricsPanelState(PANEL_STATE.LIST); this.showMonitoringCustomMetricsPanelState(PANEL_STATE.LIST);
if (!this.environmentsData) { if (!this.environmentsData) {
this.showFlashMessage(s__('PrometheusService|These metrics will only be monitored after your first deployment to an environment')); this.showFlashMessage(
s__(
'PrometheusService|These metrics will only be monitored after your first deployment to an environment',
),
);
} }
} }
...@@ -86,7 +105,7 @@ export default class EEPrometheusMetrics extends PrometheusMetrics { ...@@ -86,7 +105,7 @@ export default class EEPrometheusMetrics extends PrometheusMetrics {
this.populateCustomMetrics(customMetrics.data.metrics); this.populateCustomMetrics(customMetrics.data.metrics);
} }
}) })
.catch((customMetricError) => { .catch(customMetricError => {
this.showFlashMessage(customMetricError); this.showFlashMessage(customMetricError);
this.showMonitoringCustomMetricsPanelState(PANEL_STATE.EMPTY); this.showMonitoringCustomMetricsPanelState(PANEL_STATE.EMPTY);
}); });
......
...@@ -17,4 +17,3 @@ export default class ProtectedEnvironmentEditList { ...@@ -17,4 +17,3 @@ export default class ProtectedEnvironmentEditList {
}); });
} }
} }
...@@ -43,8 +43,9 @@ export default { ...@@ -43,8 +43,9 @@ export default {
return `Paste issue link${this.allowAutoComplete ? ' or <#issue id>' : ''}`; return `Paste issue link${this.allowAutoComplete ? ' or <#issue id>' : ''}`;
}, },
isSubmitButtonDisabled() { isSubmitButtonDisabled() {
return (this.inputValue.length === 0 && this.pendingReferences.length === 0) return (
|| this.isSubmitting; (this.inputValue.length === 0 && this.pendingReferences.length === 0) || this.isSubmitting
);
}, },
allowAutoComplete() { allowAutoComplete() {
return Object.keys(this.autoCompleteSources).length > 0; return Object.keys(this.autoCompleteSources).length > 0;
......
...@@ -113,10 +113,10 @@ export default { ...@@ -113,10 +113,10 @@ export default {
if (issueToRemove) { if (issueToRemove) {
RelatedIssuesService.remove(issueToRemove.relation_path) RelatedIssuesService.remove(issueToRemove.relation_path)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then(data => {
this.store.setRelatedIssues(data.issues); this.store.setRelatedIssues(data.issues);
}) })
.catch((res) => { .catch(res => {
if (res && res.status !== 404) { if (res && res.status !== 404) {
Flash('An error occurred while removing issues.'); Flash('An error occurred while removing issues.');
} }
...@@ -136,9 +136,10 @@ export default { ...@@ -136,9 +136,10 @@ export default {
if (this.state.pendingReferences.length > 0) { if (this.state.pendingReferences.length > 0) {
this.isSubmitting = true; this.isSubmitting = true;
this.service.addRelatedIssues(this.state.pendingReferences) this.service
.addRelatedIssues(this.state.pendingReferences)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then(data => {
// We could potentially lose some pending issues in the interim here // We could potentially lose some pending issues in the interim here
this.store.setPendingReferences([]); this.store.setPendingReferences([]);
this.store.setRelatedIssues(data.issues); this.store.setRelatedIssues(data.issues);
...@@ -147,9 +148,9 @@ export default { ...@@ -147,9 +148,9 @@ export default {
// Close the form on submission // Close the form on submission
this.isFormVisible = false; this.isFormVisible = false;
}) })
.catch((res) => { .catch(res => {
this.isSubmitting = false; this.isSubmitting = false;
let errorMessage = 'We can\'t find an issue that matches what you are looking for.'; let errorMessage = "We can't find an issue that matches what you are looking for.";
if (res.data && res.data.message) { if (res.data && res.data.message) {
errorMessage = res.data.message; errorMessage = res.data.message;
} }
...@@ -164,9 +165,10 @@ export default { ...@@ -164,9 +165,10 @@ export default {
}, },
fetchRelatedIssues() { fetchRelatedIssues() {
this.isFetching = true; this.isFetching = true;
this.service.fetchRelatedIssues() this.service
.fetchRelatedIssues()
.then(res => res.json()) .then(res => res.json())
.then((issues) => { .then(issues => {
this.store.setRelatedIssues(issues); this.store.setRelatedIssues(issues);
this.isFetching = false; this.isFetching = false;
}) })
...@@ -185,27 +187,26 @@ export default { ...@@ -185,27 +187,26 @@ export default {
move_before_id: beforeId, move_before_id: beforeId,
move_after_id: afterId, move_after_id: afterId,
}) })
.then(res => res.json()) .then(res => res.json())
.then((res) => { .then(res => {
if (!res.message) { if (!res.message) {
this.store.updateIssueOrder(oldIndex, newIndex); this.store.updateIssueOrder(oldIndex, newIndex);
} }
}) })
.catch(() => { .catch(() => {
Flash('An error occurred while reordering issues.'); Flash('An error occurred while reordering issues.');
}); });
} }
}, },
onInput(newValue, caretPos) { onInput(newValue, caretPos) {
const rawReferences = newValue const rawReferences = newValue.split(/\s/);
.split(/\s/);
let touchedReference; let touchedReference;
let iteratingPos = 0; let iteratingPos = 0;
const untouchedRawReferences = rawReferences const untouchedRawReferences = rawReferences
.filter((reference) => { .filter(reference => {
let isTouched = false; let isTouched = false;
if (caretPos >= iteratingPos && caretPos <= (iteratingPos + reference.length)) { if (caretPos >= iteratingPos && caretPos <= iteratingPos + reference.length) {
touchedReference = reference; touchedReference = reference;
isTouched = true; isTouched = true;
} }
...@@ -216,22 +217,16 @@ export default { ...@@ -216,22 +217,16 @@ export default {
}) })
.filter(reference => reference.trim().length > 0); .filter(reference => reference.trim().length > 0);
this.store.setPendingReferences( this.store.setPendingReferences(this.state.pendingReferences.concat(untouchedRawReferences));
this.state.pendingReferences.concat(untouchedRawReferences),
);
this.inputValue = `${touchedReference}`; this.inputValue = `${touchedReference}`;
}, },
onBlur(newValue) { onBlur(newValue) {
this.processAllReferences(newValue); this.processAllReferences(newValue);
}, },
processAllReferences(value = '') { processAllReferences(value = '') {
const rawReferences = value const rawReferences = value.split(/\s+/).filter(reference => reference.trim().length > 0);
.split(/\s+/)
.filter(reference => reference.trim().length > 0);
this.store.setPendingReferences( this.store.setPendingReferences(this.state.pendingReferences.concat(rawReferences));
this.state.pendingReferences.concat(rawReferences),
);
this.inputValue = ''; this.inputValue = '';
}, },
}, },
......
...@@ -11,15 +11,16 @@ export default function initRelatedIssues() { ...@@ -11,15 +11,16 @@ export default function initRelatedIssues() {
components: { components: {
relatedIssuesRoot: RelatedIssuesRoot, relatedIssuesRoot: RelatedIssuesRoot,
}, },
render: createElement => createElement('related-issues-root', { render: createElement =>
props: { createElement('related-issues-root', {
endpoint: relatedIssuesRootElement.dataset.endpoint, props: {
canAdmin: convertPermissionToBoolean( endpoint: relatedIssuesRootElement.dataset.endpoint,
relatedIssuesRootElement.dataset.canAddRelatedIssues, canAdmin: convertPermissionToBoolean(
), relatedIssuesRootElement.dataset.canAddRelatedIssues,
helpPath: relatedIssuesRootElement.dataset.helpPath, ),
}, helpPath: relatedIssuesRootElement.dataset.helpPath,
}), },
}),
}); });
} }
} }
...@@ -13,9 +13,12 @@ class RelatedIssuesService { ...@@ -13,9 +13,12 @@ class RelatedIssuesService {
} }
addRelatedIssues(newIssueReferences) { addRelatedIssues(newIssueReferences) {
return this.relatedIssuesResource.save({}, { return this.relatedIssuesResource.save(
issue_references: newIssueReferences, {},
}); {
issue_references: newIssueReferences,
},
);
} }
static saveOrder({ endpoint, move_before_id, move_after_id }) { static saveOrder({ endpoint, move_before_id, move_after_id }) {
......
...@@ -28,10 +28,10 @@ class RelatedIssuesStore { ...@@ -28,10 +28,10 @@ class RelatedIssuesStore {
} }
removePendingRelatedIssue(indexToRemove) { removePendingRelatedIssue(indexToRemove) {
this.state.pendingReferences = this.state.pendingReferences = this.state.pendingReferences.filter(
this.state.pendingReferences.filter((reference, index) => index !== indexToRemove); (reference, index) => index !== indexToRemove,
);
} }
} }
export default RelatedIssuesStore; export default RelatedIssuesStore;
<script> <script>
import _ from 'underscore'; import _ from 'underscore';
import Flash from '~/flash'; import Flash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import epicsListEmpty from './epics_list_empty.vue'; import epicsListEmpty from './epics_list_empty.vue';
import roadmapShell from './roadmap_shell.vue'; import roadmapShell from './roadmap_shell.vue';
export default { export default {
components: { components: {
epicsListEmpty, epicsListEmpty,
roadmapShell, roadmapShell,
},
props: {
store: {
type: Object,
required: true,
}, },
props: { service: {
store: { type: Object,
type: Object, required: true,
required: true,
},
service: {
type: Object,
required: true,
},
presetType: {
type: String,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
newEpicEndpoint: {
type: String,
required: true,
},
emptyStateIllustrationPath: {
type: String,
required: true,
},
}, },
data() { presetType: {
return { type: String,
isLoading: true, required: true,
isEpicsListEmpty: false,
hasError: false,
handleResizeThrottled: {},
};
}, },
computed: { hasFiltersApplied: {
epics() { type: Boolean,
return this.store.getEpics(); required: true,
},
timeframe() {
return this.store.getTimeframe();
},
timeframeStart() {
return this.timeframe[0];
},
timeframeEnd() {
const last = this.timeframe.length - 1;
return this.timeframe[last];
},
currentGroupId() {
return this.store.getCurrentGroupId();
},
showRoadmap() {
return !this.hasError && !this.isLoading && !this.isEpicsListEmpty;
},
}, },
mounted() { newEpicEndpoint: {
this.fetchEpics(); type: String,
this.handleResizeThrottled = _.throttle(this.handleResize, 600); required: true,
window.addEventListener('resize', this.handleResizeThrottled, false);
}, },
beforeDestroy() { emptyStateIllustrationPath: {
window.removeEventListener('resize', this.handleResizeThrottled, false); type: String,
required: true,
}, },
methods: { },
fetchEpics() { data() {
this.hasError = false; return {
this.service.getEpics() isLoading: true,
.then(res => res.data) isEpicsListEmpty: false,
.then((epics) => { hasError: false,
this.isLoading = false; handleResizeThrottled: {},
if (epics.length) { };
this.store.setEpics(epics); },
} else { computed: {
this.isEpicsListEmpty = true; epics() {
} return this.store.getEpics();
}) },
.catch(() => { timeframe() {
this.isLoading = false; return this.store.getTimeframe();
this.hasError = true; },
Flash(s__('GroupRoadmap|Something went wrong while fetching epics')); timeframeStart() {
}); return this.timeframe[0];
}, },
/** timeframeEnd() {
* Roadmap view works with absolute sizing and positioning const last = this.timeframe.length - 1;
* of following child components of RoadmapShell; return this.timeframe[last];
* },
* - RoadmapTimelineSection currentGroupId() {
* - TimelineTodayIndicator return this.store.getCurrentGroupId();
* - EpicItemTimeline },
* showRoadmap() {
* And hence when window is resized, any size attributes passed return !this.hasError && !this.isLoading && !this.isEpicsListEmpty;
* down to child components are no longer valid, so best approach },
* to refresh entire app is to re-render it on resize, hence },
* we toggle `isLoading` variable which is bound to `RoadmapShell`. mounted() {
*/ this.fetchEpics();
handleResize() { this.handleResizeThrottled = _.throttle(this.handleResize, 600);
this.isLoading = true; window.addEventListener('resize', this.handleResizeThrottled, false);
// We need to debounce the toggle to make sure loading animation },
// shows up while app is being rerendered. beforeDestroy() {
_.debounce(() => { window.removeEventListener('resize', this.handleResizeThrottled, false);
},
methods: {
fetchEpics() {
this.hasError = false;
this.service
.getEpics()
.then(res => res.data)
.then(epics => {
this.isLoading = false; this.isLoading = false;
}, 200)(); if (epics.length) {
}, this.store.setEpics(epics);
} else {
this.isEpicsListEmpty = true;
}
})
.catch(() => {
this.isLoading = false;
this.hasError = true;
Flash(s__('GroupRoadmap|Something went wrong while fetching epics'));
});
},
/**
* Roadmap view works with absolute sizing and positioning
* of following child components of RoadmapShell;
*
* - RoadmapTimelineSection
* - TimelineTodayIndicator
* - EpicItemTimeline
*
* And hence when window is resized, any size attributes passed
* down to child components are no longer valid, so best approach
* to refresh entire app is to re-render it on resize, hence
* we toggle `isLoading` variable which is bound to `RoadmapShell`.
*/
handleResize() {
this.isLoading = true;
// We need to debounce the toggle to make sure loading animation
// shows up while app is being rerendered.
_.debounce(() => {
this.isLoading = false;
}, 200)();
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import epicItemDetails from './epic_item_details.vue'; import epicItemDetails from './epic_item_details.vue';
import epicItemTimeline from './epic_item_timeline.vue'; import epicItemTimeline from './epic_item_timeline.vue';
export default { export default {
components: { components: {
epicItemDetails, epicItemDetails,
epicItemTimeline, epicItemTimeline,
},
props: {
presetType: {
type: String,
required: true,
}, },
props: { epic: {
presetType: { type: Object,
type: String, required: true,
required: true,
},
epic: {
type: Object,
required: true,
},
timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
itemWidth: {
type: Number,
required: true,
},
}, },
}; timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
itemWidth: {
type: Number,
required: true,
},
},
};
</script> </script>
<template> <template>
......
<script> <script>
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility'; import { dateInWords } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
export default { export default {
directives: { directives: {
tooltip, tooltip,
},
props: {
epic: {
type: Object,
required: true,
}, },
props: { currentGroupId: {
epic: { type: Number,
type: Object, required: true,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
}, },
computed: { },
isEpicGroupDifferent() { computed: {
return this.currentGroupId !== this.epic.groupId; isEpicGroupDifferent() {
}, return this.currentGroupId !== this.epic.groupId;
/** },
* In case Epic start date is out of range /**
* we need to use original date instead of proxy date * In case Epic start date is out of range
*/ * we need to use original date instead of proxy date
startDate() { */
if (this.epic.startDateOutOfRange) { startDate() {
return this.epic.originalStartDate; if (this.epic.startDateOutOfRange) {
} return this.epic.originalStartDate;
}
return this.epic.startDate; return this.epic.startDate;
}, },
/** /**
* In case Epic end date is out of range * In case Epic end date is out of range
* we need to use original date instead of proxy date * we need to use original date instead of proxy date
*/ */
endDate() { endDate() {
if (this.epic.endDateOutOfRange) { if (this.epic.endDateOutOfRange) {
return this.epic.originalEndDate; return this.epic.originalEndDate;
} }
return this.epic.endDate; return this.epic.endDate;
}, },
/** /**
* Compose timeframe string to show on UI * Compose timeframe string to show on UI
* based on start and end date availability * based on start and end date availability
*/ */
timeframeString() { timeframeString() {
if (this.epic.startDateUndefined) { if (this.epic.startDateUndefined) {
return sprintf(s__('GroupRoadmap|Until %{dateWord}'), { return sprintf(s__('GroupRoadmap|Until %{dateWord}'), {
dateWord: dateInWords(this.endDate, true), dateWord: dateInWords(this.endDate, true),
}); });
} else if (this.epic.endDateUndefined) { } else if (this.epic.endDateUndefined) {
return sprintf(s__('GroupRoadmap|From %{dateWord}'), { return sprintf(s__('GroupRoadmap|From %{dateWord}'), {
dateWord: dateInWords(this.startDate, true), dateWord: dateInWords(this.startDate, true),
}); });
} }
// In case both start and end date fall in same year // In case both start and end date fall in same year
// We should hide year from start date // We should hide year from start date
const startDateInWords = dateInWords( const startDateInWords = dateInWords(
this.startDate, this.startDate,
true, true,
this.startDate.getFullYear() === this.endDate.getFullYear(), this.startDate.getFullYear() === this.endDate.getFullYear(),
); );
return `${startDateInWords} &ndash; ${dateInWords(this.endDate, true)}`; return `${startDateInWords} &ndash; ${dateInWords(this.endDate, true)}`;
},
}, },
}; },
};
</script> </script>
<template> <template>
......
...@@ -17,11 +17,7 @@ export default { ...@@ -17,11 +17,7 @@ export default {
directives: { directives: {
tooltip, tooltip,
}, },
mixins: [ mixins: [QuartersPresetMixin, MonthsPresetMixin, WeeksPresetMixin],
QuartersPresetMixin,
MonthsPresetMixin,
WeeksPresetMixin,
],
props: { props: {
presetType: { presetType: {
type: String, type: String,
......
<script> <script>
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import SectionMixin from '../mixins/section_mixin'; import SectionMixin from '../mixins/section_mixin';
import epicItem from './epic_item.vue'; import epicItem from './epic_item.vue';
export default { export default {
components: { components: {
epicItem, epicItem,
},
mixins: [SectionMixin],
props: {
presetType: {
type: String,
required: true,
}, },
mixins: [ epics: {
SectionMixin, type: Array,
], required: true,
props: {
presetType: {
type: String,
required: true,
},
epics: {
type: Array,
required: true,
},
timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
listScrollable: {
type: Boolean,
required: true,
},
}, },
data() { timeframe: {
type: Array,
required: true,
},
currentGroupId: {
type: Number,
required: true,
},
shellWidth: {
type: Number,
required: true,
},
listScrollable: {
type: Boolean,
required: true,
},
},
data() {
return {
shellHeight: 0,
emptyRowHeight: 0,
showEmptyRow: false,
offsetLeft: 0,
showBottomShadow: false,
};
},
computed: {
emptyRowContainerStyles() {
return { return {
shellHeight: 0, height: `${this.emptyRowHeight}px`,
emptyRowHeight: 0,
showEmptyRow: false,
offsetLeft: 0,
showBottomShadow: false,
}; };
}, },
computed: { emptyRowCellStyles() {
emptyRowContainerStyles() { return {
return { width: `${this.sectionItemWidth}px`,
height: `${this.emptyRowHeight}px`, };
};
},
emptyRowCellStyles() {
return {
width: `${this.sectionItemWidth}px`,
};
},
shadowCellStyles() {
return {
left: `${this.offsetLeft}px`,
};
},
},
watch: {
shellWidth: function shellWidth() {
// Scroll view to today indicator only when shellWidth is updated.
this.scrollToTodayIndicator();
// Initialize offsetLeft when shellWidth is updated
this.offsetLeft = this.$el.parentElement.offsetLeft;
},
}, },
mounted() { shadowCellStyles() {
eventHub.$on('epicsListScrolled', this.handleEpicsListScroll); return {
this.$nextTick(() => { left: `${this.offsetLeft}px`,
this.initMounted(); };
});
}, },
beforeDestroy() { },
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll); watch: {
shellWidth: function shellWidth() {
// Scroll view to today indicator only when shellWidth is updated.
this.scrollToTodayIndicator();
// Initialize offsetLeft when shellWidth is updated
this.offsetLeft = this.$el.parentElement.offsetLeft;
}, },
methods: { },
initMounted() { mounted() {
// Get available shell height based on viewport height eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
this.shellHeight = window.innerHeight - this.$el.offsetTop; this.$nextTick(() => {
this.initMounted();
});
},
beforeDestroy() {
eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
},
methods: {
initMounted() {
// Get available shell height based on viewport height
this.shellHeight = window.innerHeight - this.$el.offsetTop;
// In case there are epics present, initialize empty row // In case there are epics present, initialize empty row
if (this.epics.length) { if (this.epics.length) {
this.initEmptyRow(); this.initEmptyRow();
} }
eventHub.$emit('epicsListRendered', { eventHub.$emit('epicsListRendered', {
width: this.$el.clientWidth, width: this.$el.clientWidth,
height: this.shellHeight, height: this.shellHeight,
}); });
}, },
/** /**
* In case number of epics in the list are not sufficient * In case number of epics in the list are not sufficient
* to fill in full page height, we need to show an empty row * to fill in full page height, we need to show an empty row
* at the bottom with fixed absolute height such that the * at the bottom with fixed absolute height such that the
* column rulers expand to full page height * column rulers expand to full page height
* *
* This method calculates absolute height for empty column in pixels * This method calculates absolute height for empty column in pixels
* based on height of available list items and sets it to component * based on height of available list items and sets it to component
* props. * props.
*/ */
initEmptyRow() { initEmptyRow() {
const children = this.$children; const children = this.$children;
let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length; let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length;
// Check if approximate height is greater than shell height // Check if approximate height is greater than shell height
if (approxChildrenHeight < this.shellHeight) { if (approxChildrenHeight < this.shellHeight) {
// reset approximate height and recalculate actual height // reset approximate height and recalculate actual height
approxChildrenHeight = 0; approxChildrenHeight = 0;
children.forEach((child) => { children.forEach(child => {
// accumulate children height // accumulate children height
// compensate for bottom border // compensate for bottom border
approxChildrenHeight += child.$el.clientHeight; approxChildrenHeight += child.$el.clientHeight;
}); });
// set height and show empty row reducing horizontal scrollbar size // set height and show empty row reducing horizontal scrollbar size
this.emptyRowHeight = (this.shellHeight - approxChildrenHeight); this.emptyRowHeight = this.shellHeight - approxChildrenHeight;
this.showEmptyRow = true; this.showEmptyRow = true;
} else { } else {
this.showBottomShadow = true; this.showBottomShadow = true;
} }
}, },
/** /**
* `clientWidth` is full width of list section, and we need to * `clientWidth` is full width of list section, and we need to
* scroll up to 60% of the view where today indicator is present. * scroll up to 60% of the view where today indicator is present.
* *
* Reason for 60% is that "today" always falls in the middle of timeframe range. * Reason for 60% is that "today" always falls in the middle of timeframe range.
*/ */
scrollToTodayIndicator() { scrollToTodayIndicator() {
const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100); const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100);
this.$el.scrollTo(uptoTodayIndicator, 0); this.$el.scrollTo(uptoTodayIndicator, 0);
}, },
handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) { handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
this.showBottomShadow = (Math.ceil(scrollTop) + clientHeight) < scrollHeight; this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
},
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import { monthInWords } from '~/lib/utils/datetime_utility'; import { monthInWords } from '~/lib/utils/datetime_utility';
import MonthsHeaderSubItem from './months_header_sub_item.vue'; import MonthsHeaderSubItem from './months_header_sub_item.vue';
export default { export default {
components: { components: {
MonthsHeaderSubItem, MonthsHeaderSubItem,
},
props: {
timeframeIndex: {
type: Number,
required: true,
}, },
props: { timeframeItem: {
timeframeIndex: { type: Date,
type: Number, required: true,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
timeframe: {
type: Array,
required: true,
},
itemWidth: {
type: Number,
required: true,
},
}, },
data() { timeframe: {
const currentDate = new Date(); type: Array,
currentDate.setHours(0, 0, 0, 0); required: true,
},
itemWidth: {
type: Number,
required: true,
},
},
data() {
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
return {
currentDate,
currentYear: currentDate.getFullYear(),
currentMonth: currentDate.getMonth(),
};
},
computed: {
itemStyles() {
return { return {
currentDate, width: `${this.itemWidth}px`,
currentYear: currentDate.getFullYear(),
currentMonth: currentDate.getMonth(),
}; };
}, },
computed: { timelineHeaderLabel() {
itemStyles() { const year = this.timeframeItem.getFullYear();
return { const month = monthInWords(this.timeframeItem, true);
width: `${this.itemWidth}px`,
};
},
timelineHeaderLabel() {
const year = this.timeframeItem.getFullYear();
const month = monthInWords(this.timeframeItem, true);
// Show Year only if current timeframe has months between // Show Year only if current timeframe has months between
// two years and current timeframe item is first month // two years and current timeframe item is first month
// from one of the two years. // from one of the two years.
// //
// End result of doing this is; // End result of doing this is;
// 2017 Nov, Dec, 2018 Jan, Feb, Mar // 2017 Nov, Dec, 2018 Jan, Feb, Mar
if (this.timeframeIndex !== 0 && if (
this.timeframe[this.timeframeIndex - 1].getFullYear() === year) { this.timeframeIndex !== 0 &&
return month; this.timeframe[this.timeframeIndex - 1].getFullYear() === year
} ) {
return month;
}
return `${year} ${month}`; return `${year} ${month}`;
}, },
timelineHeaderClass() { timelineHeaderClass() {
let itemLabelClass = ''; let itemLabelClass = '';
const timeframeYear = this.timeframeItem.getFullYear(); const timeframeYear = this.timeframeItem.getFullYear();
const timeframeMonth = this.timeframeItem.getMonth(); const timeframeMonth = this.timeframeItem.getMonth();
// Show dark color text only if timeframe item year & month // Show dark color text only if timeframe item year & month
// are greater than current year. // are greater than current year.
if (timeframeYear >= this.currentYear && if (timeframeYear >= this.currentYear && timeframeMonth >= this.currentMonth) {
timeframeMonth >= this.currentMonth) { itemLabelClass += 'label-dark';
itemLabelClass += 'label-dark'; }
}
// Show bold text only if timeframe item year & month // Show bold text only if timeframe item year & month
// is current year & month // is current year & month
if (timeframeYear === this.currentYear && if (timeframeYear === this.currentYear && timeframeMonth === this.currentMonth) {
timeframeMonth === this.currentMonth) { itemLabelClass += ' label-bold';
itemLabelClass += ' label-bold'; }
}
return itemLabelClass; return itemLabelClass;
},
}, },
}; },
};
</script> </script>
<template> <template>
......
<script> <script>
import { getSundays } from '~/lib/utils/datetime_utility'; import { getSundays } from '~/lib/utils/datetime_utility';
import { PRESET_TYPES } from '../../constants'; import { PRESET_TYPES } from '../../constants';
import timelineTodayIndicator from '../timeline_today_indicator.vue'; import timelineTodayIndicator from '../timeline_today_indicator.vue';
export default { export default {
presetType: PRESET_TYPES.MONTHS, presetType: PRESET_TYPES.MONTHS,
components: { components: {
timelineTodayIndicator, timelineTodayIndicator,
},
props: {
currentDate: {
type: Date,
required: true,
}, },
props: { timeframeItem: {
currentDate: { type: Date,
type: Date, required: true,
required: true,
},
timeframeItem: {
type: Date,
required: true,
},
}, },
computed: { },
headerSubItems() { computed: {
return getSundays(this.timeframeItem); headerSubItems() {
}, return getSundays(this.timeframeItem);
headerSubItemClass() { },
const currentYear = this.currentDate.getFullYear(); headerSubItemClass() {
const currentMonth = this.currentDate.getMonth(); const currentYear = this.currentDate.getFullYear();
const timeframeYear = this.timeframeItem.getFullYear(); const currentMonth = this.currentDate.getMonth();
const timeframeMonth = this.timeframeItem.getMonth(); const timeframeYear = this.timeframeItem.getFullYear();
const timeframeMonth = this.timeframeItem.getMonth();
// Show dark color text only for dates from current month and future months. // Show dark color text only for dates from current month and future months.
return timeframeYear >= currentYear && timeframeMonth >= currentMonth ? 'label-dark' : ''; return timeframeYear >= currentYear && timeframeMonth >= currentMonth ? 'label-dark' : '';
}, },
hasToday() { hasToday() {
const timeframeYear = this.timeframeItem.getFullYear(); const timeframeYear = this.timeframeItem.getFullYear();
const timeframeMonth = this.timeframeItem.getMonth(); const timeframeMonth = this.timeframeItem.getMonth();
return this.currentDate.getMonth() === timeframeMonth && return (
this.currentDate.getFullYear() === timeframeYear; this.currentDate.getMonth() === timeframeMonth &&
}, this.currentDate.getFullYear() === timeframeYear
);
}, },
methods: { },
getSubItemValueClass(subItem) { methods: {
const daysToClosestWeek = this.currentDate.getDate() - subItem.getDate(); getSubItemValueClass(subItem) {
// Show dark color text only for upcoming dates const daysToClosestWeek = this.currentDate.getDate() - subItem.getDate();
// and current week date // Show dark color text only for upcoming dates
if (daysToClosestWeek <= 6 && // and current week date
this.currentDate.getDate() >= subItem.getDate() && if (
this.currentDate.getFullYear() === subItem.getFullYear() && daysToClosestWeek <= 6 &&
this.currentDate.getMonth() === subItem.getMonth()) { this.currentDate.getDate() >= subItem.getDate() &&
return 'label-dark label-bold'; this.currentDate.getFullYear() === subItem.getFullYear() &&
} else if (subItem >= this.currentDate) { this.currentDate.getMonth() === subItem.getMonth()
return 'label-dark'; ) {
} return 'label-dark label-bold';
return ''; } else if (subItem >= this.currentDate) {
}, return 'label-dark';
}
return '';
}, },
}; },
};
</script> </script>
<template> <template>
......
...@@ -73,7 +73,7 @@ export default { ...@@ -73,7 +73,7 @@ export default {
} }
// Calculate proportional offset based on startDate and total days in // Calculate proportional offset based on startDate and total days in
// current month. // current month.
return `left: ${startDate / daysInMonth * 100}%;`; return `left: ${(startDate / daysInMonth) * 100}%;`;
}, },
/** /**
* This method is externally only called when current timeframe cell has timeline * This method is externally only called when current timeframe cell has timeline
......
...@@ -65,7 +65,7 @@ export default { ...@@ -65,7 +65,7 @@ export default {
return `right: ${TIMELINE_END_OFFSET_HALF}px;`; return `right: ${TIMELINE_END_OFFSET_HALF}px;`;
} }
return `left: ${startDay / daysInQuarter * 100}%;`; return `left: ${(startDay / daysInQuarter) * 100}%;`;
}, },
/** /**
* This method is externally only called when current timeframe cell has timeline * This method is externally only called when current timeframe cell has timeline
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment