diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2019-10-28 18:06:15 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2019-10-28 18:06:15 +0000 |
commit | 7515ec41c527c62bfd56f46e388cf6d9fe06479f (patch) | |
tree | 614b555ec428b7eac4b836473d43516c41f9da46 /app | |
parent | a77db6bc47d8cdd9edae2ec22f640821d0794404 (diff) | |
download | gitlab-ce-7515ec41c527c62bfd56f46e388cf6d9fe06479f.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
29 files changed, 464 insertions, 878 deletions
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue new file mode 100644 index 00000000000..7dd6b051cb4 --- /dev/null +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -0,0 +1,227 @@ +<script> +import { __ } from '~/locale'; +import _ from 'underscore'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { getDatesInRange } from '~/lib/utils/datetime_utility'; +import { xAxisLabelFormatter, dateFormatter } from '../utils'; + +export default { + components: { + GlAreaChart, + GlLoadingIcon, + }, + props: { + endpoint: { + type: String, + required: true, + }, + branch: { + type: String, + required: true, + }, + }, + data() { + return { + masterChart: null, + individualCharts: [], + svgs: {}, + masterChartHeight: 264, + individualChartHeight: 216, + }; + }, + computed: { + ...mapState(['chartData', 'loading']), + ...mapGetters(['showChart', 'parsedData']), + masterChartData() { + const data = {}; + this.xAxisRange.forEach(date => { + data[date] = this.parsedData.total[date] || 0; + }); + return [ + { + name: __('Commits'), + data: Object.entries(data), + }, + ]; + }, + masterChartOptions() { + return { + ...this.getCommonChartOptions(true), + yAxis: { + name: __('Number of commits'), + }, + grid: { + bottom: 64, + left: 64, + right: 20, + top: 20, + }, + }; + }, + individualChartsData() { + const maxNumberOfIndividualContributorsCharts = 100; + + return Object.keys(this.parsedData.byAuthor) + .map(name => { + const author = this.parsedData.byAuthor[name]; + return { + name, + email: author.email, + commits: author.commits, + dates: [ + { + name: __('Commits'), + data: this.xAxisRange.map(date => [date, author.dates[date] || 0]), + }, + ], + }; + }) + .sort((a, b) => b.commits - a.commits) + .slice(0, maxNumberOfIndividualContributorsCharts); + }, + individualChartOptions() { + return { + ...this.getCommonChartOptions(false), + yAxis: { + name: __('Commits'), + max: this.individualChartYAxisMax, + }, + grid: { + bottom: 27, + left: 64, + right: 20, + top: 8, + }, + }; + }, + individualChartYAxisMax() { + return this.individualChartsData.reduce((acc, item) => { + const values = item.dates[0].data.map(value => value[1]); + return Math.max(acc, ...values); + }, 0); + }, + xAxisRange() { + const dates = Object.keys(this.parsedData.total).sort((a, b) => new Date(a) - new Date(b)); + + const firstContributionDate = new Date(dates[0]); + const lastContributionDate = new Date(dates[dates.length - 1]); + + return getDatesInRange(firstContributionDate, lastContributionDate, dateFormatter); + }, + firstContributionDate() { + return this.xAxisRange[0]; + }, + lastContributionDate() { + return this.xAxisRange[this.xAxisRange.length - 1]; + }, + charts() { + return _.uniq(this.individualCharts); + }, + }, + mounted() { + this.fetchChartData(this.endpoint); + }, + methods: { + ...mapActions(['fetchChartData']), + getCommonChartOptions(isMasterChart) { + return { + xAxis: { + type: 'time', + name: '', + data: this.xAxisRange, + axisLabel: { + formatter: xAxisLabelFormatter, + showMaxLabel: false, + showMinLabel: false, + }, + boundaryGap: false, + splitNumber: isMasterChart ? 24 : 18, + // 28 days + minInterval: 28 * 86400 * 1000, + min: this.firstContributionDate, + max: this.lastContributionDate, + }, + }; + }, + setSvg(name) { + return getSvgIconPathContent(name) + .then(path => { + if (path) { + this.$set(this.svgs, name, `path://${path}`); + } + }) + .catch(() => {}); + }, + onMasterChartCreated(chart) { + this.masterChart = chart; + this.setSvg('scroll-handle') + .then(() => { + this.masterChart.setOption({ + dataZoom: [ + { + type: 'slider', + handleIcon: this.svgs['scroll-handle'], + }, + ], + }); + }) + .catch(() => {}); + this.masterChart.on('datazoom', _.debounce(this.setIndividualChartsZoom, 200)); + }, + onIndividualChartCreated(chart) { + this.individualCharts.push(chart); + }, + setIndividualChartsZoom(options) { + this.charts.forEach(chart => + chart.setOption( + { + dataZoom: { + start: options.start, + end: options.end, + show: false, + }, + }, + { lazyUpdate: true }, + ), + ); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="loading" class="contributors-loader text-center"> + <gl-loading-icon :inline="true" :size="4" /> + </div> + + <div v-else-if="showChart" class="contributors-charts"> + <h4>{{ __('Commits to') }} {{ branch }}</h4> + <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> + <div> + <gl-area-chart + :data="masterChartData" + :option="masterChartOptions" + :height="masterChartHeight" + @created="onMasterChartCreated" + /> + </div> + + <div class="row"> + <div v-for="contributor in individualChartsData" :key="contributor.name" class="col-6"> + <h4>{{ contributor.name }}</h4> + <p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p> + <gl-area-chart + :data="contributor.dates" + :option="individualChartOptions" + :height="individualChartHeight" + @created="onIndividualChartCreated" + /> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js new file mode 100644 index 00000000000..b6063589734 --- /dev/null +++ b/app/assets/javascripts/contributors/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import ContributorsGraphs from './components/contributors.vue'; +import store from './stores'; + +export default () => { + const el = document.querySelector('.js-contributors-graph'); + + if (!el) return null; + + return new Vue({ + el, + store, + + render(createElement) { + return createElement(ContributorsGraphs, { + props: { + endpoint: el.dataset.projectGraphPath, + branch: el.dataset.projectBranch, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/contributors/services/contributors_service.js b/app/assets/javascripts/contributors/services/contributors_service.js new file mode 100644 index 00000000000..5a8bbb66511 --- /dev/null +++ b/app/assets/javascripts/contributors/services/contributors_service.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + fetchChartData(endpoint) { + return axios.get(endpoint); + }, +}; diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js new file mode 100644 index 00000000000..4138ff24f1d --- /dev/null +++ b/app/assets/javascripts/contributors/stores/actions.js @@ -0,0 +1,20 @@ +import flash from '~/flash'; +import { __ } from '~/locale'; +import service from '../services/contributors_service'; +import * as types from './mutation_types'; + +export const fetchChartData = ({ commit }, endpoint) => { + commit(types.SET_LOADING_STATE, true); + + return service + .fetchChartData(endpoint) + .then(res => res.data) + .then(data => { + commit(types.SET_CHART_DATA, data); + commit(types.SET_LOADING_STATE, false); + }) + .catch(() => flash(__('An error occurred while loading chart data'))); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js new file mode 100644 index 00000000000..9e02e3ed9e7 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/getters.js @@ -0,0 +1,33 @@ +export const showChart = state => Boolean(!state.loading && state.chartData); + +export const parsedData = state => { + const byAuthor = {}; + const total = {}; + + state.chartData.forEach(({ date, author_name, author_email }) => { + total[date] = total[date] ? total[date] + 1 : 1; + + const authorData = byAuthor[author_name]; + + if (!authorData) { + byAuthor[author_name] = { + email: author_email.toLowerCase(), + commits: 1, + dates: { + [date]: 1, + }, + }; + } else { + authorData.commits += 1; + authorData.dates[date] = authorData.dates[date] ? authorData.dates[date] + 1 : 1; + } + }); + + return { + total, + byAuthor, + }; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js new file mode 100644 index 00000000000..bc739851aa7 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import mutations from './mutations'; +import * as getters from './getters'; +import * as actions from './actions'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + mutations, + getters, + state: state(), + }); + +export default createStore(); diff --git a/app/assets/javascripts/contributors/stores/mutation_types.js b/app/assets/javascripts/contributors/stores/mutation_types.js new file mode 100644 index 00000000000..62e0a51d5f8 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/mutation_types.js @@ -0,0 +1,3 @@ +export const SET_CHART_DATA = 'SET_CHART_DATA'; +export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_ACTIVE_BRANCH = 'SET_ACTIVE_BRANCH'; diff --git a/app/assets/javascripts/contributors/stores/mutations.js b/app/assets/javascripts/contributors/stores/mutations.js new file mode 100644 index 00000000000..f1f460d072d --- /dev/null +++ b/app/assets/javascripts/contributors/stores/mutations.js @@ -0,0 +1,17 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_LOADING_STATE](state, value) { + state.loading = value; + }, + [types.SET_CHART_DATA](state, chartData) { + Object.assign(state, { + chartData, + }); + }, + [types.SET_ACTIVE_BRANCH](state, branch) { + Object.assign(state, { + branch, + }); + }, +}; diff --git a/app/assets/javascripts/contributors/stores/state.js b/app/assets/javascripts/contributors/stores/state.js new file mode 100644 index 00000000000..1dc1a3c7b75 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/state.js @@ -0,0 +1,5 @@ +export default () => ({ + loading: false, + chartData: null, + branch: 'master', +}); diff --git a/app/assets/javascripts/contributors/utils.js b/app/assets/javascripts/contributors/utils.js new file mode 100644 index 00000000000..7d8932ce495 --- /dev/null +++ b/app/assets/javascripts/contributors/utils.js @@ -0,0 +1,30 @@ +import { getMonthNames } from '~/lib/utils/datetime_utility'; + +/** + * Converts provided string to date and returns formatted value as a year for date in January and month name for the rest + * @param {String} + * @returns {String} - formatted value + * + * xAxisLabelFormatter('01-12-2019') will return '2019' + * xAxisLabelFormatter('02-12-2019') will return 'Feb' + * xAxisLabelFormatter('07-12-2019') will return 'Jul' + */ +export const xAxisLabelFormatter = val => { + const date = new Date(val); + const month = date.getUTCMonth(); + const year = date.getUTCFullYear(); + return month === 0 ? `${year}` : getMonthNames(true)[month]; +}; + +/** + * Formats provided date to YYYY-MM-DD format + * @param {Date} + * @returns {String} - formatted value + */ +export const dateFormatter = date => { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + + return `${year}-${`0${month + 1}`.slice(-2)}-${`0${day}`.slice(-2)}`; +}; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 37b0215f6f9..e2d188103bc 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -564,3 +564,26 @@ export const getDateInPast = (date, daysInPast) => { export const beginOfDayTime = 'T00:00:00Z'; export const endOfDayTime = 'T23:59:59Z'; + +/** + * @param {Date} d1 + * @param {Date} d2 + * @param {Function} formatter + * @return {Any[]} an array of formatted dates between 2 given dates (including start&end date) + */ +export const getDatesInRange = (d1, d2, formatter = x => x) => { + if (!(d1 instanceof Date) || !(d2 instanceof Date)) { + return []; + } + let startDate = d1.getTime(); + const endDate = d2.getTime(); + const oneDay = 24 * 3600 * 1000; + const range = [d1]; + + while (startDate < endDate) { + startDate += oneDay; + range.push(new Date(startDate)); + } + + return range.map(formatter); +}; diff --git a/app/assets/javascripts/pages/projects/graphs/show/index.js b/app/assets/javascripts/pages/projects/graphs/show/index.js index f79c386b59e..09d9c78c446 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/index.js +++ b/app/assets/javascripts/pages/projects/graphs/show/index.js @@ -1,25 +1,3 @@ -import $ from 'jquery'; -import flash from '~/flash'; -import { __ } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; -import ContributorsStatGraph from './stat_graph_contributors'; +import initContributorsGraphs from '~/contributors'; -document.addEventListener('DOMContentLoaded', () => { - const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath; - - axios - .get(url) - .then(({ data }) => { - const graph = new ContributorsStatGraph(); - graph.init(data); - - $('#brush_change').change(() => { - graph.change_date_header(); - graph.redraw_authors(); - }); - - $('.stat-graph').fadeIn(); - $('.loading-graph').hide(); - }) - .catch(() => flash(__('Error fetching contributors data.'))); -}); +document.addEventListener('DOMContentLoaded', initContributorsGraphs); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js deleted file mode 100644 index 5b873e6b909..00000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign */ - -import $ from 'jquery'; -import _ from 'underscore'; -import { n__, s__, createDateTimeFormat, sprintf } from '~/locale'; -import { - ContributorsGraph, - ContributorsAuthorGraph, - ContributorsMasterGraph, -} from './stat_graph_contributors_graph'; -import ContributorsStatGraphUtil from './stat_graph_contributors_util'; - -export default (function() { - function ContributorsStatGraph() { - this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' }); - } - - ContributorsStatGraph.prototype.init = function(log) { - var author_commits, total_commits; - this.parsed_log = ContributorsStatGraphUtil.parse_log(log); - this.set_current_field('commits'); - total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); - author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field); - this.add_master_graph(total_commits); - this.add_authors_graph(author_commits); - return this.change_date_header(); - }; - - ContributorsStatGraph.prototype.add_master_graph = function(total_data) { - this.master_graph = new ContributorsMasterGraph(total_data); - return this.master_graph.draw(); - }; - - ContributorsStatGraph.prototype.add_authors_graph = function(author_data) { - var limited_author_data; - this.authors = []; - limited_author_data = author_data.slice(0, 100); - return _.each( - limited_author_data, - (function(_this) { - return function(d) { - var author_graph, author_header; - author_header = _this.create_author_header(d); - $('.contributors-list').append(author_header); - - author_graph = new ContributorsAuthorGraph(d.dates); - _this.authors[d.author_name] = author_graph; - return author_graph.draw(); - }; - })(this), - ); - }; - - ContributorsStatGraph.prototype.format_author_commit_info = function(author) { - var commits; - commits = $('<span/>', { - class: 'graph-author-commits-count', - }); - commits.text(n__('%d commit', '%d commits', author.commits)); - return $('<span/>').append(commits); - }; - - ContributorsStatGraph.prototype.create_author_header = function(author) { - var author_commit_info, author_commit_info_span, author_email, author_name, list_item; - list_item = $('<li/>', { - class: 'person', - style: 'display: block;', - }); - author_name = $(`<h4>${author.author_name}</h4>`); - author_email = $(`<p class="graph-author-email">${author.author_email}</p>`); - author_commit_info_span = $('<span/>', { - class: 'commits', - }); - author_commit_info = this.format_author_commit_info(author); - author_commit_info_span.html(author_commit_info); - list_item.append(author_name); - list_item.append(author_email); - list_item.append(author_commit_info_span); - return list_item; - }; - - ContributorsStatGraph.prototype.redraw_master = function() { - var total_data; - total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); - this.master_graph.set_data(total_data); - return this.master_graph.redraw(); - }; - - ContributorsStatGraph.prototype.redraw_authors = function() { - $('ol').html(''); - - const { x_domain } = ContributorsGraph.prototype; - const author_commits = ContributorsStatGraphUtil.get_author_data( - this.parsed_log, - this.field, - x_domain, - ); - - return _.each( - author_commits, - (function(_this) { - return function(d) { - _this.redraw_author_commit_info(d); - if (_this.authors[d.author_name] != null) { - $(_this.authors[d.author_name].list_item).appendTo('ol'); - _this.authors[d.author_name].set_data(d.dates); - return _this.authors[d.author_name].redraw(); - } - return ''; - }; - })(this), - ); - }; - - ContributorsStatGraph.prototype.set_current_field = function(field) { - return (this.field = field); - }; - - ContributorsStatGraph.prototype.change_date_header = function() { - const { x_domain } = ContributorsGraph.prototype; - const formattedDateRange = sprintf(s__('ContributorsPage|%{startDate} – %{endDate}'), { - startDate: this.dateFormat.format(new Date(x_domain[0])), - endDate: this.dateFormat.format(new Date(x_domain[1])), - }); - return $('#date_header').text(formattedDateRange); - }; - - ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) { - var author_commit_info, author_list_item, $author; - $author = this.authors[author.author_name]; - if ($author != null) { - author_list_item = $(this.authors[author.author_name].list_item); - author_commit_info = this.format_author_commit_info(author); - return author_list_item.find('span').html(author_commit_info); - } - return ''; - }; - - return ContributorsStatGraph; -})(); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js deleted file mode 100644 index 86794800f87..00000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js +++ /dev/null @@ -1,379 +0,0 @@ -/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, no-else-return, no-shadow */ - -import $ from 'jquery'; -import _ from 'underscore'; -import { extent, max } from 'd3-array'; -import { select, event as d3Event } from 'd3-selection'; -import { scaleTime, scaleLinear } from 'd3-scale'; -import { axisLeft, axisBottom } from 'd3-axis'; -import { area } from 'd3-shape'; -import { brushX } from 'd3-brush'; -import { timeParse } from 'd3-time-format'; -import { dateTickFormat } from '~/lib/utils/tick_formats'; - -const d3 = { - extent, - max, - select, - scaleTime, - scaleLinear, - axisLeft, - axisBottom, - area, - brushX, - timeParse, -}; - -const hasProp = {}.hasOwnProperty; -const extend = function(child, parent) { - for (const key in parent) { - if (hasProp.call(parent, key)) child[key] = parent[key]; - } - function ctor() { - this.constructor = child; - } - ctor.prototype = parent.prototype; - child.prototype = new ctor(); - child.__super__ = parent.prototype; - return child; -}; - -export const ContributorsGraph = (function() { - function ContributorsGraph() {} - - ContributorsGraph.prototype.MARGIN = { - top: 20, - right: 10, - bottom: 30, - left: 40, - }; - - ContributorsGraph.prototype.x_domain = null; - - ContributorsGraph.prototype.y_domain = null; - - ContributorsGraph.prototype.dates = []; - - ContributorsGraph.prototype.determine_width = function(baseWidth, $parentElement) { - const parentPaddingWidth = - parseFloat($parentElement.css('padding-left')) + - parseFloat($parentElement.css('padding-right')); - const marginWidth = this.MARGIN.left + this.MARGIN.right; - return baseWidth - parentPaddingWidth - marginWidth; - }; - - ContributorsGraph.set_x_domain = function(data) { - return (ContributorsGraph.prototype.x_domain = data); - }; - - ContributorsGraph.set_y_domain = function(data) { - return (ContributorsGraph.prototype.y_domain = [ - 0, - d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), - ]); - }; - - ContributorsGraph.init_x_domain = function(data) { - return (ContributorsGraph.prototype.x_domain = d3.extent(data, d => d.date)); - }; - - ContributorsGraph.init_y_domain = function(data) { - return (ContributorsGraph.prototype.y_domain = [ - 0, - d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), - ]); - }; - - ContributorsGraph.init_domain = function(data) { - ContributorsGraph.init_x_domain(data); - return ContributorsGraph.init_y_domain(data); - }; - - ContributorsGraph.set_dates = function(data) { - return (ContributorsGraph.prototype.dates = data); - }; - - ContributorsGraph.prototype.set_x_domain = function() { - return this.x.domain(this.x_domain); - }; - - ContributorsGraph.prototype.set_y_domain = function() { - return this.y.domain(this.y_domain); - }; - - ContributorsGraph.prototype.set_domain = function() { - this.set_x_domain(); - return this.set_y_domain(); - }; - - ContributorsGraph.prototype.create_scale = function(width, height) { - this.x = d3 - .scaleTime() - .range([0, width]) - .clamp(true); - return (this.y = d3 - .scaleLinear() - .range([height, 0]) - .nice()); - }; - - ContributorsGraph.prototype.draw_x_axis = function() { - return this.svg - .append('g') - .attr('class', 'x axis') - .attr('transform', `translate(0, ${this.height})`) - .call(this.x_axis); - }; - - ContributorsGraph.prototype.draw_y_axis = function() { - return this.svg - .append('g') - .attr('class', 'y axis') - .call(this.y_axis); - }; - - ContributorsGraph.prototype.set_data = function(data) { - return (this.data = data); - }; - - return ContributorsGraph; -})(); - -export const ContributorsMasterGraph = (function(superClass) { - extend(ContributorsMasterGraph, superClass); - - function ContributorsMasterGraph(data1) { - const $parentElement = $('#contributors-master'); - - this.data = data1; - this.update_content = this.update_content.bind(this); - this.width = this.determine_width($('.js-graphs-show').width(), $parentElement); - this.height = 200; - this.x = null; - this.y = null; - this.x_axis = null; - this.y_axis = null; - this.area = null; - this.svg = null; - this.brush = null; - this.x_max_domain = null; - } - - ContributorsMasterGraph.prototype.process_dates = function(data) { - const dates = this.get_dates(data); - this.parse_dates(data); - return ContributorsGraph.set_dates(dates); - }; - - ContributorsMasterGraph.prototype.get_dates = function(data) { - return _.pluck(data, 'date'); - }; - - ContributorsMasterGraph.prototype.parse_dates = function(data) { - const parseDate = d3.timeParse('%Y-%m-%d'); - return data.forEach(d => (d.date = parseDate(d.date))); - }; - - ContributorsMasterGraph.prototype.create_scale = function() { - return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height); - }; - - ContributorsMasterGraph.prototype.create_axes = function() { - this.x_axis = d3 - .axisBottom() - .scale(this.x) - .tickFormat(dateTickFormat); - return (this.y_axis = d3 - .axisLeft() - .scale(this.y) - .ticks(5)); - }; - - ContributorsMasterGraph.prototype.create_svg = function() { - this.svg = d3 - .select('#contributors-master') - .append('svg') - .attr('width', this.width + this.MARGIN.left + this.MARGIN.right) - .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) - .attr('class', 'tint-box') - .append('g') - .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); - return this.svg; - }; - - ContributorsMasterGraph.prototype.create_area = function(x, y) { - return (this.area = d3 - .area() - .x(d => x(d.date)) - .y0(this.height) - .y1(d => { - d.commits = d.commits || d.additions || d.deletions; - return y(d.commits); - })); - }; - - ContributorsMasterGraph.prototype.create_brush = function() { - return (this.brush = d3 - .brushX(this.x) - .extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]) - .on('end', this.update_content)); - }; - - ContributorsMasterGraph.prototype.draw_path = function(data) { - return this.svg - .append('path') - .datum(data) - .attr('class', 'area') - .attr('d', this.area); - }; - - ContributorsMasterGraph.prototype.add_brush = function() { - return this.svg - .append('g') - .attr('class', 'selection') - .call(this.brush) - .selectAll('rect') - .attr('height', this.height); - }; - - ContributorsMasterGraph.prototype.update_content = function() { - // d3Event.selection replaces the function brush.empty() calls - if (d3Event.selection != null) { - ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert)); - } else { - ContributorsGraph.set_x_domain(this.x_max_domain); - } - return $('#brush_change').trigger('change'); - }; - - ContributorsMasterGraph.prototype.draw = function() { - this.process_dates(this.data); - this.create_scale(); - this.create_axes(); - ContributorsGraph.init_domain(this.data); - this.x_max_domain = this.x_domain; - this.set_domain(); - this.create_area(this.x, this.y); - this.create_svg(); - this.create_brush(); - this.draw_path(this.data); - this.draw_x_axis(); - this.draw_y_axis(); - return this.add_brush(); - }; - - ContributorsMasterGraph.prototype.redraw = function() { - this.process_dates(this.data); - ContributorsGraph.set_y_domain(this.data); - this.set_y_domain(); - this.svg.select('path').datum(this.data); - this.svg.select('path').attr('d', this.area); - return this.svg.select('.y.axis').call(this.y_axis); - }; - - return ContributorsMasterGraph; -})(ContributorsGraph); - -export const ContributorsAuthorGraph = (function(superClass) { - extend(ContributorsAuthorGraph, superClass); - - function ContributorsAuthorGraph(data1) { - const $parentElements = $('.person'); - - this.data = data1; - // Don't split graph size in half for mobile devices. - if ($(window).width() < 790) { - this.width = this.determine_width($('.js-graphs-show').width(), $parentElements); - } else { - this.width = this.determine_width($('.js-graphs-show').width() / 2, $parentElements); - } - this.height = 200; - this.x = null; - this.y = null; - this.x_axis = null; - this.y_axis = null; - this.area = null; - this.svg = null; - this.list_item = null; - } - - ContributorsAuthorGraph.prototype.create_scale = function() { - return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height); - }; - - ContributorsAuthorGraph.prototype.create_axes = function() { - this.x_axis = d3 - .axisBottom() - .scale(this.x) - .ticks(8) - .tickFormat(dateTickFormat); - return (this.y_axis = d3 - .axisLeft() - .scale(this.y) - .ticks(5)); - }; - - ContributorsAuthorGraph.prototype.create_area = function(x, y) { - return (this.area = d3 - .area() - .x(d => { - const parseDate = d3.timeParse('%Y-%m-%d'); - return x(parseDate(d)); - }) - .y0(this.height) - .y1( - (function(_this) { - return function(d) { - if (_this.data[d] != null) { - return y(_this.data[d]); - } else { - return y(0); - } - }; - })(this), - )); - }; - - ContributorsAuthorGraph.prototype.create_svg = function() { - const persons = document.querySelectorAll('.person'); - this.list_item = persons[persons.length - 1]; - this.svg = d3 - .select(this.list_item) - .append('svg') - .attr('width', this.width + this.MARGIN.left + this.MARGIN.right) - .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) - .attr('class', 'spark') - .append('g') - .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); - return this.svg; - }; - - ContributorsAuthorGraph.prototype.draw_path = function(data) { - return this.svg - .append('path') - .datum(data) - .attr('class', 'area-contributor') - .attr('d', this.area); - }; - - ContributorsAuthorGraph.prototype.draw = function() { - this.create_scale(); - this.create_axes(); - this.set_domain(); - this.create_area(this.x, this.y); - this.create_svg(); - this.draw_path(this.dates); - this.draw_x_axis(); - return this.draw_y_axis(); - }; - - ContributorsAuthorGraph.prototype.redraw = function() { - this.set_domain(); - this.svg.select('path').datum(this.dates); - this.svg.select('path').attr('d', this.area); - this.svg.select('.x.axis').call(this.x_axis); - return this.svg.select('.y.axis').call(this.y_axis); - }; - - return ContributorsAuthorGraph; -})(ContributorsGraph); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js deleted file mode 100644 index a89a13fe37a..00000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, consistent-return, no-cond-assign, no-else-return */ -import _ from 'underscore'; - -export default { - parse_log(log) { - var by_author, by_email, data, entry, i, len, total, normalized_email; - total = {}; - by_author = {}; - by_email = {}; - for (i = 0, len = log.length; i < len; i += 1) { - entry = log[i]; - if (total[entry.date] == null) { - this.add_date(entry.date, total); - } - normalized_email = entry.author_email.toLowerCase(); - data = by_author[entry.author_name] || by_email[normalized_email]; - if (data == null) { - data = this.add_author(entry, by_author, by_email); - } - if (!data[entry.date]) { - this.add_date(entry.date, data); - } - this.store_data(entry, total[entry.date], data[entry.date]); - } - total = _.toArray(total); - by_author = _.toArray(by_author); - return { - total, - by_author, - }; - }, - add_date(date, collection) { - collection[date] = {}; - return (collection[date].date = date); - }, - add_author(author, by_author, by_email) { - var data, normalized_email; - data = {}; - data.author_name = author.author_name; - data.author_email = author.author_email; - normalized_email = author.author_email.toLowerCase(); - by_author[author.author_name] = data; - by_email[normalized_email] = data; - return data; - }, - store_data(entry, total, by_author) { - this.store_commits(total, by_author); - this.store_additions(entry, total, by_author); - return this.store_deletions(entry, total, by_author); - }, - store_commits(total, by_author) { - this.add(total, 'commits', 1); - return this.add(by_author, 'commits', 1); - }, - add(collection, field, value) { - if (collection[field] == null) { - collection[field] = 0; - } - return (collection[field] += value); - }, - store_additions(entry, total, by_author) { - if (entry.additions == null) { - entry.additions = 0; - } - this.add(total, 'additions', entry.additions); - return this.add(by_author, 'additions', entry.additions); - }, - store_deletions(entry, total, by_author) { - if (entry.deletions == null) { - entry.deletions = 0; - } - this.add(total, 'deletions', entry.deletions); - return this.add(by_author, 'deletions', entry.deletions); - }, - get_total_data(parsed_log, field) { - var log, total_data; - log = parsed_log.total; - total_data = this.pick_field(log, field); - return _.sortBy(total_data, d => d.date); - }, - pick_field(log, field) { - var total_data; - total_data = []; - _.each(log, d => total_data.push(_.pick(d, [field, 'date']))); - return total_data; - }, - get_author_data(parsed_log, field, date_range) { - var author_data, log; - if (date_range == null) { - date_range = null; - } - log = parsed_log.by_author; - author_data = []; - _.each( - log, - (function(_this) { - return function(log_entry) { - var parsed_log_entry; - parsed_log_entry = _this.parse_log_entry(log_entry, field, date_range); - if (!_.isEmpty(parsed_log_entry.dates)) { - return author_data.push(parsed_log_entry); - } - }; - })(this), - ); - return _.sortBy(author_data, d => d[field]).reverse(); - }, - parse_log_entry(log_entry, field, date_range) { - var parsed_entry; - parsed_entry = {}; - - parsed_entry.author_name = log_entry.author_name; - parsed_entry.author_email = log_entry.author_email; - parsed_entry.dates = {}; - - parsed_entry.commits = 0; - parsed_entry.additions = 0; - parsed_entry.deletions = 0; - - _.each( - _.omit(log_entry, 'author_name', 'author_email'), - (function(_this) { - return function(value) { - if (_this.in_range(value.date, date_range)) { - parsed_entry.dates[value.date] = value[field]; - parsed_entry.commits += value.commits; - parsed_entry.additions += value.additions; - return (parsed_entry.deletions += value.deletions); - } - }; - })(this), - ); - return parsed_entry; - }, - in_range(date, date_range) { - var ref; - if (date_range === null || (date_range[0] <= (ref = new Date(date)) && ref <= date_range[1])) { - return true; - } else { - return false; - } - }, -}; diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 89cac42abae..fcc57da0649 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -345,8 +345,8 @@ export default { <project-setting-row v-if="pagesAvailable && pagesAccessControlEnabled" :help-path="pagesHelpPath" - label="Pages access control" - help-text="Access control for the project's static website" + :label="s__('ProjectSettings|Pages')" + :help-text="__('With GitLab Pages you can host your static websites on GitLab')" > <project-feature-setting v-model="pagesAccessLevel" diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss index 3febf4cf826..a8de8303a19 100644 --- a/app/assets/stylesheets/pages/graph.scss +++ b/app/assets/stylesheets/pages/graph.scss @@ -17,21 +17,6 @@ } } -.graphs { - .graph-author-email { - float: right; - color: $gl-gray-500; - } - - .graph-additions { - color: $green-600; - } - - .graph-deletions { - color: $red-500; - } -} - .svg-graph-container { width: 100%; diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss deleted file mode 100644 index 31ccdacbc02..00000000000 --- a/app/assets/stylesheets/pages/stat_graph.scss +++ /dev/null @@ -1,62 +0,0 @@ -.tint-box { - background: $stat-graph-common-bg; - position: relative; - margin-bottom: 10px; -} - -.area { - fill: $green-500; - fill-opacity: 0.5; -} - -.axis { - font-size: 10px; -} - -#contributors-master { - @include media-breakpoint-up(md) { - @include make-col-ready(); - @include make-col(12); - } -} - -#contributors { - flex: 1; - - .contributors-list { - margin: 0 0 10px; - list-style: none; - padding: 0; - } - - .person { - @include media-breakpoint-up(md) { - @include make-col-ready(); - @include make-col(6); - } - - margin-top: 10px; - - @include media-breakpoint-down(xs) { - width: 100%; - } - - .spark { - display: block; - background: $stat-graph-common-bg; - width: 100%; - } - - .area-contributor { - fill: $orange-500; - } - } -} - -.selection rect { - fill-opacity: 0.1; - stroke-width: 1px; - stroke-opacity: 0.4; - shape-rendering: crispedges; - stroke-dasharray: 3 3; -} diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index df9d1933271..3c72f41a4c9 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -281,10 +281,7 @@ module IssuablesHelper } data[:hasClosingMergeRequest] = issuable.merge_requests_count(current_user) != 0 if issuable.is_a?(Issue) - - zoom_links = Gitlab::ZoomLinkExtractor.new(issuable.description).links - - data[:zoomMeetingUrl] = zoom_links.last if zoom_links.any? + data[:zoomMeetingUrl] = ZoomMeeting.canonical_meeting_url(issuable) if issuable.is_a?(Issue) if parent.is_a?(Group) data[:groupPath] = parent.path diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 978d0a8c8fb..a59cc3282bb 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -273,7 +273,7 @@ module SearchHelper sanitize(html, tags: %w(a p ol ul li pre code)) end - def search_tabs?(tab) + def show_user_search_tab? return false if Feature.disabled?(:users_search, default_enabled: true) if @project diff --git a/app/models/issue.rb b/app/models/issue.rb index 91bef81227f..260593c38f4 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -40,6 +40,7 @@ class Issue < ApplicationRecord has_many :issue_assignees has_many :assignees, class_name: "User", through: :issue_assignees + has_many :zoom_meetings validates :project, presence: true diff --git a/app/models/zoom_meeting.rb b/app/models/zoom_meeting.rb index cb3b5c60e54..a7ecd1e6a2c 100644 --- a/app/models/zoom_meeting.rb +++ b/app/models/zoom_meeting.rb @@ -14,4 +14,13 @@ class ZoomMeeting < ApplicationRecord scope :added_to_issue, -> { where(issue_status: :added) } scope :removed_from_issue, -> { where(issue_status: :removed) } + scope :canonical, -> (issue) { where(issue: issue).added_to_issue } + + def self.canonical_meeting(issue) + canonical(issue)&.take + end + + def self.canonical_meeting_url(issue) + canonical_meeting(issue)&.url + end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 528b1ea61b3..b98a4d2567f 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -61,8 +61,6 @@ module Issues if added_mentions.present? notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user) end - - ZoomNotesService.new(issue, project, current_user, old_description: old_associations[:description]).execute end def handle_task_changes(issuable) diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb index 561c86475e5..023d7080e88 100644 --- a/app/services/issues/zoom_link_service.rb +++ b/app/services/issues/zoom_link_service.rb @@ -6,32 +6,37 @@ module Issues super(issue.project, user) @issue = issue + @added_meeting = ZoomMeeting.canonical_meeting(@issue) end def add_link(link) if can_add_link? && (link = parse_link(link)) - track_meeting_added_event - success(_('Zoom meeting added'), append_to_description(link)) + begin + add_zoom_meeting(link) + success(_('Zoom meeting added')) + rescue ActiveRecord::RecordNotUnique + error(_('Failed to add a Zoom meeting')) + end else error(_('Failed to add a Zoom meeting')) end end - def can_add_link? - can? && !link_in_issue_description? - end - def remove_link if can_remove_link? - track_meeting_removed_event - success(_('Zoom meeting removed'), remove_from_description) + remove_zoom_meeting + success(_('Zoom meeting removed')) else error(_('Failed to remove a Zoom meeting')) end end + def can_add_link? + can_update_issue? && !@added_meeting + end + def can_remove_link? - can? && link_in_issue_description? + can_update_issue? && !!@added_meeting end def parse_link(link) @@ -42,10 +47,6 @@ module Issues attr_reader :issue - def issue_description - issue.description || '' - end - def track_meeting_added_event ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id) end @@ -54,39 +55,33 @@ module Issues ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id) end - def success(message, description) - ServiceResponse - .success(message: message, payload: { description: description }) - end - - def error(message) - ServiceResponse.error(message: message) + def add_zoom_meeting(link) + ZoomMeeting.create( + issue: @issue, + project: @issue.project, + issue_status: :added, + url: link + ) + track_meeting_added_event + SystemNoteService.zoom_link_added(@issue, @project, current_user) end - def append_to_description(link) - "#{issue_description}\n\n#{link}" + def remove_zoom_meeting + @added_meeting.update(issue_status: :removed) + track_meeting_removed_event + SystemNoteService.zoom_link_removed(@issue, @project, current_user) end - def remove_from_description - link = parse_link(issue_description) - return issue_description unless link - - issue_description.delete_suffix(link).rstrip + def success(message) + ServiceResponse.success(message: message) end - def link_in_issue_description? - link = extract_link_from_issue_description - return unless link - - Gitlab::ZoomLinkExtractor.new(link).match? - end - - def extract_link_from_issue_description - issue_description[/(\S+)\z/, 1] + def error(message) + ServiceResponse.error(message: message) end - def can? - current_user.can?(:update_issue, project) + def can_update_issue? + can?(current_user, :update_issue, project) end end end diff --git a/app/services/zoom_notes_service.rb b/app/services/zoom_notes_service.rb deleted file mode 100644 index 983a7fcacd1..00000000000 --- a/app/services/zoom_notes_service.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -class ZoomNotesService - def initialize(issue, project, current_user, old_description: nil) - @issue = issue - @project = project - @current_user = current_user - @old_description = old_description - end - - def execute - return if @issue.description == @old_description - - if zoom_link_added? - zoom_link_added_notification - elsif zoom_link_removed? - zoom_link_removed_notification - end - end - - private - - def zoom_link_added? - has_zoom_link?(@issue.description) && !has_zoom_link?(@old_description) - end - - def zoom_link_removed? - !has_zoom_link?(@issue.description) && has_zoom_link?(@old_description) - end - - def has_zoom_link?(text) - Gitlab::ZoomLinkExtractor.new(text).match? - end - - def zoom_link_added_notification - SystemNoteService.zoom_link_added(@issue, @project, @current_user) - end - - def zoom_link_removed_notification - SystemNoteService.zoom_link_removed(@issue, @project, @current_user) - end -end diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 9bab3bf56aa..049010cadf4 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -16,7 +16,7 @@ .bs-callout.bs-callout-info = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.') %strong - = link_to _('More information'), help_page_path('user/group/clusters/', anchor: 'cluster-precedence') + = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence') .clusters-table.js-clusters-list .gl-responsive-table-row.table-row-header{ role: "row" } diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index 6e5e4607232..a952db0eea3 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,26 +1,8 @@ - page_title _('Contributors') -.js-graphs-show{ 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json) } - .sub-header-block - .tree-ref-holder.inline.vertical-align-middle - = render 'shared/ref_switcher', destination: 'graphs' - = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn' +.sub-header-block.bg-gray-light.gl-p-3 + .tree-ref-holder.inline.vertical-align-middle + = render 'shared/ref_switcher', destination: 'graphs' + = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn' - .loading-graph - .center - %h3.page-title - %i.fa.fa-spinner.fa-spin - = s_('ContributorsPage|Building repository graph.') - %p.slead - = s_('ContributorsPage|Please wait a moment, this page will automatically refresh when ready.') - - .stat-graph.hide - .header.clearfix - %h3#date_header.page-title - %p.light - = s_('ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits.') % { branch_name: @ref } - %input#brush_change{ :type => "hidden" } - .graphs.row - #contributors-master.svg-w-100 - #contributors.clearfix - %ol.contributors-list.svg-w-100.row +.js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json),'data-project-branch': current_ref } diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml index fc20bc52d1c..1e7903535c6 100644 --- a/app/views/projects/services/edit.html.haml +++ b/app/views/projects/services/edit.html.haml @@ -1,6 +1,7 @@ -- breadcrumb_title s_("ProjectService|Integrations") +- breadcrumb_title @service.title - page_title @service.title, s_("ProjectService|Services") - add_to_breadcrumbs(s_("ProjectService|Settings"), edit_project_path(@project)) +- add_to_breadcrumbs(s_("ProjectService|Integrations"), namespace_project_settings_integrations_path) = render 'deprecated_message' if @service.deprecation_message diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index eae2a491ceb..84198489e41 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -1,5 +1,5 @@ - users = capture_haml do - - if search_tabs?(:members) + - if show_user_search_tab? = search_filter_link 'users', _("Users") .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller |