...@@ -40,27 +40,33 @@ export default { ...@@ -40,27 +40,33 @@ export default {
Object.assign(state, { isBatchLoading }); Object.assign(state, { isBatchLoading });
}, },
[types.SET_RETRIEVING_BATCHES](state, retrievingBatches) {
Object.assign(state, { retrievingBatches });
},
[types.SET_DIFF_DATA](state, data) { [types.SET_DIFF_DATA](state, data) {
let files = state.diffFiles;
if ( if (
!( !(gon?.features?.diffsBatchLoad && window.location.search.indexOf('diff_id') === -1) &&
gon && data.diff_files
gon.features &&
gon.features.diffsBatchLoad &&
window.location.search.indexOf('diff_id') === -1
)
) { ) {
prepareDiffData(data); files = prepareDiffData(data, files);
} }
Object.assign(state, { Object.assign(state, {
...convertObjectPropsToCamelCase(data), ...convertObjectPropsToCamelCase(data),
diffFiles: files,
}); });
}, },
[types.SET_DIFF_DATA_BATCH](state, data) { [types.SET_DIFF_DATA_BATCH](state, data) {
prepareDiffData(data); const files = prepareDiffData(data, state.diffFiles);
state.diffFiles.push(...data.diff_files); Object.assign(state, {
...convertObjectPropsToCamelCase(data),
diffFiles: files,
});
}, },
[types.RENDER_FILE](state, file) { [types.RENDER_FILE](state, file) {
...@@ -84,11 +90,11 @@ export default { ...@@ -84,11 +90,11 @@ export default {
if (!diffFile) return; if (!diffFile) return;
if (diffFile.highlighted_diff_lines) { if (diffFile.highlighted_diff_lines.length) {
diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm; diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm;
} }
if (diffFile.parallel_diff_lines) { if (diffFile.parallel_diff_lines.length) {
const line = diffFile.parallel_diff_lines.find(l => { const line = diffFile.parallel_diff_lines.find(l => {
const { left, right } = l; const { left, right } = l;
...@@ -149,13 +155,13 @@ export default { ...@@ -149,13 +155,13 @@ export default {
}, },
[types.EXPAND_ALL_FILES](state) { [types.EXPAND_ALL_FILES](state) {
state.diffFiles = state.diffFiles.map(file => ({ state.diffFiles.forEach(file => {
...file, Object.assign(file, {
viewer: { viewer: Object.assign(file.viewer, {
...file.viewer,
collapsed: false, collapsed: false,
}, }),
})); });
});
}, },
[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
...@@ -173,14 +179,17 @@ export default { ...@@ -173,14 +179,17 @@ export default {
const mapDiscussions = (line, extraCheck = () => true) => ({ const mapDiscussions = (line, extraCheck = () => true) => ({
...line, ...line,
discussions: extraCheck() discussions: extraCheck()
? line.discussions ? line.discussions &&
line.discussions
.filter(() => !line.discussions.some(({ id }) => discussion.id === id)) .filter(() => !line.discussions.some(({ id }) => discussion.id === id))
.concat(lineCheck(line) ? discussion : line.discussions) .concat(lineCheck(line) ? discussion : line.discussions)
: [], : [],
}); });
const setDiscussionsExpanded = line => { const setDiscussionsExpanded = line => {
const isLineNoteTargeted = line.discussions.some( const isLineNoteTargeted =
line.discussions &&
line.discussions.some(
disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`), disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`),
); );
...@@ -193,29 +202,29 @@ export default { ...@@ -193,29 +202,29 @@ export default {
}; };
}; };
state.diffFiles = state.diffFiles.map(diffFile => { state.diffFiles.forEach(file => {
if (diffFile.file_hash === fileHash) { if (file.file_hash === fileHash) {
const file = { ...diffFile }; if (file.highlighted_diff_lines.length) {
file.highlighted_diff_lines.forEach(line => {
if (file.highlighted_diff_lines) { Object.assign(
file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => line,
setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
); );
});
} }
if (file.parallel_diff_lines) { if (file.parallel_diff_lines.length) {
file.parallel_diff_lines = file.parallel_diff_lines.map(line => { file.parallel_diff_lines.forEach(line => {
const left = line.left && lineCheck(line.left); const left = line.left && lineCheck(line.left);
const right = line.right && lineCheck(line.right); const right = line.right && lineCheck(line.right);
if (left || right) { if (left || right) {
return { Object.assign(line, {
...line,
left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null, left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null,
right: line.right right: line.right
? setDiscussionsExpanded(mapDiscussions(line.right, () => !left)) ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left))
: null, : null,
}; });
} }
return line; return line;
...@@ -223,15 +232,15 @@ export default { ...@@ -223,15 +232,15 @@ export default {
} }
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) { if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
file.discussions = (file.discussions || []) const newDiscussions = (file.discussions || [])
.filter(d => d.id !== discussion.id) .filter(d => d.id !== discussion.id)
.concat(discussion); .concat(discussion);
}
return file; Object.assign(file, {
discussions: newDiscussions,
});
}
} }
return diffFile;
}); });
}, },
...@@ -255,9 +264,9 @@ export default { ...@@ -255,9 +264,9 @@ export default {
[types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) { [types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash); const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
updateLineInFile(selectedFile, lineCode, line => updateLineInFile(selectedFile, lineCode, line => {
Object.assign(line, { discussionsExpanded: expanded }), Object.assign(line, { discussionsExpanded: expanded });
); });
}, },
[types.TOGGLE_FOLDER_OPEN](state, path) { [types.TOGGLE_FOLDER_OPEN](state, path) {
... ...
......
...@@ -185,6 +185,7 @@ export function addContextLines(options) { ...@@ -185,6 +185,7 @@ export function addContextLines(options) {
* Trims the first char of the `richText` property when it's either a space or a diff symbol. * Trims the first char of the `richText` property when it's either a space or a diff symbol.
* @param {Object} line * @param {Object} line
* @returns {Object} * @returns {Object}
* @deprecated
*/ */
export function trimFirstCharOfLineContent(line = {}) { export function trimFirstCharOfLineContent(line = {}) {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
...@@ -212,80 +213,172 @@ function getLineCode({ left, right }, index) { ...@@ -212,80 +213,172 @@ function getLineCode({ left, right }, index) {
return index; return index;
} }
// This prepares and optimizes the incoming diff data from the server function diffFileUniqueId(file) {
// by setting up incremental rendering and removing unneeded data return `${file.content_sha}-${file.file_hash}`;
export function prepareDiffData(diffData) { }
const filesLength = diffData.diff_files.length;
let showingLines = 0;
for (let i = 0; i < filesLength; i += 1) {
const file = diffData.diff_files[i];
if (file.parallel_diff_lines) { function combineDiffFilesWithPriorFiles(files, prior = []) {
const linesLength = file.parallel_diff_lines.length; files.forEach(file => {
for (let u = 0; u < linesLength; u += 1) { const id = diffFileUniqueId(file);
const line = file.parallel_diff_lines[u]; const oldMatch = prior.find(oldFile => diffFileUniqueId(oldFile) === id);
line.line_code = getLineCode(line, u); if (oldMatch) {
if (line.left) { const missingInline = !file.highlighted_diff_lines;
line.left = trimFirstCharOfLineContent(line.left); const missingParallel = !file.parallel_diff_lines;
line.left.discussions = [];
line.left.hasForm = false; if (missingInline) {
Object.assign(file, {
highlighted_diff_lines: oldMatch.highlighted_diff_lines,
});
} }
if (line.right) {
line.right = trimFirstCharOfLineContent(line.right); if (missingParallel) {
line.right.discussions = []; Object.assign(file, {
line.right.hasForm = false; parallel_diff_lines: oldMatch.parallel_diff_lines,
});
} }
} }
});
return files;
} }
if (file.highlighted_diff_lines) { function ensureBasicDiffFileLines(file) {
const linesLength = file.highlighted_diff_lines.length; const missingInline = !file.highlighted_diff_lines;
for (let u = 0; u < linesLength; u += 1) { const missingParallel = !file.parallel_diff_lines;
const line = file.highlighted_diff_lines[u];
Object.assign(line, { Object.assign(file, {
...trimFirstCharOfLineContent(line), highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines,
parallel_diff_lines: missingParallel ? [] : file.parallel_diff_lines,
});
return file;
}
function cleanRichText(text) {
return text ? text.replace(/^[+ -]/, '') : undefined;
}
function prepareLine(line) {
return Object.assign(line, {
rich_text: cleanRichText(line.rich_text),
discussionsExpanded: true,
discussions: [], discussions: [],
hasForm: false, hasForm: false,
text: undefined,
});
}
function prepareDiffFileLines(file) {
const inlineLines = file.highlighted_diff_lines;
const parallelLines = file.parallel_diff_lines;
let parallelLinesCount = 0;
inlineLines.forEach(prepareLine);
parallelLines.forEach((line, index) => {
Object.assign(line, { line_code: getLineCode(line, index) });
if (line.left) {
parallelLinesCount += 1;
prepareLine(line.left);
}
if (line.right) {
parallelLinesCount += 1;
prepareLine(line.right);
}
Object.assign(file, {
inlineLinesCount: inlineLines.length,
parallelLinesCount,
});
}); });
return file;
} }
showingLines += file.parallel_diff_lines.length;
function getVisibleDiffLines(file) {
return Math.max(file.inlineLinesCount, file.parallelLinesCount);
} }
function finalizeDiffFile(file) {
const name = (file.viewer && file.viewer.name) || diffViewerModes.text; const name = (file.viewer && file.viewer.name) || diffViewerModes.text;
const lines = getVisibleDiffLines(file);
Object.assign(file, { Object.assign(file, {
renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, renderIt: lines < LINES_TO_BE_RENDERED_DIRECTLY,
collapsed: name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED, collapsed: name === diffViewerModes.text && lines > MAX_LINES_TO_BE_RENDERED,
isShowingFullFile: false, isShowingFullFile: false,
isLoadingFullFile: false, isLoadingFullFile: false,
discussions: [], discussions: [],
renderingLines: false, renderingLines: false,
}); });
return file;
} }
export function prepareDiffData(diffData, priorFiles) {
return combineDiffFilesWithPriorFiles(diffData.diff_files, priorFiles)
.map(ensureBasicDiffFileLines)
.map(prepareDiffFileLines)
.map(finalizeDiffFile);
} }
export function getDiffPositionByLineCode(diffFiles) { export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) {
return diffFiles.reduce((acc, diffFile) => { let lines = [];
// We can only use highlightedDiffLines to create the map of diff lines because const hasInlineDiffs = diffFiles.some(file => file.highlighted_diff_lines.length > 0);
// highlightedDiffLines will also include every parallel diff line in it.
if (diffFile.highlighted_diff_lines) { if (!useSingleDiffStyle || hasInlineDiffs) {
// In either of these cases, we can use `highlighted_diff_lines` because
// that will include all of the parallel diff lines, too
lines = diffFiles.reduce((acc, diffFile) => {
diffFile.highlighted_diff_lines.forEach(line => { diffFile.highlighted_diff_lines.forEach(line => {
acc.push({ file: diffFile, line });
});
return acc;
}, []);
} else {
// If we're in single diff view mode and the inline lines haven't been
// loaded yet, we need to parse the parallel lines
lines = diffFiles.reduce((acc, diffFile) => {
diffFile.parallel_diff_lines.forEach(pair => {
// It's possible for a parallel line to have an opposite line that doesn't exist
// For example: *deleted* lines will have `null` right lines, while
// *added* lines will have `null` left lines.
// So we have to check each line before we push it onto the array so we're not
// pushing null line diffs
if (pair.left) {
acc.push({ file: diffFile, line: pair.left });
}
if (pair.right) {
acc.push({ file: diffFile, line: pair.right });
}
});
return acc;
}, []);
}
return lines.reduce((acc, { file, line }) => {
if (line.line_code) { if (line.line_code) {
acc[line.line_code] = { acc[line.line_code] = {
base_sha: diffFile.diff_refs.base_sha, base_sha: file.diff_refs.base_sha,
head_sha: diffFile.diff_refs.head_sha, head_sha: file.diff_refs.head_sha,
start_sha: diffFile.diff_refs.start_sha, start_sha: file.diff_refs.start_sha,
new_path: diffFile.new_path, new_path: file.new_path,
old_path: diffFile.old_path, old_path: file.old_path,
old_line: line.old_line, old_line: line.old_line,
new_line: line.new_line, new_line: line.new_line,
line_code: line.line_code, line_code: line.line_code,
position_type: 'text', position_type: 'text',
}; };
} }
});
}
return acc; return acc;
}, {}); }, {});
...@@ -462,47 +555,47 @@ export const convertExpandLines = ({ ...@@ -462,47 +555,47 @@ export const convertExpandLines = ({
export const idleCallback = cb => requestIdleCallback(cb); export const idleCallback = cb => requestIdleCallback(cb);
export const updateLineInFile = (selectedFile, lineCode, updateFn) => { function getLinesFromFileByLineCode(file, lineCode) {
if (selectedFile.parallel_diff_lines) { const parallelLines = file.parallel_diff_lines;
const targetLine = selectedFile.parallel_diff_lines.find( const inlineLines = file.highlighted_diff_lines;
line => const matchesCode = line => line.line_code === lineCode;
(line.left && line.left.line_code === lineCode) ||
(line.right && line.right.line_code === lineCode),
);
if (targetLine) {
const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
updateFn(targetLine[side]); return [
} ...parallelLines.reduce((acc, line) => {
if (line.left) {
acc.push(line.left);
} }
if (selectedFile.highlighted_diff_lines) {
const targetInlineLine = selectedFile.highlighted_diff_lines.find(
line => line.line_code === lineCode,
);
if (targetInlineLine) { if (line.right) {
updateFn(targetInlineLine); acc.push(line.right);
} }
return acc;
}, []),
...inlineLines,
].filter(matchesCode);
} }
export const updateLineInFile = (selectedFile, lineCode, updateFn) => {
getLinesFromFileByLineCode(selectedFile, lineCode).forEach(updateFn);
}; };
export const allDiscussionWrappersExpanded = diff => { export const allDiscussionWrappersExpanded = diff => {
const discussionsExpandedArray = []; let discussionsExpanded = true;
if (diff.parallel_diff_lines) { const changeExpandedResult = line => {
diff.parallel_diff_lines.forEach(line => { if (line && line.discussions.length) {
if (line.left && line.left.discussions.length) { discussionsExpanded = discussionsExpanded && line.discussionsExpanded;
discussionsExpandedArray.push(line.left.discussionsExpanded);
}
if (line.right && line.right.discussions.length) {
discussionsExpandedArray.push(line.right.discussionsExpanded);
} }
};
diff.parallel_diff_lines.forEach(line => {
changeExpandedResult(line.left);
changeExpandedResult(line.right);
}); });
} else if (diff.highlighted_diff_lines) {
diff.highlighted_diff_lines.forEach(line => { diff.highlighted_diff_lines.forEach(line => {
if (line.discussions.length) { changeExpandedResult(line);
discussionsExpandedArray.push(line.discussionsExpanded);
}
}); });
}
return discussionsExpandedArray.every(el => el); return discussionsExpanded;
}; };
...@@ -101,6 +101,11 @@ class DropDown { ...@@ -101,6 +101,11 @@ class DropDown {
render(data) { render(data) {
const children = data ? data.map(this.renderChildren.bind(this)) : []; const children = data ? data.map(this.renderChildren.bind(this)) : [];
if (this.list.querySelector('.filter-dropdown-loading')) {
return;
}
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
renderableList.innerHTML = children.join(''); renderableList.innerHTML = children.join('');
... ...
......
...@@ -2,6 +2,7 @@ import $ from 'jquery'; ...@@ -2,6 +2,7 @@ import $ from 'jquery';
import Dropzone from 'dropzone'; import Dropzone from 'dropzone';
import _ from 'underscore'; import _ from 'underscore';
import './behaviors/preview_markdown'; import './behaviors/preview_markdown';
import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table';
import csrf from './lib/utils/csrf'; import csrf from './lib/utils/csrf';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
import { n__, __ } from '~/locale'; import { n__, __ } from '~/locale';
...@@ -173,8 +174,18 @@ export default function dropzoneInput(form) { ...@@ -173,8 +174,18 @@ export default function dropzoneInput(form) {
// eslint-disable-next-line consistent-return // eslint-disable-next-line consistent-return
handlePaste = event => { handlePaste = event => {
const pasteEvent = event.originalEvent; const pasteEvent = event.originalEvent;
if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) { 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 (converter.isTable()) {
event.preventDefault();
const text = converter.convertToTableMarkdown();
pasteText(text);
} else {
const image = isImage(pasteEvent); const image = isImage(pasteEvent);
if (image) { if (image) {
event.preventDefault(); event.preventDefault();
const filename = getFilename(pasteEvent) || 'image.png'; const filename = getFilename(pasteEvent) || 'image.png';
...@@ -183,6 +194,7 @@ export default function dropzoneInput(form) { ...@@ -183,6 +194,7 @@ export default function dropzoneInput(form) {
return uploadFile(image.getAsFile(), filename); return uploadFile(image.getAsFile(), filename);
} }
} }
}
}; };
isImage = data => { isImage = data => {
... ...
......
...@@ -116,11 +116,13 @@ class DueDateSelect { ...@@ -116,11 +116,13 @@ class DueDateSelect {
} }
updateIssueBoardIssue() { updateIssueBoardIssue() {
// eslint-disable-next-line no-jquery/no-fade
this.$loading.fadeIn(); this.$loading.fadeIn();
this.$dropdown.trigger('loading.gl.dropdown'); this.$dropdown.trigger('loading.gl.dropdown');
this.$selectbox.hide(); this.$selectbox.hide();
this.$value.css('display', ''); this.$value.css('display', '');
const fadeOutLoader = () => { const fadeOutLoader = () => {
// eslint-disable-next-line no-jquery/no-fade
this.$loading.fadeOut(); this.$loading.fadeOut();
}; };
...@@ -135,6 +137,7 @@ class DueDateSelect { ...@@ -135,6 +137,7 @@ class DueDateSelect {
const hasDueDate = this.displayedDate !== __('None'); const hasDueDate = this.displayedDate !== __('None');
const displayedDateStyle = hasDueDate ? 'bold' : 'no-value'; const displayedDateStyle = hasDueDate ? 'bold' : 'no-value';
// eslint-disable-next-line no-jquery/no-fade
this.$loading.removeClass('hidden').fadeIn(); this.$loading.removeClass('hidden').fadeIn();
if (isDropdown) { if (isDropdown) {
...@@ -158,6 +161,7 @@ class DueDateSelect { ...@@ -158,6 +161,7 @@ class DueDateSelect {
} }
this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); this.$sidebarCollapsedValue.attr('data-original-title', tooltipText);
// eslint-disable-next-line no-jquery/no-fade
return this.$loading.fadeOut(); return this.$loading.fadeOut();
}); });
} }
... ...
......
<script> <script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ /* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { format } from 'timeago.js';
import _ from 'underscore'; import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui'; import { GlTooltipDirective } from '@gitlab/ui';
import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; import { __, sprintf } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
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 CommitComponent from '~/vue_shared/components/commit.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { __, sprintf } from '~/locale'; import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue'; import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue'; import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue'; import MonitoringButtonComponent from './environment_monitoring.vue';
import PinComponent from './environment_pin.vue';
import RollbackComponent from './environment_rollback.vue'; import RollbackComponent from './environment_rollback.vue';
import StopComponent from './environment_stop.vue';
import TerminalButtonComponent from './environment_terminal_button.vue'; import TerminalButtonComponent from './environment_terminal_button.vue';
import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
/** /**
* Environment Item Component * Environment Item Component
...@@ -26,21 +27,22 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; ...@@ -26,21 +27,22 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default { export default {
components: { components: {
CommitComponent,
Icon,
ActionsComponent, ActionsComponent,
CommitComponent,
ExternalUrlComponent, ExternalUrlComponent,
StopComponent, Icon,
MonitoringButtonComponent,
PinComponent,
RollbackComponent, RollbackComponent,
StopComponent,
TerminalButtonComponent, TerminalButtonComponent,
MonitoringButtonComponent,
TooltipOnTruncate, TooltipOnTruncate,
UserAvatarLink, UserAvatarLink,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
mixins: [environmentItemMixin], mixins: [environmentItemMixin, timeagoMixin],
props: { props: {
canReadEnvironment: { canReadEnvironment: {
...@@ -52,7 +54,12 @@ export default { ...@@ -52,7 +54,12 @@ export default {
model: { model: {
type: Object, type: Object,
required: true, required: true,
default: () => ({}), },
shouldShowAutoStopDate: {
type: Boolean,
required: false,
default: false,
}, },
tableData: { tableData: {
...@@ -76,6 +83,16 @@ export default { ...@@ -76,6 +83,16 @@ export default {
return false; return false;
}, },
/**
* Checkes whether the row displayed is a folder.
*
* @returns {Boolean}
*/
isFolder() {
return this.model.isFolder;
},
/** /**
* Checkes whether the environment is protected. * Checkes whether the environment is protected.
* (`is_protected` currently only set in EE) * (`is_protected` currently only set in EE)
...@@ -112,24 +129,64 @@ export default { ...@@ -112,24 +129,64 @@ export default {
}, },
/** /**
* Verifies if the date to be shown is present. * Verifies if the autostop date is present.
*
* @returns {Boolean}
*/
canShowAutoStopDate() {
if (!this.model.auto_stop_at) {
return false;
}
const autoStopDate = new Date(this.model.auto_stop_at);
const now = new Date();
return now < autoStopDate;
},
/**
* Human readable deployment date.
*
* @returns {String}
*/
autoStopDate() {
if (this.canShowAutoStopDate) {
return {
formatted: this.timeFormatted(this.model.auto_stop_at),
tooltip: this.tooltipTitle(this.model.auto_stop_at),
};
}
return {
formatted: '',
tooltip: '',
};
},
/**
* Verifies if the deployment date is present.
* *
* @returns {Boolean|Undefined} * @returns {Boolean|Undefined}
*/ */
canShowDate() { canShowDeploymentDate() {
return this.model && this.model.last_deployment && this.model.last_deployment.deployed_at; return this.model && this.model.last_deployment && this.model.last_deployment.deployed_at;
}, },
/** /**
* Human readable date. * Human readable deployment date.
* *
* @returns {String} * @returns {String}
*/ */
deployedDate() { deployedDate() {
if (this.canShowDate) { if (this.canShowDeploymentDate) {
return format(this.model.last_deployment.deployed_at); return {
formatted: this.timeFormatted(this.model.last_deployment.deployed_at),
tooltip: this.tooltipTitle(this.model.last_deployment.deployed_at),
};
} }
return ''; return {
formatted: '',
tooltip: '',
};
}, },
actions() { actions() {
...@@ -344,6 +401,15 @@ export default { ...@@ -344,6 +401,15 @@ export default {
return {}; return {};
}, },
/**
* Checkes whether to display no deployment text.
*
* @returns {Boolean}
*/
showNoDeployments() {
return !this.hasLastDeploymentKey && !this.isFolder;
},
/** /**
* Verifies if the build name column should be rendered by verifing * Verifies if the build name column should be rendered by verifing
* if all the information needed is present * if all the information needed is present
...@@ -353,7 +419,7 @@ export default { ...@@ -353,7 +419,7 @@ export default {
*/ */
shouldRenderBuildName() { shouldRenderBuildName() {
return ( return (
!this.model.isFolder && !this.isFolder &&
!_.isEmpty(this.model.last_deployment) && !_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.deployable) !_.isEmpty(this.model.last_deployment.deployable)
); );
...@@ -383,11 +449,7 @@ export default { ...@@ -383,11 +449,7 @@ export default {
* @return {String} * @return {String}
*/ */
externalURL() { externalURL() {
if (this.model && this.model.external_url) { return this.model.external_url || '';
return this.model.external_url;
}
return '';
}, },
/** /**
...@@ -399,26 +461,22 @@ export default { ...@@ -399,26 +461,22 @@ export default {
*/ */
shouldRenderDeploymentID() { shouldRenderDeploymentID() {
return ( return (
!this.model.isFolder && !this.isFolder &&
!_.isEmpty(this.model.last_deployment) && !_.isEmpty(this.model.last_deployment) &&
this.model.last_deployment.iid !== undefined this.model.last_deployment.iid !== undefined
); );
}, },
environmentPath() { environmentPath() {
if (this.model && this.model.environment_path) { return this.model.environment_path || '';
return this.model.environment_path;
}
return '';
}, },
monitoringUrl() { monitoringUrl() {
if (this.model && this.model.metrics_path) { return this.model.metrics_path || '';
return this.model.metrics_path; },
}
return ''; autoStopUrl() {
return this.model.cancel_auto_stop_path || '';
}, },
displayEnvironmentActions() { displayEnvironmentActions() {
...@@ -447,7 +505,7 @@ export default { ...@@ -447,7 +505,7 @@ export default {
<div <div
:class="{ :class="{
'js-child-row environment-child-row': model.isChildren, 'js-child-row environment-child-row': model.isChildren,
'folder-row': model.isFolder, 'folder-row': isFolder,
}" }"
class="gl-responsive-table-row" class="gl-responsive-table-row"
role="row" role="row"
...@@ -457,7 +515,7 @@ export default { ...@@ -457,7 +515,7 @@ export default {
:class="tableData.name.spacing" :class="tableData.name.spacing"
role="gridcell" role="gridcell"
> >
<div v-if="!model.isFolder" class="table-mobile-header" role="rowheader"> <div v-if="!isFolder" class="table-mobile-header" role="rowheader">
{{ tableData.name.title }} {{ tableData.name.title }}
</div> </div>
...@@ -466,7 +524,7 @@ export default { ...@@ -466,7 +524,7 @@ export default {
</span> </span>
<span <span
v-if="!model.isFolder" v-if="!isFolder"
v-gl-tooltip v-gl-tooltip
:title="model.name" :title="model.name"
class="environment-name table-mobile-content" class="environment-name table-mobile-content"
...@@ -506,7 +564,7 @@ export default { ...@@ -506,7 +564,7 @@ export default {
{{ deploymentInternalId }} {{ deploymentInternalId }}
</span> </span>
<span v-if="!model.isFolder && deploymentHasUser" class="text-break-word"> <span v-if="!isFolder && deploymentHasUser" class="text-break-word">
by by
<user-avatar-link <user-avatar-link
:link-href="deploymentUser.web_url" :link-href="deploymentUser.web_url"
...@@ -516,6 +574,10 @@ export default { ...@@ -516,6 +574,10 @@ export default {
class="js-deploy-user-container float-none" class="js-deploy-user-container float-none"
/> />
</span> </span>
<div v-if="showNoDeployments" class="commit-title table-mobile-content">
{{ s__('Environments|No deployments yet') }}
</div>
</div> </div>
<div <div
...@@ -536,14 +598,8 @@ export default { ...@@ -536,14 +598,8 @@ export default {
</a> </a>
</div> </div>
<div <div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell">
v-if="!model.isFolder"
class="table-section"
:class="tableData.commit.spacing"
role="gridcell"
>
<div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div> <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div>
<div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content"> <div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content">
<commit-component <commit-component
:tag="commitTag" :tag="commitTag"
...@@ -554,31 +610,51 @@ export default { ...@@ -554,31 +610,51 @@ export default {
:author="commitAuthor" :author="commitAuthor"
/> />
</div> </div>
<div v-if="!hasLastDeploymentKey" class="commit-title table-mobile-content">
{{ s__('Environments|No deployments yet') }}
</div> </div>
<div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell">
<div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div>
<span
v-if="canShowDeploymentDate"
v-gl-tooltip
:title="deployedDate.tooltip"
class="environment-created-date-timeago table-mobile-content flex-truncate-parent"
>
<span class="flex-truncate-child">
{{ deployedDate.formatted }}
</span>
</span>
</div> </div>
<div <div
v-if="!model.isFolder" v-if="!isFolder && shouldShowAutoStopDate"
class="table-section" class="table-section"
:class="tableData.date.spacing" :class="tableData.autoStop.spacing"
role="gridcell" role="gridcell"
> >
<div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> <div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div>
<span
<span v-if="canShowDate" class="environment-created-date-timeago table-mobile-content"> v-if="canShowAutoStopDate"
{{ deployedDate }} v-gl-tooltip
:title="autoStopDate.tooltip"
class="table-mobile-content flex-truncate-parent"
>
<span class="flex-truncate-child js-auto-stop">{{ autoStopDate.formatted }}</span>
</span> </span>
</div> </div>
<div <div
v-if="!model.isFolder && displayEnvironmentActions" v-if="!isFolder && displayEnvironmentActions"
class="table-section table-button-footer" class="table-section table-button-footer"
:class="tableData.actions.spacing" :class="tableData.actions.spacing"
role="gridcell" role="gridcell"
> >
<div class="btn-group table-action-buttons" role="group"> <div class="btn-group table-action-buttons" role="group">
<pin-component
v-if="canShowAutoStopDate && shouldShowAutoStopDate"
:auto-stop-url="autoStopUrl"
/>
<external-url-component <external-url-component
v-if="externalURL && canReadEnvironment" v-if="externalURL && canReadEnvironment"
:external-url="externalURL" :external-url="externalURL"
... ...
......
<script>
/**
* Renders a prevent auto-stop button.
* Used in environments table.
*/
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
Icon,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
autoStopUrl: {
type: String,
required: true,
},
},
methods: {
onPinClick() {
eventHub.$emit('cancelAutoStop', this.autoStopUrl);
},
},
title: __('Prevent environment from auto-stopping'),
};
</script>
<template>
<gl-button v-gl-tooltip :title="$options.title" :aria-label="$options.title" @click="onPinClick">
<icon name="thumbtack" />
</gl-button>
</template>
...@@ -8,7 +8,6 @@ ...@@ -8,7 +8,6 @@
import { GlTooltipDirective, GlLoadingIcon, GlModalDirective, GlButton } from '@gitlab/ui'; import { GlTooltipDirective, GlLoadingIcon, GlModalDirective, GlButton } from '@gitlab/ui';
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 ConfirmRollbackModal from './confirm_rollback_modal.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
...@@ -16,7 +15,6 @@ export default { ...@@ -16,7 +15,6 @@ export default {
Icon, Icon,
GlLoadingIcon, GlLoadingIcon,
GlButton, GlButton,
ConfirmRollbackModal,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
... ...
......
...@@ -6,6 +6,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; ...@@ -6,6 +6,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import _ from 'underscore'; import _ from 'underscore';
import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin'; import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import EnvironmentItem from './environment_item.vue'; import EnvironmentItem from './environment_item.vue';
export default { export default {
...@@ -16,7 +17,7 @@ export default { ...@@ -16,7 +17,7 @@ export default {
CanaryDeploymentCallout: () => CanaryDeploymentCallout: () =>
import('ee_component/environments/components/canary_deployment_callout.vue'), import('ee_component/environments/components/canary_deployment_callout.vue'),
}, },
mixins: [environmentTableMixin], mixins: [environmentTableMixin, glFeatureFlagsMixin()],
props: { props: {
environments: { environments: {
type: Array, type: Array,
...@@ -42,6 +43,9 @@ export default { ...@@ -42,6 +43,9 @@ export default {
: env, : env,
); );
}, },
shouldShowAutoStopDate() {
return this.glFeatures.autoStopEnvironments;
},
tableData() { tableData() {
return { return {
// percent spacing for cols, should add up to 100 // percent spacing for cols, should add up to 100
...@@ -65,8 +69,12 @@ export default { ...@@ -65,8 +69,12 @@ export default {
title: s__('Environments|Updated'), title: s__('Environments|Updated'),
spacing: 'section-10', spacing: 'section-10',
}, },
autoStop: {
title: s__('Environments|Auto stop in'),
spacing: 'section-5',
},
actions: { actions: {
spacing: 'section-30', spacing: this.shouldShowAutoStopDate ? 'section-25' : 'section-30',
}, },
}; };
}, },
...@@ -123,6 +131,14 @@ export default { ...@@ -123,6 +131,14 @@ export default {
<div class="table-section" :class="tableData.date.spacing" role="columnheader"> <div class="table-section" :class="tableData.date.spacing" role="columnheader">
{{ tableData.date.title }} {{ tableData.date.title }}
</div> </div>
<div
v-if="shouldShowAutoStopDate"
class="table-section"
:class="tableData.autoStop.spacing"
role="columnheader"
>
{{ tableData.autoStop.title }}
</div>
</div> </div>
<template v-for="(model, i) in sortedEnvironments" :model="model"> <template v-for="(model, i) in sortedEnvironments" :model="model">
<div <div
...@@ -130,6 +146,7 @@ export default { ...@@ -130,6 +146,7 @@ export default {
:key="`environment-item-${i}`" :key="`environment-item-${i}`"
:model="model" :model="model"
:can-read-environment="canReadEnvironment" :can-read-environment="canReadEnvironment"
:should-show-auto-stop-date="shouldShowAutoStopDate"
:table-data="tableData" :table-data="tableData"
/> />
... ...
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
import { GlTooltipDirective } from '@gitlab/ui'; import { GlTooltipDirective } from '@gitlab/ui';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
...@@ -12,7 +11,6 @@ export default { ...@@ -12,7 +11,6 @@ export default {
components: { components: {
GlModal: DeprecatedModal2, GlModal: DeprecatedModal2,
LoadingButton,
}, },
directives: { directives: {
... ...
......
...@@ -90,16 +90,19 @@ export default { ...@@ -90,16 +90,19 @@ export default {
Flash(s__('Environments|An error occurred while fetching the environments.')); Flash(s__('Environments|An error occurred while fetching the environments.'));
}, },
postAction({ endpoint, errorMessage }) { postAction({
endpoint,
errorMessage = s__('Environments|An error occurred while making the request.'),
}) {
if (!this.isMakingRequest) { if (!this.isMakingRequest) {
this.isLoading = true; this.isLoading = true;
this.service this.service
.postAction(endpoint) .postAction(endpoint)
.then(() => this.fetchEnvironments()) .then(() => this.fetchEnvironments())
.catch(() => { .catch(err => {
this.isLoading = false; this.isLoading = false;
Flash(errorMessage || s__('Environments|An error occurred while making the request.')); Flash(_.isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage);
}); });
} }
}, },
...@@ -138,6 +141,13 @@ export default { ...@@ -138,6 +141,13 @@ export default {
); );
this.postAction({ endpoint: retryUrl, errorMessage }); this.postAction({ endpoint: retryUrl, errorMessage });
}, },
cancelAutoStop(autoStopPath) {
const errorMessage = ({ message }) =>
message ||
s__('Environments|An error occurred while canceling the auto stop, please try again');
this.postAction({ endpoint: autoStopPath, errorMessage });
},
}, },
computed: { computed: {
...@@ -199,6 +209,8 @@ export default { ...@@ -199,6 +209,8 @@ export default {
eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$on('rollbackEnvironment', this.rollbackEnvironment); eventHub.$on('rollbackEnvironment', this.rollbackEnvironment);
eventHub.$on('cancelAutoStop', this.cancelAutoStop);
}, },
beforeDestroy() { beforeDestroy() {
...@@ -208,5 +220,7 @@ export default { ...@@ -208,5 +220,7 @@ export default {
eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$off('rollbackEnvironment', this.rollbackEnvironment); eventHub.$off('rollbackEnvironment', this.rollbackEnvironment);
eventHub.$off('cancelAutoStop', this.cancelAutoStop);
}, },
}; };
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; import createFlash from '~/flash';
import { GlButton, GlFormInput, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui';
import { __, sprintf, n__ } from '~/locale'; import { __, sprintf, n__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
...@@ -11,21 +12,41 @@ import TrackEventDirective from '~/vue_shared/directives/track_event'; ...@@ -11,21 +12,41 @@ import TrackEventDirective from '~/vue_shared/directives/track_event';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import { trackClickErrorLinkToSentryOptions } from '../utils'; import { trackClickErrorLinkToSentryOptions } from '../utils';
import query from '../queries/details.query.graphql';
export default { export default {
components: { components: {
LoadingButton, LoadingButton,
GlButton,
GlFormInput, GlFormInput,
GlLink, GlLink,
GlLoadingIcon, GlLoadingIcon,
TooltipOnTruncate, TooltipOnTruncate,
Icon, Icon,
Stacktrace, Stacktrace,
GlBadge,
}, },
directives: { directives: {
TrackEvent: TrackEventDirective, TrackEvent: TrackEventDirective,
}, },
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
listPath: {
type: String,
required: true,
},
issueUpdatePath: {
type: String,
required: true,
},
issueId: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
issueDetailsPath: { issueDetailsPath: {
type: String, type: String,
required: true, required: true,
...@@ -43,38 +64,67 @@ export default { ...@@ -43,38 +64,67 @@ export default {
required: true, required: true,
}, },
}, },
apollo: {
GQLerror: {
query,
variables() {
return {
fullPath: this.projectPath,
errorId: `gid://gitlab/Gitlab::ErrorTracking::DetailedError/${this.issueId}`,
};
},
pollInterval: 2000,
update: data => data.project.sentryDetailedError,
error: () => createFlash(__('Failed to load error details from Sentry.')),
result(res) {
if (res.data.project?.sentryDetailedError) {
this.$apollo.queries.GQLerror.stopPolling();
}
},
},
},
data() { data() {
return { return {
GQLerror: null,
issueCreationInProgress: false, issueCreationInProgress: false,
}; };
}, },
computed: { computed: {
...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']), ...mapState('details', [
'error',
'loading',
'loadingStacktrace',
'stacktraceData',
'updatingResolveStatus',
'updatingIgnoreStatus',
]),
...mapGetters('details', ['stacktrace']), ...mapGetters('details', ['stacktrace']),
reported() { reported() {
return sprintf( return sprintf(
__('Reported %{timeAgo} by %{reportedBy}'), __('Reported %{timeAgo} by %{reportedBy}'),
{ {
reportedBy: `<strong>${this.error.culprit}</strong>`, reportedBy: `<strong>${this.GQLerror.culprit}</strong>`,
timeAgo: this.timeFormatted(this.stacktraceData.date_received), timeAgo: this.timeFormatted(this.stacktraceData.date_received),
}, },
false, false,
); );
}, },
firstReleaseLink() { firstReleaseLink() {
return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`; return `${this.error.external_base_url}/releases/${this.GQLerror.firstReleaseShortVersion}`;
}, },
lastReleaseLink() { lastReleaseLink() {
return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`; return `${this.error.external_base_url}releases/${this.GQLerror.lastReleaseShortVersion}`;
}, },
showDetails() { showDetails() {
return Boolean(!this.loading && this.error && this.error.id); return Boolean(
!this.loading && !this.$apollo.queries.GQLerror.loading && this.error && this.GQLerror,
);
}, },
showStacktrace() { showStacktrace() {
return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length);
}, },
issueTitle() { issueTitle() {
return this.error.title; return this.GQLerror.title;
}, },
issueDescription() { issueDescription() {
return sprintf( return sprintf(
...@@ -83,29 +133,35 @@ export default { ...@@ -83,29 +133,35 @@ export default {
), ),
{ {
description: '# Error Details:\n', description: '# Error Details:\n',
errorUrl: `${this.error.external_url}\n`, errorUrl: `${this.GQLerror.externalUrl}\n`,
firstSeen: `\n${this.error.first_seen}\n`, firstSeen: `\n${this.GQLerror.firstSeen}\n`,
lastSeen: `${this.error.last_seen}\n`, lastSeen: `${this.GQLerror.lastSeen}\n`,
countLabel: n__('- Event', '- Events', this.error.count), countLabel: n__('- Event', '- Events', this.GQLerror.count),
count: `${this.error.count}\n`, count: `${this.GQLerror.count}\n`,
userCountLabel: n__('- User', '- Users', this.error.user_count), userCountLabel: n__('- User', '- Users', this.GQLerror.userCount),
userCount: `${this.error.user_count}\n`, userCount: `${this.GQLerror.userCount}\n`,
}, },
false, false,
); );
}, },
errorLevel() {
return sprintf(__('level: %{level}'), { level: this.error.tags.level });
},
}, },
mounted() { mounted() {
this.startPollingDetails(this.issueDetailsPath); this.startPollingDetails(this.issueDetailsPath);
this.startPollingStacktrace(this.issueStackTracePath); this.startPollingStacktrace(this.issueStackTracePath);
}, },
methods: { methods: {
...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']), ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace', 'updateStatus']),
trackClickErrorLinkToSentryOptions, trackClickErrorLinkToSentryOptions,
createIssue() { createIssue() {
this.issueCreationInProgress = true; this.issueCreationInProgress = true;
this.$refs.sentryIssueForm.submit(); this.$refs.sentryIssueForm.submit();
}, },
updateIssueStatus(status) {
this.updateStatus({ endpoint: this.issueUpdatePath, redirectUrl: this.listPath, status });
},
formatDate(date) { formatDate(date) {
return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`;
}, },
...@@ -115,17 +171,43 @@ export default { ...@@ -115,17 +171,43 @@ export default {
<template> <template>
<div> <div>
<div v-if="loading" class="py-3"> <div v-if="$apollo.queries.GQLerror.loading || loading" class="py-3">
<gl-loading-icon :size="3" /> <gl-loading-icon :size="3" />
</div> </div>
<div v-else-if="showDetails" class="error-details"> <div v-else-if="showDetails" class="error-details">
<div class="top-area align-items-center justify-content-between py-3"> <div class="top-area align-items-center justify-content-between py-3">
<span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span> <span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span>
<form ref="sentryIssueForm" :action="projectIssuesPath" method="POST"> <div class="d-inline-flex">
<loading-button
:label="__('Ignore')"
:loading="updatingIgnoreStatus"
@click="updateIssueStatus('ignored')"
/>
<loading-button
class="btn-outline-info ml-2"
:label="__('Resolve')"
:loading="updatingResolveStatus"
@click="updateIssueStatus('resolved')"
/>
<gl-button
v-if="error.gitlab_issue"
class="ml-2"
data-qa-selector="view_issue_button"
:href="error.gitlab_issue"
variant="success"
>
{{ __('View issue') }}
</gl-button>
<form
ref="sentryIssueForm"
:action="projectIssuesPath"
method="POST"
class="d-inline-block ml-2"
>
<gl-form-input class="hidden" name="issue[title]" :value="issueTitle" /> <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" />
<input name="issue[description]" :value="issueDescription" type="hidden" /> <input name="issue[description]" :value="issueDescription" type="hidden" />
<gl-form-input <gl-form-input
:value="error.id" :value="GQLerror.sentryId"
class="hidden" class="hidden"
name="issue[sentry_issue_attributes][sentry_issue_identifier]" name="issue[sentry_issue_attributes][sentry_issue_identifier]"
/> />
...@@ -140,50 +222,67 @@ export default { ...@@ -140,50 +222,67 @@ export default {
/> />
</form> </form>
</div> </div>
</div>
<div> <div>
<tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> <tooltip-on-truncate :title="GQLerror.title" truncate-target="child" placement="top">
<h2 class="text-truncate">{{ error.title }}</h2> <h2 class="text-truncate">{{ GQLerror.title }}</h2>
</tooltip-on-truncate> </tooltip-on-truncate>
<h3>{{ __('Error details') }}</h3> <template v-if="error.tags">
<gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2"
>{{ errorLevel }}
</gl-badge>
<gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill"
>{{ error.tags.logger }}
</gl-badge>
</template>
<ul> <ul>
<li v-if="GQLerror.gitlabCommit">
<strong class="bold">{{ __('GitLab commit') }}:</strong>
<gl-link :href="GQLerror.gitlabCommitPath">
<span>{{ GQLerror.gitlabCommit.substr(0, 10) }}</span>
</gl-link>
</li>
<li v-if="error.gitlab_issue"> <li v-if="error.gitlab_issue">
<span class="bold">{{ __('GitLab Issue') }}:</span> <strong class="bold">{{ __('GitLab Issue') }}:</strong>
<gl-link :href="error.gitlab_issue"> <gl-link :href="error.gitlab_issue">
<span>{{ error.gitlab_issue }}</span> <span>{{ error.gitlab_issue }}</span>
</gl-link> </gl-link>
</li> </li>
<li> <li>
<span class="bold">{{ __('Sentry event') }}:</span> <strong class="bold">{{ __('Sentry event') }}:</strong>
<gl-link <gl-link
v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)" v-track-event="trackClickErrorLinkToSentryOptions(GQLerror.externalUrl)"
:href="error.external_url" class="d-inline-flex align-items-center"
:href="GQLerror.externalUrl"
target="_blank" target="_blank"
> >
<span class="text-truncate">{{ error.external_url }}</span> <span class="text-truncate">{{ GQLerror.externalUrl }}</span>
<icon name="external-link" class="ml-1 flex-shrink-0" /> <icon name="external-link" class="ml-1 flex-shrink-0" />
</gl-link> </gl-link>
</li> </li>
<li v-if="error.first_release_short_version"> <li v-if="GQLerror.firstReleaseShortVersion">
<span class="bold">{{ __('First seen') }}:</span> <strong class="bold">{{ __('First seen') }}:</strong>
{{ formatDate(error.first_seen) }} {{ formatDate(GQLerror.firstSeen) }}
<gl-link :href="firstReleaseLink" target="_blank"> <gl-link :href="firstReleaseLink" target="_blank">
<span>{{ __('Release') }}: {{ error.first_release_short_version }}</span> <span>
{{ __('Release') }}: {{ GQLerror.firstReleaseShortVersion.substr(0, 10) }}
</span>
</gl-link> </gl-link>
</li> </li>
<li v-if="error.last_release_short_version"> <li v-if="GQLerror.lastReleaseShortVersion">
<span class="bold">{{ __('Last seen') }}:</span> <strong class="bold">{{ __('Last seen') }}:</strong>
{{ formatDate(error.last_seen) }} {{ formatDate(GQLerror.lastSeen) }}
<gl-link :href="lastReleaseLink" target="_blank"> <gl-link :href="lastReleaseLink" target="_blank">
<span>{{ __('Release') }}: {{ error.last_release_short_version }}</span> <span>{{ __('Release') }}: {{ GQLerror.lastReleaseShortVersion.substr(0, 10) }}</span>
</gl-link> </gl-link>
</li> </li>
<li> <li>
<span class="bold">{{ __('Events') }}:</span> <strong class="bold">{{ __('Events') }}:</strong>
<span>{{ error.count }}</span> <span>{{ GQLerror.count }}</span>
</li> </li>
<li> <li>
<span class="bold">{{ __('Users') }}:</span> <strong class="bold">{{ __('Users') }}:</strong>
<span>{{ error.user_count }}</span> <span>{{ GQLerror.userCount }}</span>
</li> </li>
</ul> </ul>
... ...
......
...@@ -25,10 +25,47 @@ export default { ...@@ -25,10 +25,47 @@ export default {
PREV_PAGE: 1, PREV_PAGE: 1,
NEXT_PAGE: 2, NEXT_PAGE: 2,
fields: [ fields: [
{ key: 'error', label: __('Open errors'), thClass: 'w-70p' }, {
{ key: 'events', label: __('Events') }, key: 'error',
{ key: 'users', label: __('Users') }, label: __('Error'),
{ key: 'lastSeen', label: __('Last seen'), thClass: 'w-15p' }, thClass: 'w-60p',
tdClass: 'table-col d-flex d-sm-table-cell px-3',
},
{
key: 'events',
label: __('Events'),
thClass: 'text-right',
tdClass: 'table-col d-flex d-sm-table-cell',
},
{
key: 'users',
label: __('Users'),
thClass: 'text-right',
tdClass: 'table-col d-flex d-sm-table-cell',
},
{
key: 'lastSeen',
label: __('Last seen'),
thClass: '',
tdClass: 'table-col d-flex d-sm-table-cell',
},
{
key: 'ignore',
label: '',
thClass: 'w-3rem',
tdClass: 'table-col d-flex pl-0 d-sm-table-cell',
},
{
key: 'resolved',
label: '',
thClass: 'w-3rem',
tdClass: 'table-col d-flex pl-0 d-sm-table-cell',
},
{
key: 'details',
tdClass: 'table-col d-sm-none d-flex align-items-center',
thClass: 'invisible w-0',
},
], ],
sortFields: { sortFields: {
last_seen: __('Last Seen'), last_seen: __('Last Seen'),
...@@ -74,6 +111,14 @@ export default { ...@@ -74,6 +111,14 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
projectPath: {
type: String,
required: true,
},
listPath: {
type: String,
required: true,
},
}, },
hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(), hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(),
data() { data() {
...@@ -90,6 +135,7 @@ export default { ...@@ -90,6 +135,7 @@ export default {
'sortField', 'sortField',
'recentSearches', 'recentSearches',
'pagination', 'pagination',
'cursor',
]), ]),
paginationRequired() { paginationRequired() {
return !_.isEmpty(this.pagination); return !_.isEmpty(this.pagination);
...@@ -119,6 +165,8 @@ export default { ...@@ -119,6 +165,8 @@ export default {
'clearRecentSearches', 'clearRecentSearches',
'loadRecentSearches', 'loadRecentSearches',
'setIndexPath', 'setIndexPath',
'fetchPaginatedResults',
'updateStatus',
]), ]),
setSearchText(text) { setSearchText(text) {
this.errorSearchQuery = text; this.errorSearchQuery = text;
...@@ -129,10 +177,10 @@ export default { ...@@ -129,10 +177,10 @@ export default {
}, },
goToNextPage() { goToNextPage() {
this.pageValue = this.$options.NEXT_PAGE; this.pageValue = this.$options.NEXT_PAGE;
this.startPolling(`${this.indexPath}?cursor=${this.pagination.next.cursor}`); this.fetchPaginatedResults(this.pagination.next.cursor);
}, },
goToPrevPage() { goToPrevPage() {
this.startPolling(`${this.indexPath}?cursor=${this.pagination.previous.cursor}`); this.fetchPaginatedResults(this.pagination.previous.cursor);
}, },
goToPage(page) { goToPage(page) {
window.scrollTo(0, 0); window.scrollTo(0, 0);
...@@ -141,6 +189,16 @@ export default { ...@@ -141,6 +189,16 @@ export default {
isCurrentSortField(field) { isCurrentSortField(field) {
return field === this.sortField; return field === this.sortField;
}, },
getIssueUpdatePath(errorId) {
return `/${this.projectPath}/-/error_tracking/${errorId}.json`;
},
updateIssueStatus(errorId, status) {
this.updateStatus({
endpoint: this.getIssueUpdatePath(errorId),
redirectUrl: this.listPath,
status,
});
},
}, },
}; };
</script> </script>
...@@ -148,13 +206,12 @@ export default { ...@@ -148,13 +206,12 @@ export default {
<template> <template>
<div class="error-list"> <div class="error-list">
<div v-if="errorTrackingEnabled"> <div v-if="errorTrackingEnabled">
<div <div class="row flex-column flex-sm-row align-items-sm-center row-top m-0 mt-sm-2 p-0 p-sm-3">
class="d-flex flex-row justify-content-around align-items-center bg-secondary border mt-2" <div class="search-box flex-fill mr-sm-2 my-3 m-sm-0 p-3 p-sm-0">
> <div class="filtered-search-box mb-0">
<div class="filtered-search-box flex-grow-1 my-3 ml-3 mr-2">
<gl-dropdown <gl-dropdown
:text="__('Recent searches')" :text="__('Recent searches')"
class="filtered-search-history-dropdown-wrapper d-none d-md-block" class="filtered-search-history-dropdown-wrapper"
toggle-class="filtered-search-history-dropdown-toggle-button" toggle-class="filtered-search-history-dropdown-toggle-button"
:disabled="loading" :disabled="loading"
> >
...@@ -166,12 +223,12 @@ export default { ...@@ -166,12 +223,12 @@ export default {
v-for="searchQuery in recentSearches" v-for="searchQuery in recentSearches"
:key="searchQuery" :key="searchQuery"
@click="setSearchText(searchQuery)" @click="setSearchText(searchQuery)"
>{{ searchQuery }}</gl-dropdown-item >{{ searchQuery }}
> </gl-dropdown-item>
<gl-dropdown-divider /> <gl-dropdown-divider />
<gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches">{{ <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches"
__('Clear recent searches') >{{ __('Clear recent searches') }}
}}</gl-dropdown-item> </gl-dropdown-item>
</template> </template>
<div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div>
</gl-dropdown> </gl-dropdown>
...@@ -198,12 +255,13 @@ export default { ...@@ -198,12 +255,13 @@ export default {
</gl-button> </gl-button>
</div> </div>
</div> </div>
</div>
<gl-dropdown <gl-dropdown
class="sort-control"
:text="$options.sortFields[sortField]" :text="$options.sortFields[sortField]"
left left
:disabled="loading" :disabled="loading"
class="mr-3"
menu-class="sort-dropdown" menu-class="sort-dropdown"
> >
<gl-dropdown-item <gl-dropdown-item
...@@ -227,51 +285,85 @@ export default { ...@@ -227,51 +285,85 @@ export default {
<gl-loading-icon size="md" /> <gl-loading-icon size="md" />
</div> </div>
<template v-else>
<h4 class="d-block d-sm-none my-3">{{ __('Open errors') }}</h4>
<gl-table <gl-table
v-else
class="mt-3" class="mt-3"
:items="errors" :items="errors"
:fields="$options.fields" :fields="$options.fields"
:show-empty="true" :show-empty="true"
fixed fixed
stacked="sm" stacked="sm"
tbody-tr-class="table-row mb-4"
> >
<template slot="HEAD_events" slot-scope="data"> <template v-slot:head(error)>
<div class="text-md-right">{{ data.label }}</div> <div class="d-none d-sm-block">{{ __('Open errors') }}</div>
</template>
<template v-slot:head(events)="data">
<div class="text-sm-right">{{ data.label }}</div>
</template> </template>
<template slot="HEAD_users" slot-scope="data"> <template v-slot:head(users)="data">
<div class="text-md-right">{{ data.label }}</div> <div class="text-sm-right">{{ data.label }}</div>
</template> </template>
<template slot="error" slot-scope="errors">
<template v-slot:error="errors">
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)"> <gl-link class="d-flex mw-100 text-dark" :href="getDetailsLink(errors.item.id)">
<strong class="text-truncate">{{ errors.item.title.trim() }}</strong> <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
</gl-link> </gl-link>
<span class="text-secondary text-truncate"> <span class="text-secondary text-truncate mw-100">
{{ errors.item.culprit }} {{ errors.item.culprit }}
</span> </span>
</div> </div>
</template> </template>
<template slot="events" slot-scope="errors"> <template v-slot:events="errors">
<div class="text-md-right">{{ errors.item.count }}</div> <div class="text-right">{{ errors.item.count }}</div>
</template> </template>
<template slot="users" slot-scope="errors"> <template v-slot:users="errors">
<div class="text-md-right">{{ errors.item.userCount }}</div> <div class="text-right">{{ errors.item.userCount }}</div>
</template> </template>
<template slot="lastSeen" slot-scope="errors"> <template v-slot:lastSeen="errors">
<div class="d-flex align-items-center"> <div class="text-md-left text-right">
<time-ago :time="errors.item.lastSeen" class="text-secondary" /> <time-ago :time="errors.item.lastSeen" class="text-secondary" />
</div> </div>
</template> </template>
<template slot="empty"> <template v-slot:ignore="errors">
<div ref="empty"> <gl-button
ref="ignoreError"
v-gl-tooltip.hover
:title="__('Ignore')"
@click="updateIssueStatus(errors.item.id, 'ignored')"
>
<gl-icon name="eye-slash" :size="12" />
</gl-button>
</template>
<template v-slot:resolved="errors">
<gl-button
ref="resolveError"
v-gl-tooltip
:title="__('Resolve')"
@click="updateIssueStatus(errors.item.id, 'resolved')"
>
<gl-icon name="check-circle" :size="12" />
</gl-button>
</template>
<template v-slot:details="errors">
<gl-button
:href="getDetailsLink(errors.item.id)"
variant="outline-info"
class="d-block"
>
{{ __('More details') }}
</gl-button>
</template>
<template v-slot:empty>
{{ __('No errors to display.') }} {{ __('No errors to display.') }}
<gl-link class="js-try-again" @click="restartPolling"> <gl-link class="js-try-again" @click="restartPolling">
{{ __('Check again') }} {{ __('Check again') }}
</gl-link> </gl-link>
</div>
</template> </template>
</gl-table> </gl-table>
<gl-pagination <gl-pagination
...@@ -283,6 +375,7 @@ export default { ...@@ -283,6 +375,7 @@ export default {
align="center" align="center"
@input="goToPage" @input="goToPage"
/> />
</template>
</div> </div>
<div v-else-if="userCanEnableErrorTracking"> <div v-else-if="userCanEnableErrorTracking">
<gl-empty-state <gl-empty-state
... ...
......
<script> <script>
import _ from 'underscore';
import { GlTooltip } from '@gitlab/ui'; import { GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
...@@ -56,17 +57,36 @@ export default { ...@@ -56,17 +57,36 @@ export default {
collapseIcon() { collapseIcon() {
return this.isExpanded ? 'chevron-down' : 'chevron-right'; return this.isExpanded ? 'chevron-down' : 'chevron-right';
}, },
noCodeFn() { errorFnText() {
return this.errorFn ? sprintf(__('in %{errorFn} '), { errorFn: this.errorFn }) : ''; return this.errorFn
? sprintf(
__(`%{spanStart}in%{spanEnd} %{errorFn}`),
{
errorFn: `<strong>${_.escape(this.errorFn)}</strong>`,
spanStart: `<span class="text-tertiary">`,
spanEnd: `</span>`,
},
false,
)
: '';
}, },
noCodeLine() { errorPositionText() {
return this.errorLine return this.errorLine
? sprintf(__('at line %{errorLine}%{errorColumn}'), { ? sprintf(
errorLine: this.errorLine, __(`%{spanStart}at line%{spanEnd} %{errorLine}%{errorColumn}`),
errorColumn: this.errorColumn ? `:${this.errorColumn}` : '', {
}) errorLine: `<strong>${this.errorLine}</strong>`,
errorColumn: this.errorColumn ? `:<strong>${this.errorColumn}</strong>` : ``,
spanStart: `<span class="text-tertiary">`,
spanEnd: `</span>`,
},
false,
)
: ''; : '';
}, },
errorInfo() {
return `${this.errorFnText} ${this.errorPositionText}`;
},
}, },
methods: { methods: {
isHighlighted(lineNum) { isHighlighted(lineNum) {
...@@ -102,8 +122,7 @@ export default { ...@@ -102,8 +122,7 @@ export default {
<strong <strong
v-gl-tooltip v-gl-tooltip
:title="filePath" :title="filePath"
class="file-title-name d-inline-block overflow-hidden text-truncate" class="file-title-name d-inline-block overflow-hidden text-truncate limited-width"
:class="{ 'limited-width': !hasCode }"
data-container="body" data-container="body"
> >
{{ filePath }} {{ filePath }}
...@@ -113,7 +132,7 @@ export default { ...@@ -113,7 +132,7 @@ export default {
:text="filePath" :text="filePath"
css-class="btn-default btn-transparent btn-clipboard position-static" css-class="btn-default btn-transparent btn-clipboard position-static"
/> />
<span v-if="!hasCode" class="text-tertiary">{{ noCodeFn }}{{ noCodeLine }}</span> <span v-html="errorInfo"></span>
</div> </div>
</div> </div>
... ...
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import store from './store'; import store from './store';
import ErrorDetails from './components/error_details.vue'; import ErrorDetails from './components/error_details.vue';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
Vue.use(VueApollo);
export default () => { export default () => {
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: '#js-error_details', el: '#js-error_details',
apolloProvider,
components: { components: {
ErrorDetails, ErrorDetails,
}, },
store, store,
render(createElement) { render(createElement) {
const domEl = document.querySelector(this.$options.el); const domEl = document.querySelector(this.$options.el);
const { issueDetailsPath, issueStackTracePath, projectIssuesPath } = domEl.dataset; const {
issueId,
projectPath,
listPath,
issueUpdatePath,
issueDetailsPath,
issueStackTracePath,
projectIssuesPath,
} = domEl.dataset;
return createElement('error-details', { return createElement('error-details', {
props: { props: {
issueId,
projectPath,
listPath,
issueUpdatePath,
issueDetailsPath, issueDetailsPath,
issueStackTracePath, issueStackTracePath,
projectIssuesPath, projectIssuesPath,
... ...
......
...@@ -13,7 +13,13 @@ export default () => { ...@@ -13,7 +13,13 @@ export default () => {
store, store,
render(createElement) { render(createElement) {
const domEl = document.querySelector(this.$options.el); const domEl = document.querySelector(this.$options.el);
const { indexPath, enableErrorTrackingLink, illustrationPath } = domEl.dataset; const {
indexPath,
enableErrorTrackingLink,
illustrationPath,
projectPath,
listPath,
} = domEl.dataset;
let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset; let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset;
errorTrackingEnabled = parseBoolean(errorTrackingEnabled); errorTrackingEnabled = parseBoolean(errorTrackingEnabled);
...@@ -26,6 +32,8 @@ export default () => { ...@@ -26,6 +32,8 @@ export default () => {
errorTrackingEnabled, errorTrackingEnabled,
illustrationPath, illustrationPath,
userCanEnableErrorTracking, userCanEnableErrorTracking,
projectPath,
listPath,
}, },
}); });
}, },
... ...
......
query errorDetails($fullPath: ID!, $errorId: ID!) {
project(fullPath: $fullPath) {
sentryDetailedError(id: $errorId) {
id
sentryId
title
userCount
count
firstSeen
lastSeen
message
culprit
externalUrl
firstReleaseShortVersion
lastReleaseShortVersion
gitlabCommit
gitlabCommitPath
}
}
}
...@@ -4,4 +4,7 @@ export default { ...@@ -4,4 +4,7 @@ export default {
getSentryData({ endpoint, params }) { getSentryData({ endpoint, params }) {
return axios.get(endpoint, { params }); return axios.get(endpoint, { params });
}, },
updateErrorStatus(endpoint, status) {
return axios.put(endpoint, { status });
},
}; };
import service from './../services';
import * as types from './mutation_types';
import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
export function updateStatus({ commit }, { endpoint, redirectUrl, status }) {
const type =
status === 'resolved' ? types.SET_UPDATING_RESOLVE_STATUS : types.SET_UPDATING_IGNORE_STATUS;
commit(type, true);
return service
.updateErrorStatus(endpoint, status)
.then(() => visitUrl(redirectUrl))
.catch(() => createFlash(__('Failed to update issue status')))
.finally(() => commit(type, false));
}
export default () => {};
...@@ -3,4 +3,6 @@ export default () => ({ ...@@ -3,4 +3,6 @@ export default () => ({
stacktraceData: {}, stacktraceData: {},
loading: true, loading: true,
loadingStacktrace: true, loadingStacktrace: true,
updatingResolveStatus: false,
updatingIgnoreStatus: false,
}); });