From 5afd8575506372dd64c238203bd05b4826f3ae2e Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 9 Jan 2020 09:07:51 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- .../behaviors/markdown/paste_markdown_table.js | 94 +++-- app/assets/javascripts/dropzone_input.js | 4 +- .../monitoring/components/dashboard.vue | 56 ++- .../date_time_picker/date_time_picker.vue | 143 ++++---- .../unreleased/36235-services-usage-ping.yml | 5 + ...owplow-custom-events-for-monitor-apm-alerts.yml | 5 + .../testing_guide/end_to_end/style_guide.md | 18 +- lib/gitlab/usage_data.rb | 23 +- lib/sentry/client.rb | 108 ------ lib/sentry/client/issue.rb | 107 ++++++ locale/gitlab.pot | 23 +- qa/Gemfile | 1 + qa/Gemfile.lock | 2 + qa/qa.rb | 3 +- qa/qa/page/base.rb | 10 +- qa/qa/resource/events/base.rb | 7 +- qa/qa/support/repeater.rb | 65 ++++ qa/qa/support/retrier.rb | 74 ++-- qa/qa/support/waiter.rb | 43 ++- qa/qa/vendor/github/page/login.rb | 2 +- qa/qa/vendor/jenkins/page/configure.rb | 2 +- qa/qa/vendor/jenkins/page/login.rb | 2 +- qa/spec/page/base_spec.rb | 8 +- qa/spec/page/logging_spec.rb | 8 +- qa/spec/resource/events/project_spec.rb | 1 + qa/spec/support/repeater_spec.rb | 385 +++++++++++++++++++++ qa/spec/support/retrier_spec.rb | 126 +++++++ qa/spec/support/waiter_spec.rb | 42 ++- .../markdown/paste_markdown_table_spec.js | 44 ++- .../monitoring/components/dashboard_spec.js | 9 +- .../components/dashboard_time_url_spec.js | 13 +- .../components/dashboard_time_window_spec.js | 7 +- .../date_time_picker/date_time_picker_spec.js | 45 ++- .../monitoring/components/graph_group_spec.js | 7 +- .../frontend/vue_shared/components/callout_spec.js | 7 +- .../vue_shared/components/expand_button_spec.js | 7 +- .../components/issue/related_issuable_item_spec.js | 7 +- .../markdown/suggestion_diff_header_spec.js | 7 +- .../components/notes/system_note_spec.js | 5 +- .../components/notes/timeline_entry_item_spec.js | 5 +- .../vue_shared/components/pagination_links_spec.js | 5 +- .../vue_shared/components/time_ago_tooltip_spec.js | 3 +- .../vue_shared/directives/track_event_spec.js | 7 +- .../vue_shared/droplab_dropdown_button_spec.js | 10 +- .../mixins/gl_feature_flags_mixin_spec.js | 5 +- spec/javascripts/dropzone_input_spec.js | 8 +- spec/lib/gitlab/usage_data_spec.rb | 14 +- spec/lib/sentry/client/issue_spec.rb | 210 +++++++++++ spec/lib/sentry/client_spec.rb | 216 +----------- 49 files changed, 1350 insertions(+), 658 deletions(-) create mode 100644 changelogs/unreleased/36235-services-usage-ping.yml create mode 100644 changelogs/unreleased/36955-snowplow-custom-events-for-monitor-apm-alerts.yml create mode 100644 qa/qa/support/repeater.rb create mode 100644 qa/spec/support/repeater_spec.rb create mode 100644 qa/spec/support/retrier_spec.rb diff --git a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js index d14799c976b..665a7216424 100644 --- a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js +++ b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js @@ -1,58 +1,81 @@ +const maxColumnWidth = (rows, columnIndex) => Math.max(...rows.map(row => row[columnIndex].length)); + export default class PasteMarkdownTable { constructor(clipboardData) { this.data = clipboardData; + this.columnWidths = []; + this.rows = []; + this.tableFound = this.parseTable(); + } + + isTable() { + return this.tableFound; } - static maxColumnWidth(rows, columnIndex) { - return Math.max.apply(null, rows.map(row => row[columnIndex].length)); + convertToTableMarkdown() { + this.calculateColumnWidths(); + + const markdownRows = this.rows.map( + row => + // | Name | Title | Email Address | + // |--------------|-------|----------------| + // | Jane Atler | CEO | jane@acme.com | + // | John Doherty | CTO | john@acme.com | + // | Sally Smith | CFO | sally@acme.com | + `| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`, + ); + + // Insert a header break (e.g. -----) to the second row + markdownRows.splice(1, 0, this.generateHeaderBreak()); + + return markdownRows.join('\n'); } + // Private methods below + // To determine whether the cut data is a table, the following criteria // must be satisfied with the clipboard data: // // 1. MIME types "text/plain" and "text/html" exist // 2. The "text/html" data must have a single element - static isTable(data) { - const types = new Set(data.types); - - if (!types.has('text/html') || !types.has('text/plain')) { + // 3. The number of rows in the "text/plain" data matches that of the "text/html" data + // 4. The max number of columns in "text/plain" matches that of the "text/html" data + parseTable() { + if (!this.data.types.includes('text/html') || !this.data.types.includes('text/plain')) { return false; } - const htmlData = data.getData('text/html'); - const doc = new DOMParser().parseFromString(htmlData, 'text/html'); + const htmlData = this.data.getData('text/html'); + this.doc = new DOMParser().parseFromString(htmlData, 'text/html'); + const tables = this.doc.querySelectorAll('table'); // We're only looking for exactly one table. If there happens to be // multiple tables, it's possible an application copied data into // the clipboard that is not related to a simple table. It may also be // complicated converting multiple tables into Markdown. - if (doc.querySelectorAll('table').length === 1) { - return true; + if (tables.length !== 1) { + return false; } - return false; - } - - convertToTableMarkdown() { const text = this.data.getData('text/plain').trim(); - this.rows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g).map(row => row.split('\t')); - this.normalizeRows(); - this.calculateColumnWidths(); + const splitRows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g); - const markdownRows = this.rows.map( - row => - // | Name | Title | Email Address | - // |--------------|-------|----------------| - // | Jane Atler | CEO | jane@acme.com | - // | John Doherty | CTO | john@acme.com | - // | Sally Smith | CFO | sally@acme.com | - `| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`, - ); + // Now check that the number of rows matches between HTML and text + if (this.doc.querySelectorAll('tr').length !== splitRows.length) { + return false; + } - // Insert a header break (e.g. -----) to the second row - markdownRows.splice(1, 0, this.generateHeaderBreak()); + this.rows = splitRows.map(row => row.split('\t')); + this.normalizeRows(); - return markdownRows.join('\n'); + // Check that the max number of columns in the HTML matches the number of + // columns in the text. GitHub, for example, copies a line number and the + // line itself into the HTML data. + if (!this.columnCountsMatch()) { + return false; + } + + return true; } // Ensure each row has the same number of columns @@ -69,10 +92,21 @@ export default class PasteMarkdownTable { calculateColumnWidths() { this.columnWidths = this.rows[0].map((_column, columnIndex) => - PasteMarkdownTable.maxColumnWidth(this.rows, columnIndex), + maxColumnWidth(this.rows, columnIndex), ); } + columnCountsMatch() { + const textColumnCount = this.rows[0].length; + let htmlColumnCount = 0; + + this.doc.querySelectorAll('table tr').forEach(row => { + htmlColumnCount = Math.max(row.cells.length, htmlColumnCount); + }); + + return textColumnCount === htmlColumnCount; + } + formatColumn(column, index) { const spaces = Array(this.columnWidths[index] - column.length + 1).join(' '); return column + spaces; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 79739072abb..86590865892 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -176,11 +176,11 @@ export default function dropzoneInput(form) { const pasteEvent = event.originalEvent; const { clipboardData } = pasteEvent; if (clipboardData && clipboardData.items) { + const converter = new PasteMarkdownTable(clipboardData); // Apple Numbers copies a table as an image, HTML, and text, so // we need to check for the presence of a table first. - if (PasteMarkdownTable.isTable(clipboardData)) { + if (converter.isTable()) { event.preventDefault(); - const converter = new PasteMarkdownTable(clipboardData); const text = converter.convertToTableMarkdown(); pasteText(text); } else { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index c1ca5449ba3..797fd0e7e19 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -22,9 +22,11 @@ import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import GroupEmptyState from './group_empty_state.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils'; +import { getTimeDiff, getAddMetricTrackingOptions } from '../utils'; import { metricStates } from '../constants'; +const defaultTimeDiff = getTimeDiff(); + export default { components: { VueDraggable, @@ -168,9 +170,10 @@ export default { return { state: 'gettingStarted', formIsValid: null, - selectedTimeWindow: {}, - isRearrangingPanels: false, + startDate: getParameterValues('start')[0] || defaultTimeDiff.start, + endDate: getParameterValues('end')[0] || defaultTimeDiff.end, hasValidDates: true, + isRearrangingPanels: false, }; }, computed: { @@ -228,24 +231,10 @@ export default { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); } else { - const defaultRange = getTimeDiff(); - const start = getParameterValues('start')[0] || defaultRange.start; - const end = getParameterValues('end')[0] || defaultRange.end; - - const range = { - start, - end, - }; - - this.selectedTimeWindow = range; - - if (!isValidDate(start) || !isValidDate(end)) { - this.hasValidDates = false; - this.showInvalidDateError(); - } else { - this.hasValidDates = true; - this.fetchData(range); - } + this.fetchData({ + start: this.startDate, + end: this.endDate, + }); } }, methods: { @@ -267,9 +256,20 @@ export default { key, }); }, - showInvalidDateError() { - createFlash(s__('Metrics|Link contains an invalid time window.')); + + onDateTimePickerApply(params) { + redirectTo(mergeUrlParams(params, window.location.href)); + }, + onDateTimePickerInvalid() { + createFlash( + s__( + 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.', + ), + ); + this.startDate = defaultTimeDiff.start; + this.endDate = defaultTimeDiff.end; }, + generateLink(group, title, yLabel) { const dashboard = this.currentDashboard || this.firstDashboard.path; const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null); @@ -287,9 +287,6 @@ export default { submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, - onDateTimePickerApply(timeWindowUrlParams) { - return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href)); - }, /** * Return a single empty state for a group. * @@ -378,15 +375,16 @@ export default { diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue index 8749019c5cd..0aa710b1b3a 100644 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue @@ -5,14 +5,21 @@ import Icon from '~/vue_shared/components/icon.vue'; import DateTimePickerInput from './date_time_picker_input.vue'; import { getTimeDiff, + isValidDate, getTimeWindow, stringToISODate, ISODateToString, truncateZerosInDateTime, isDateTimePickerInputValid, } from '~/monitoring/utils'; + import { timeWindows } from '~/monitoring/constants'; +const events = { + apply: 'apply', + invalid: 'invalid', +}; + export default { components: { Icon, @@ -23,77 +30,94 @@ export default { GlDropdownItem, }, props: { + start: { + type: String, + required: true, + }, + end: { + type: String, + required: true, + }, timeWindows: { type: Object, required: false, default: () => timeWindows, }, - selectedTimeWindow: { - type: Object, - required: false, - default: () => {}, - }, }, data() { return { - selectedTimeWindowText: '', - customTime: { - from: null, - to: null, - }, + startDate: this.start, + endDate: this.end, }; }, computed: { - applyEnabled() { - return Boolean(this.inputState.from && this.inputState.to); + startInputValid() { + return isValidDate(this.startDate); }, - inputState() { - const { from, to } = this.customTime; - return { - from: from && isDateTimePickerInputValid(from), - to: to && isDateTimePickerInputValid(to), - }; + endInputValid() { + return isValidDate(this.endDate); }, - }, - watch: { - selectedTimeWindow() { - this.verifyTimeRange(); + isValid() { + return this.startInputValid && this.endInputValid; + }, + + startInput: { + get() { + return this.startInputValid ? this.formatDate(this.startDate) : this.startDate; + }, + set(val) { + // Attempt to set a formatted date if possible + this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + }, + }, + endInput: { + get() { + return this.endInputValid ? this.formatDate(this.endDate) : this.endDate; + }, + set(val) { + // Attempt to set a formatted date if possible + this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + }, + }, + + timeWindowText() { + const timeWindow = getTimeWindow({ start: this.start, end: this.end }); + if (timeWindow) { + return this.timeWindows[timeWindow]; + } else if (isValidDate(this.start) && isValidDate(this.end)) { + return sprintf(s__('%{start} to %{end}'), { + start: this.formatDate(this.start), + end: this.formatDate(this.end), + }); + } + return ''; }, }, mounted() { - this.verifyTimeRange(); + // Validate on mounted, and trigger an update if needed + if (!this.isValid) { + this.$emit(events.invalid); + } }, methods: { - activeTimeWindow(key) { - return this.timeWindows[key] === this.selectedTimeWindowText; + formatDate(date) { + return truncateZerosInDateTime(ISODateToString(date)); }, - setCustomTimeWindowParameter() { - this.$emit('onApply', { - start: stringToISODate(this.customTime.from), - end: stringToISODate(this.customTime.to), - }); - }, - setTimeWindowParameter(key) { + setTimeWindow(key) { const { start, end } = getTimeDiff(key); - this.$emit('onApply', { - start, - end, - }); + this.startDate = start; + this.endDate = end; + + this.apply(); }, closeDropdown() { this.$refs.dropdown.hide(); }, - verifyTimeRange() { - const range = getTimeWindow(this.selectedTimeWindow); - if (range) { - this.selectedTimeWindowText = this.timeWindows[range]; - } else { - this.customTime = { - from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), - to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), - }; - this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); - } + apply() { + this.$emit(events.apply, { + start: this.startDate, + end: this.endDate, + }); }, }, }; @@ -101,7 +125,7 @@ export default {