diff options
Diffstat (limited to 'app/assets/javascripts')
105 files changed, 2086 insertions, 514 deletions
diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/avatar_picker.js index dcda625f587..d38e0b4abaa 100644 --- a/app/assets/javascripts/group_avatar.js +++ b/app/assets/javascripts/avatar_picker.js @@ -1,11 +1,12 @@ import $ from 'jquery'; -export default function groupAvatar() { - $('.js-choose-group-avatar-button').on('click', function onClickGroupAvatar() { +export default function initAvatarPicker() { + $('.js-choose-avatar-button').on('click', function onClickAvatar() { const form = $(this).closest('form'); - return form.find('.js-group-avatar-input').click(); + return form.find('.js-avatar-input').click(); }); - $('.js-group-avatar-input').on('change', function onChangeAvatarInput() { + + $('.js-avatar-input').on('change', function onChangeAvatarInput() { const form = $(this).closest('form'); const filename = $(this) .val() diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 90ab3a76342..206573dd444 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; @@ -92,6 +93,9 @@ export default { const { referencePath, groupId } = this.issue; return !groupId ? referencePath.split('#')[0] : null; }, + orderedLabels() { + return _.sortBy(this.issue.labels, 'title'); + }, }, methods: { isIndexLessThanlimit(index) { @@ -176,7 +180,7 @@ export default { </div> <div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap"> <button - v-for="label in issue.labels" + v-for="label in orderedLabels" v-if="showLabel(label)" :key="label.id" v-gl-tooltip diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 10577da9305..a5ed695af35 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -8,7 +8,11 @@ import boardsStore from '../stores/boards_store'; $(document) .off('created.label') - .on('created.label', (e, label) => { + .on('created.label', (e, label, addNewList) => { + if (!addNewList) { + return; + } + boardsStore.new({ title: label.title, position: boardsStore.state.lists.length - 2, diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index dd92d3c8552..2edb6723ada 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -119,7 +119,17 @@ class ListIssue { } const projectPath = this.project ? this.project.path : ''; - return Vue.http.patch(`${this.path}.json`, data); + return Vue.http.patch(`${this.path}.json`, data).then(({ body = {} } = {}) => { + /** + * Since post implementation of Scoped labels, server can reject + * same key-ed labels. To keep the UI and server Model consistent, + * we're just assigning labels that server echo's back to us when we + * PATCH the said object. + */ + if (body) { + this.labels = body.labels; + } + }); } } diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js index 7951348d8b2..4d5d6bb864b 100644 --- a/app/assets/javascripts/breakpoints.js +++ b/app/assets/javascripts/breakpoints.js @@ -14,6 +14,9 @@ const BreakpointInstance = { return breakpoint; }, + isDesktop() { + return ['lg', 'md'].includes(this.getBreakpointSize); + }, }; export default BreakpointInstance; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index c95d7608e37..df855261b3c 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -262,7 +262,7 @@ export default class Clusters { this.store.updateAppProperty(appId, 'requestReason', null); this.store.updateAppProperty(appId, 'statusReason', null); - this.service.installApplication(appId, data.params).catch(() => { + return this.service.installApplication(appId, data.params).catch(() => { this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); this.store.updateAppProperty( appId, @@ -288,10 +288,11 @@ export default class Clusters { } toggleIngressDomainHelpText(ingressPreviousState, ingressNewState) { - const helpTextHidden = ingressNewState.status !== APPLICATION_STATUS.INSTALLED; - const domainSnippetText = `${ingressNewState.externalIp}${INGRESS_DOMAIN_SUFFIX}`; + const { externalIp, status } = ingressNewState; + const helpTextHidden = status !== APPLICATION_STATUS.INSTALLED || !externalIp; + const domainSnippetText = `${externalIp}${INGRESS_DOMAIN_SUFFIX}`; - if (ingressPreviousState.status !== ingressNewState.status) { + if (ingressPreviousState.status !== status) { this.ingressDomainHelpText.classList.toggle('hide', helpTextHidden); this.ingressDomainSnippet.textContent = domainSnippetText; } diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index d4ecfa4aa93..bc666aef54b 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -71,29 +71,39 @@ export default class ImageFile { // eslint-disable-next-line class-methods-use-this initDraggable($el, padding, callback) { var dragging = false; - var $body = $('body'); - var $offsetEl = $el.parent(); - - $el.off('mousedown').on('mousedown', function() { + const $body = $('body'); + const $offsetEl = $el.parent(); + const dragStart = function() { dragging = true; $body.css('user-select', 'none'); - }); + }; + const dragStop = function() { + dragging = false; + $body.css('user-select', ''); + }; + const dragMove = function(e) { + const moveX = e.pageX || e.touches[0].pageX; + const left = moveX - ($offsetEl.offset().left + padding); + if (!dragging) return; + + callback(e, left); + }; + + $el + .off('mousedown') + .off('touchstart') + .on('mousedown', dragStart) + .on('touchstart', dragStart); $body .off('mouseup') .off('mousemove') - .on('mouseup', function() { - dragging = false; - $body.css('user-select', ''); - }) - .on('mousemove', function(e) { - var left; - if (!dragging) return; - - left = e.pageX - ($offsetEl.offset().left + padding); - - callback(e, left); - }); + .off('touchend') + .off('touchmove') + .on('mouseup', dragStop) + .on('touchend', dragStop) + .on('mousemove', dragMove) + .on('touchmove', dragMove); } prepareFrames(view) { diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 67fcdd082a2..03dea1ec0a5 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -41,6 +41,9 @@ export default class ContextualSidebar { this.toggleCollapsedSidebar(value, true); } }); + this.$page.on('transitionstart transitionend', () => { + $(document).trigger('content.resize'); + }); $(window).on('resize', () => _.debounce(this.render(), 100)); } diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js index 28ca7d97314..eac0e37bcaa 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/create_label.js @@ -14,6 +14,7 @@ export default class CreateLabelDropdown { this.$newLabelField = $('#new_label_name', this.$el); this.$newColorField = $('#new_label_color', this.$el); this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el); + this.$addList = $('.js-add-list', this.$el); this.$newLabelError = $('.js-label-error', this.$el); this.$newLabelCreateButton = $('.js-new-label-btn', this.$el); this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el); @@ -21,6 +22,8 @@ export default class CreateLabelDropdown { this.$newLabelError.hide(); this.$newLabelCreateButton.disable(); + this.addListDefault = this.$addList.is(':checked'); + this.cleanBinding(); this.addBinding(); } @@ -83,6 +86,8 @@ export default class CreateLabelDropdown { this.$newColorField.val('').trigger('change'); + this.$addList.prop('checked', this.addListDefault); + this.$colorPreview .css('background-color', '') .parent() @@ -116,9 +121,9 @@ export default class CreateLabelDropdown { this.$newLabelError.html(errors).show(); } else { + const addNewList = this.$addList.is(':checked'); this.$dropdownBack.trigger('click'); - - $(document).trigger('created.label', label); + $(document).trigger('created.label', [label, addNewList]); } }, ); diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index e8f8c09152a..5e74998579b 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -20,6 +20,7 @@ import { MAX_TREE_WIDTH, TREE_HIDE_STATS_WIDTH, MR_TREE_SHOW_KEY, + CENTERED_LIMITED_CONTAINER_CLASSES, } from '../constants'; export default { @@ -114,6 +115,9 @@ export default { hideFileStats() { return this.treeWidth <= TREE_HIDE_STATS_WIDTH; }, + isLimitedContainer() { + return !this.showTreeList && !this.isParallelView; + }, }, watch: { diffViewType() { @@ -148,6 +152,7 @@ export default { this.adjustView(); eventHub.$once('fetchedNotesData', this.setDiscussions); eventHub.$once('fetchDiffData', this.fetchData); + this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; }, beforeDestroy() { eventHub.$off('fetchDiffData', this.fetchData); @@ -202,8 +207,6 @@ export default { adjustView() { if (this.shouldShow) { this.$nextTick(() => { - window.mrTabs.resetViewContainer(); - window.mrTabs.expandViewContainer(this.showTreeList); this.setEventListeners(); }); } else { @@ -256,6 +259,7 @@ export default { :merge-request-diffs="mergeRequestDiffs" :merge-request-diff="mergeRequestDiff" :target-branch="targetBranch" + :is-limited-container="isLimitedContainer" /> <hidden-files-warning @@ -285,7 +289,12 @@ export default { /> <tree-list :hide-file-stats="hideFileStats" /> </div> - <div class="diff-files-holder"> + <div + class="diff-files-holder" + :class="{ + [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, + }" + > <commit-widget v-if="commit" :commit="commit" /> <template v-if="renderDiffFiles"> <diff-file diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index fe49dfff10b..363ebad1594 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -7,6 +7,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import CompareVersionsDropdown from './compare_versions_dropdown.vue'; import SettingsDropdown from './settings_dropdown.vue'; import DiffStats from './diff_stats.vue'; +import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants'; export default { components: { @@ -35,6 +36,11 @@ export default { required: false, default: null, }, + isLimitedContainer: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']), @@ -62,6 +68,9 @@ export default { return this.mergeRequestDiff.base_version_path; }, }, + created() { + this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; + }, mounted() { polyfillSticky(this.$el); }, @@ -77,8 +86,13 @@ export default { </script> <template> - <div class="mr-version-controls" :class="{ 'is-fileTreeOpen': showTreeList }"> - <div class="mr-version-menus-container content-block"> + <div class="mr-version-controls border-top border-bottom"> + <div + class="mr-version-menus-container content-block" + :class="{ + [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, + }" + > <button v-gl-tooltip.hover type="button" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index fda7b7c5fd9..32e5fa5bf8b 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; -import { polyfillSticky } from '~/lib/utils/sticky'; +import { polyfillSticky, stickyMonitor } from '~/lib/utils/sticky'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -11,7 +11,7 @@ import { __, s__, sprintf } from '~/locale'; import { diffViewerModes } from '~/ide/constants'; import EditButton from './edit_button.vue'; import DiffStats from './diff_stats.vue'; -import { scrollToElement } from '~/lib/utils/common_utils'; +import { scrollToElement, contentTop } from '~/lib/utils/common_utils'; export default { components: { @@ -137,9 +137,20 @@ export default { isModeChanged() { return this.diffFile.viewer.name === diffViewerModes.mode_changed; }, + showExpandDiffToFullFileEnabled() { + return gon.features.expandDiffFullFile && !this.diffFile.is_fully_expanded; + }, + expandDiffToFullFileTitle() { + if (this.diffFile.isShowingFullFile) { + return s__('MRDiff|Show changes only'); + } + return s__('MRDiff|Show full file'); + }, }, mounted() { polyfillSticky(this.$refs.header); + const fileHeaderHeight = this.$refs.header.clientHeight; + stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false); }, methods: { ...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']), @@ -243,70 +254,70 @@ export default { class="file-actions d-none d-sm-block" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> - <template v-if="diffFile.blob && diffFile.blob.readable_text"> - <button - :disabled="!diffHasDiscussions(diffFile)" - :class="{ active: hasExpandedDiscussions }" - :title="s__('MergeRequests|Toggle comments for this file')" - class="js-btn-vue-toggle-comments btn" - type="button" - @click="handleToggleDiscussions" - > - <icon name="comment" /> - </button> - - <edit-button - v-if="!diffFile.deleted_file" - :can-current-user-fork="canCurrentUserFork" - :edit-path="diffFile.edit_path" - :can-modify-blob="diffFile.can_modify_blob" - @showForkMessage="showForkMessage" - /> - </template> + <div class="btn-group" role="group"> + <template v-if="diffFile.blob && diffFile.blob.readable_text"> + <button + :disabled="!diffHasDiscussions(diffFile)" + :class="{ active: hasExpandedDiscussions }" + :title="s__('MergeRequests|Toggle comments for this file')" + class="js-btn-vue-toggle-comments btn" + type="button" + @click="handleToggleDiscussions" + > + <icon name="comment" /> + </button> - <a - v-if="diffFile.replaced_view_path" - :href="diffFile.replaced_view_path" - class="btn view-file js-view-replaced-file" - v-html="viewReplacedFileButtonText" - > - </a> - <gl-tooltip :target="() => $refs.viewButton" placement="bottom"> - <span v-html="viewFileButtonText"></span> - </gl-tooltip> - <gl-button - ref="viewButton" - :href="diffFile.view_path" - target="blank" - class="view-file js-view-file-button" - > - <icon name="external-link" /> - </gl-button> - <gl-button - v-if="!diffFile.is_fully_expanded" - class="expand-file js-expand-file" - @click="toggleFullDiff(diffFile.file_path)" - > - <template v-if="diffFile.isShowingFullFile"> - {{ s__('MRDiff|Show changes only') }} - </template> - <template v-else> - {{ s__('MRDiff|Show full file') }} + <edit-button + v-if="!diffFile.deleted_file" + :can-current-user-fork="canCurrentUserFork" + :edit-path="diffFile.edit_path" + :can-modify-blob="diffFile.can_modify_blob" + @showForkMessage="showForkMessage" + /> </template> - <gl-loading-icon v-if="diffFile.isLoadingFullFile" inline /> - </gl-button> - <a - v-if="diffFile.external_url" - v-gl-tooltip.hover - :href="diffFile.external_url" - :title="`View on ${diffFile.formatted_external_url}`" - target="_blank" - rel="noopener noreferrer" - class="btn btn-file-option js-external-url" - > - <icon name="external-link" /> - </a> + <a + v-if="diffFile.replaced_view_path" + :href="diffFile.replaced_view_path" + class="btn view-file js-view-replaced-file" + v-html="viewReplacedFileButtonText" + > + </a> + <gl-button + v-if="!diffFile.is_fully_expanded" + ref="expandDiffToFullFileButton" + v-gl-tooltip.hover + :title="expandDiffToFullFileTitle" + class="expand-file js-expand-file" + @click="toggleFullDiff(diffFile.file_path)" + > + <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline /> + <icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" /> + <icon v-else name="doc-expand" /> + </gl-button> + <gl-button + ref="viewButton" + v-gl-tooltip.hover + :href="diffFile.view_path" + target="blank" + class="view-file js-view-file-button" + :title="viewFileButtonText" + > + <icon name="external-link" /> + </gl-button> + + <a + v-if="diffFile.external_url" + v-gl-tooltip.hover + :href="diffFile.external_url" + :title="`View on ${diffFile.formatted_external_url}`" + target="_blank" + rel="noopener noreferrer" + class="btn btn-file-option js-external-url" + > + <icon name="external-link" /> + </a> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 6f380fe6ece..5dabe224baa 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -47,3 +47,6 @@ export const OLD_LINE_KEY = 'old_line'; export const NEW_LINE_KEY = 'new_line'; export const TYPE_KEY = 'type'; export const LEFT_LINE_KEY = 'left'; + +export const CENTERED_LIMITED_CONTAINER_CLASSES = + 'container-limited limit-container-width mx-auto px-3'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 0c2e87521d9..efd03ec952f 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,6 +1,7 @@ import _ from 'underscore'; import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import { visitUrl } from '../lib/utils/url_utility'; import Flash from '../flash'; import FilteredSearchContainer from './container'; @@ -36,10 +37,11 @@ export default class FilteredSearchManager { this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = filteredSearchTokenKeys; this.stateFiltersSelector = stateFiltersSelector; - this.recentsStorageKeyNames = { - issues: 'issue-recent-searches', - merge_requests: 'merge-request-recent-searches', - }; + + const { multipleAssignees } = this.filteredSearchInput.dataset; + if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) { + this.filteredSearchTokenKeys.enableMultipleAssignees(); + } this.recentSearchesStore = new RecentSearchesStore({ isLocalStorageAvailable: RecentSearchesService.isAvailable(), @@ -51,7 +53,7 @@ export default class FilteredSearchManager { const fullPath = this.searchHistoryDropdownElement ? this.searchHistoryDropdownElement.dataset.fullPath : 'project'; - const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`; + const recentSearchesKey = `${fullPath}-${recentSearchesStorageKeys[this.page]}`; this.recentSearchesService = new RecentSearchesService(recentSearchesKey); } diff --git a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js new file mode 100644 index 00000000000..7e9b809e9b2 --- /dev/null +++ b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js @@ -0,0 +1,4 @@ +export default { + issues: 'issue-recent-searches', + merge_requests: 'merge-request-recent-searches', +}; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 42d14b65b3a..92c3bcb5012 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,6 +1,9 @@ <script> /* eslint-disable vue/require-default-prop */ -import Identicon from '../../vue_shared/components/identicon.vue'; +import _ from 'underscore'; +import Identicon from '~/vue_shared/components/identicon.vue'; +import highlight from '~/lib/utils/highlight'; +import { truncateNamespace } from '~/lib/utils/text_utility'; export default { components: { @@ -36,43 +39,13 @@ export default { }, computed: { hasAvatar() { - return this.avatarUrl !== null; + return _.isString(this.avatarUrl) && !_.isEmpty(this.avatarUrl); }, - highlightedItemName() { - if (this.matcher) { - const matcherRegEx = new RegExp(this.matcher, 'gi'); - const matches = this.itemName.match(matcherRegEx); - - if (matches && matches.length > 0) { - return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`); - } - } - return this.itemName; - }, - /** - * Smartly truncates item namespace by doing two things; - * 1. Only include Group names in path by removing item name - * 2. Only include first and last group names in the path - * when namespace has more than 2 groups present - * - * First part (removal of item name from namespace) can be - * done from backend but doing so involves migration of - * existing item namespaces which is not wise thing to do. - */ truncatedNamespace() { - if (!this.namespace) { - return null; - } - const namespaceArr = this.namespace.split(' / '); - - namespaceArr.splice(-1, 1); - let namespace = namespaceArr.join(' / '); - - if (namespaceArr.length > 2) { - namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; - } - - return namespace; + return truncateNamespace(this.namespace); + }, + highlightedItemName() { + return highlight(this.itemName, this.matcher); }, }, }; @@ -92,8 +65,16 @@ export default { /> </div> <div class="frequent-items-item-metadata-container"> - <div :title="itemName" class="frequent-items-item-title" v-html="highlightedItemName"></div> - <div v-if="truncatedNamespace" :title="namespace" class="frequent-items-item-namespace"> + <div + :title="itemName" + class="frequent-items-item-title js-frequent-items-item-title" + v-html="highlightedItemName" + ></div> + <div + v-if="namespace" + :title="namespace" + class="frequent-items-item-namespace js-frequent-items-item-namespace" + > {{ truncatedNamespace }} </div> </div> diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 27d8669b256..1c6b18c0e03 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -561,10 +561,9 @@ GitLabDropdown = (function() { !$target.data('isLink') ) { e.stopPropagation(); - return false; - } else { - return true; } + + return true; } }; diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index d5d5954ce6a..c4fd719c8d0 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -15,7 +15,7 @@ export default class GlFieldErrors { initValidators() { // register selectors here as needed - const validateSelectors = [':text', ':password', '[type=email]'] + const validateSelectors = [':text', ':password', '[type=email]', '[type=url]', '[type=number]'] .map(selector => `input${selector}`) .join(','); diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js new file mode 100644 index 00000000000..2c2a04d5b5e --- /dev/null +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -0,0 +1,17 @@ +/* eslint-disable import/prefer-default-export */ + +export const makeDataSeries = (queryResults, defaultConfig) => + queryResults.reduce((acc, result) => { + const data = result.values.filter(([, value]) => !Number.isNaN(value)); + if (!data.length) { + return acc; + } + const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_'); + const name = result.metric[relevantMetric]; + const series = { data }; + if (name) { + series.name = `${defaultConfig.name}: ${name}`; + } + + return acc.concat({ ...defaultConfig, ...series }); + }, []); diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index d360dc42cd3..1824a0f6147 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -1,17 +1,23 @@ <script> import _ from 'underscore'; -import { mapActions, mapState, mapGetters } from 'vuex'; +import { mapActions, mapState, mapGetters, createNamespacedHelpers } from 'vuex'; import { sprintf, __ } from '~/locale'; -import * as consts from '../../stores/modules/commit/constants'; +import consts from '../../stores/modules/commit/constants'; import RadioGroup from './radio_group.vue'; +const { mapState: mapCommitState, mapGetters: mapCommitGetters } = createNamespacedHelpers( + 'commit', +); + export default { components: { RadioGroup, }, computed: { ...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']), - ...mapGetters(['currentProject', 'currentBranch']), + ...mapCommitState(['commitAction', 'shouldCreateMR', 'shouldDisableNewMrOption']), + ...mapGetters(['currentProject', 'currentBranch', 'currentMergeRequest']), + ...mapCommitGetters(['shouldDisableNewMrOption']), commitToCurrentBranchText() { return sprintf( __('Commit to %{branchName} branch'), @@ -32,7 +38,7 @@ export default { this.updateSelectedCommitAction(); }, methods: { - ...mapActions('commit', ['updateCommitAction']), + ...mapActions('commit', ['updateCommitAction', 'toggleShouldCreateMR']), updateSelectedCommitAction() { if (this.currentBranch && !this.currentBranch.can_push) { this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH); @@ -43,7 +49,6 @@ export default { }, commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, - commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR, currentBranchPermissionsTooltip: __( "This option is disabled as you don't have write permissions for the current branch", ), @@ -64,13 +69,17 @@ export default { :label="__('Create a new branch')" :show-input="true" /> - <radio-group - v-if="currentProject.merge_requests_enabled" - :value="$options.commitToNewBranchMR" - :label="__('Create a new branch and merge request')" - :title="__('This option is disabled while you still have unstaged changes')" - :show-input="true" - :disabled="disableMergeRequestRadio" - /> + <hr class="my-2" /> + <label class="mb-0"> + <input + :checked="shouldCreateMR" + :disabled="shouldDisableNewMrOption" + type="checkbox" + @change="toggleShouldCreateMR" + /> + <span class="prepend-left-10" :class="{ 'text-secondary': shouldDisableNewMrOption }"> + {{ __('Start a new merge request') }} + </span> + </label> </div> </template> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index d6673cf0421..80a6ab9598a 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -23,7 +23,7 @@ export default { type: Object, required: true, }, - mouseOver: { + dropdownOpen: { type: Boolean, required: true, }, @@ -92,8 +92,9 @@ export default { <new-dropdown :type="file.type" :path="file.path" - :mouse-over="mouseOver" + :is-open="dropdownOpen" class="prepend-left-8" + v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 593a9162a06..27d24fa5e1d 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -21,38 +21,29 @@ export default { required: false, default: '', }, - mouseOver: { + isOpen: { type: Boolean, - required: true, + required: false, + default: false, }, }, - data() { - return { - dropdownOpen: false, - }; - }, watch: { - dropdownOpen() { + isOpen() { this.$nextTick(() => { this.$refs.dropdownMenu.scrollIntoView({ block: 'nearest', }); }); }, - mouseOver() { - if (!this.mouseOver) { - this.dropdownOpen = false; - } - }, }, methods: { ...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']), createNewItem(type) { this.openNewEntryModal({ type, path: this.path }); - this.dropdownOpen = false; + this.$emit('toggle', false); }, openDropdown() { - this.dropdownOpen = !this.dropdownOpen; + this.$emit('toggle', !this.isOpen); }, }, modalTypes, @@ -63,7 +54,7 @@ export default { <div class="ide-new-btn"> <div :class="{ - show: dropdownOpen, + show: isOpen, }" class="dropdown d-flex" > diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 8dd88f187d4..99f1d4a573d 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -5,7 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; -import * as consts from '../stores/modules/commit/constants'; +import consts from '../stores/modules/commit/constants'; import { activityBarViews, stageKeys } from '../constants'; export default { diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 362ced248a1..1273e375859 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -4,10 +4,11 @@ import service from '../../services'; import * as types from '../mutation_types'; import { activityBarViews } from '../../constants'; -export const getMergeRequestsForBranch = ({ commit }, { projectId, branchId } = {}) => +export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branchId } = {}) => service .getProjectMergeRequests(`${projectId}`, { source_branch: branchId, + source_project_id: state.projects[projectId].id, order_by: 'created_at', per_page: 1, }) diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 06ed5c0b572..4b10d148ebf 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -147,6 +147,11 @@ export const openBranch = ({ dispatch, state }, { projectId, branchId, basePath if (treeEntry) { dispatch('handleTreeEntryAction', treeEntry); + } else { + dispatch('createTempEntry', { + name: path, + type: 'blob', + }); } } }) diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 8ad85074d6b..490658a4543 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -25,7 +25,10 @@ export const projectsWithTrees = state => }); export const currentMergeRequest = state => { - if (state.projects[state.currentProjectId]) { + if ( + state.projects[state.currentProjectId] && + state.projects[state.currentProjectId].mergeRequests + ) { return state.projects[state.currentProjectId].mergeRequests[state.currentMergeRequestId]; } return null; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 24c2f71ae2b..c2760eb1554 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -6,7 +6,7 @@ import { createCommitPayload, createNewMergeRequestUrl } from '../../utils'; import router from '../../../ide_router'; import service from '../../../services'; import * as types from './mutation_types'; -import * as consts from './constants'; +import consts from './constants'; import { activityBarViews } from '../../../constants'; import eventHub from '../../../eventhub'; @@ -18,16 +18,23 @@ export const discardDraft = ({ commit }) => { commit(types.UPDATE_COMMIT_MESSAGE, ''); }; -export const updateCommitAction = ({ commit }, commitAction) => { - commit(types.UPDATE_COMMIT_ACTION, commitAction); +export const updateCommitAction = ({ commit, rootGetters }, commitAction) => { + commit(types.UPDATE_COMMIT_ACTION, { + commitAction, + currentMergeRequest: rootGetters.currentMergeRequest, + }); +}; + +export const toggleShouldCreateMR = ({ commit }) => { + commit(types.TOGGLE_SHOULD_CREATE_MR); }; export const updateBranchName = ({ commit }, branchName) => { commit(types.UPDATE_NEW_BRANCH_NAME, branchName); }; -export const setLastCommitMessage = ({ rootState, commit }, data) => { - const currentProject = rootState.projects[rootState.currentProjectId]; +export const setLastCommitMessage = ({ commit, rootGetters }, data) => { + const { currentProject } = rootGetters; const commitStats = data.stats ? sprintf(__('with %{additions} additions, %{deletions} deletions.'), { additions: data.stats.additions, @@ -48,8 +55,8 @@ export const setLastCommitMessage = ({ rootState, commit }, data) => { commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true }); }; -export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data }) => { - const selectedProject = rootState.projects[rootState.currentProjectId]; +export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetters }, { data }) => { + const selectedProject = rootGetters.currentProject; const lastCommit = { commit_path: `${selectedProject.web_url}/commit/${data.id}`, commit: { @@ -135,14 +142,15 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo branch: getters.branchName, }) .then(() => { - if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) { + if (state.shouldCreateMR) { + const { currentProject } = rootGetters; + const targetBranch = getters.isCreatingNewBranch + ? rootState.currentBranchId + : currentProject.default_branch; + dispatch( 'redirectToUrl', - createNewMergeRequestUrl( - rootState.projects[rootState.currentProjectId].web_url, - getters.branchName, - rootState.currentBranchId, - ), + createNewMergeRequestUrl(currentProject.web_url, getters.branchName, targetBranch), { root: true }, ); } diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js index 230b0a3d9b5..c6c3701effe 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/constants.js +++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js @@ -1,3 +1,7 @@ -export const COMMIT_TO_CURRENT_BRANCH = '1'; -export const COMMIT_TO_NEW_BRANCH = '2'; -export const COMMIT_TO_NEW_BRANCH_MR = '3'; +const COMMIT_TO_CURRENT_BRANCH = '1'; +const COMMIT_TO_NEW_BRANCH = '2'; + +export default { + COMMIT_TO_CURRENT_BRANCH, + COMMIT_TO_NEW_BRANCH, +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index bbe40b2ec2f..6aa5d22a4ea 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -1,5 +1,5 @@ import { sprintf, n__, __ } from '../../../../locale'; -import * as consts from './constants'; +import consts from './constants'; const BRANCH_SUFFIX_COUNT = 5; const createTranslatedTextForFiles = (files, text) => { @@ -20,10 +20,7 @@ export const placeholderBranchName = (state, _, rootState) => )}`; export const branchName = (state, getters, rootState) => { - if ( - state.commitAction === consts.COMMIT_TO_NEW_BRANCH || - state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR - ) { + if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { if (state.newBranchName === '') { return getters.placeholderBranchName; } @@ -49,5 +46,10 @@ export const preBuiltCommitMessage = (state, _, rootState) => { .join('\n'); }; +export const isCreatingNewBranch = state => state.commitAction === consts.COMMIT_TO_NEW_BRANCH; + +export const shouldDisableNewMrOption = (state, _getters, _rootState, rootGetters) => + rootGetters.currentMergeRequest && state.commitAction === consts.COMMIT_TO_CURRENT_BRANCH; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js index 9221f054e9f..7ad8f3570b7 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js +++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js @@ -2,3 +2,4 @@ export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE'; export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION'; export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME'; export const UPDATE_LOADING = 'UPDATE_LOADING'; +export const TOGGLE_SHOULD_CREATE_MR = 'TOGGLE_SHOULD_CREATE_MR'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js index 797357e3df9..be0f894c059 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js @@ -1,4 +1,5 @@ import * as types from './mutation_types'; +import consts from './constants'; export default { [types.UPDATE_COMMIT_MESSAGE](state, commitMessage) { @@ -6,9 +7,13 @@ export default { commitMessage, }); }, - [types.UPDATE_COMMIT_ACTION](state, commitAction) { + [types.UPDATE_COMMIT_ACTION](state, { commitAction, currentMergeRequest }) { Object.assign(state, { commitAction, + shouldCreateMR: + commitAction === consts.COMMIT_TO_CURRENT_BRANCH && currentMergeRequest + ? false + : state.shouldCreateMR, }); }, [types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) { @@ -21,4 +26,9 @@ export default { submitCommitLoading, }); }, + [types.TOGGLE_SHOULD_CREATE_MR](state) { + Object.assign(state, { + shouldCreateMR: !state.shouldCreateMR, + }); + }, }; diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js index 8dae50961b0..5c0e6a41ca1 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/state.js +++ b/app/assets/javascripts/ide/stores/modules/commit/state.js @@ -3,4 +3,5 @@ export default () => ({ commitAction: '1', newBranchName: '', submitCommitLoading: false, + shouldCreateMR: false, }); diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index eac7441ee54..359943b4ab7 100644 --- a/app/assets/javascripts/ide/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -1,5 +1,5 @@ import * as types from '../mutation_types'; -import { sortTree } from '../utils'; +import { sortTree, mergeTrees } from '../utils'; export default { [types.TOGGLE_TREE_OPEN](state, path) { @@ -23,9 +23,15 @@ export default { }); }, [types.SET_DIRECTORY_DATA](state, { data, treePath }) { - Object.assign(state.trees[treePath], { - tree: data, - }); + const selectedTree = state.trees[treePath]; + + // If we opened files while loading the tree, we need to merge them + // Otherwise, simply overwrite the tree + const tree = !selectedTree.tree.length + ? data + : selectedTree.loading && mergeTrees(selectedTree.tree, data); + + Object.assign(selectedTree, { tree }); }, [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { Object.assign(tree, { diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 0b2a18e9c8a..3ab8f3f11be 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -170,3 +170,31 @@ export const filePathMatches = (filePath, path) => filePath.indexOf(`${path}/`) export const getChangesCountForFiles = (files, path) => files.filter(f => filePathMatches(f.path, path)).length; + +export const mergeTrees = (fromTree, toTree) => { + if (!fromTree || !fromTree.length) { + return toTree; + } + + const recurseTree = (n, t) => { + if (!n) { + return t; + } + const existingTreeNode = t.find(el => el.path === n.path); + + if (existingTreeNode && n.tree.length > 0) { + existingTreeNode.opened = true; + recurseTree(n.tree[0], existingTreeNode.tree); + } else if (!existingTreeNode) { + const sorted = sortTree(t.concat(n)); + t.splice(0, t.length + 1, ...sorted); + } + return t; + }; + + for (let i = 0, l = fromTree.length; i < l; i += 1) { + recurseTree(fromTree[i], toTree); + } + + return toTree; +}; diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index b3508f36cf9..cd1afb6ba83 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -15,7 +15,6 @@ export default class Issue { Issue.$btnNewBranch = $('#new-branch'); Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); - Issue.initMergeRequests(); if (document.querySelector('#related-branches')) { Issue.initRelatedBranches(); } @@ -143,19 +142,6 @@ export default class Issue { } } - static initMergeRequests() { - var $container; - $container = $('#merge-requests'); - return axios - .get($container.data('url')) - .then(({ data }) => { - if ('html' in data) { - $container.html(data.html); - } - }) - .catch(() => flash('Failed to load referenced merge requests')); - } - static initRelatedBranches() { var $container; $container = $('#related-branches'); diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index d08e8ba0c4b..529b6386221 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,12 +1,9 @@ import Vue from 'vue'; -import sanitize from 'sanitize-html'; import issuableApp from './components/app.vue'; +import { parseIssuableData } from './utils/parse_data'; import '../vue_shared/vue_resource_interceptor'; export default function initIssueableApp() { - const initialDataEl = document.getElementById('js-issuable-app-initial-data'); - const props = JSON.parse(sanitize(initialDataEl.textContent).replace(/"/g, '"')); - return new Vue({ el: document.getElementById('js-issuable-app'), components: { @@ -14,7 +11,7 @@ export default function initIssueableApp() { }, render(createElement) { return createElement('issuable-app', { - props, + props: parseIssuableData(), }); }, }); diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js new file mode 100644 index 00000000000..05e384adad3 --- /dev/null +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -0,0 +1,15 @@ +import sanitize from 'sanitize-html'; + +export const parseIssuableData = () => { + try { + const initialDataEl = document.getElementById('js-issuable-app-initial-data'); + + return JSON.parse(sanitize(initialDataEl.textContent).replace(/"/g, '"')); + } catch (e) { + console.error(e); // eslint-disable-line no-console + + return {}; + } +}; + +export default {}; diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue index 668fcf3d673..04f910b6b80 100644 --- a/app/assets/javascripts/jobs/components/empty_state.vue +++ b/app/assets/javascripts/jobs/components/empty_state.vue @@ -49,7 +49,7 @@ export default { <div class="text-content"> <h4 class="js-job-empty-state-title text-center">{{ title }}</h4> - <p v-if="content" class="js-job-empty-state-content">{{ content }}</p> + <p v-if="content" class="js-job-empty-state-content text-center">{{ content }}</p> <div v-if="action" class="text-center"> <gl-link diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index dbadd224251..0670e2b06b9 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -15,6 +15,7 @@ import ErasedBlock from './erased_block.vue'; import Log from './job_log.vue'; import LogTopBar from './job_log_controllers.vue'; import StuckBlock from './stuck_block.vue'; +import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue'; import Sidebar from './sidebar.vue'; import { sprintf } from '~/locale'; import delayedJobMixin from '../mixins/delayed_job_mixin'; @@ -32,6 +33,7 @@ export default { Log, LogTopBar, StuckBlock, + UnmetPrerequisitesBlock, Sidebar, GlLoadingIcon, SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'), @@ -48,6 +50,11 @@ export default { required: false, default: null, }, + deploymentHelpUrl: { + type: String, + required: false, + default: null, + }, endpoint: { type: String, required: true, @@ -82,6 +89,7 @@ export default { ]), ...mapGetters([ 'headerTime', + 'hasUnmetPrerequisitesFailure', 'shouldRenderCalloutMessage', 'shouldRenderTriggeredLabel', 'hasEnvironment', @@ -210,7 +218,10 @@ export default { /> </div> - <callout v-if="shouldRenderCalloutMessage" :message="job.callout_message" /> + <callout + v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure" + :message="job.callout_message" + /> </header> <!-- EO Header Section --> @@ -223,6 +234,12 @@ export default { :runners-path="runnerSettingsUrl" /> + <unmet-prerequisites-block + v-if="hasUnmetPrerequisitesFailure" + class="js-job-failed" + :help-path="deploymentHelpUrl" + /> + <shared-runner v-if="shouldRenderSharedRunnerLimitWarning" class="js-shared-runner-limit" diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue new file mode 100644 index 00000000000..25a8da84873 --- /dev/null +++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue @@ -0,0 +1,30 @@ +<script> +import { GlLink } from '@gitlab/ui'; +/** + * Renders Unmet Prerequisites block for job's view. + */ +export default { + components: { + GlLink, + }, + props: { + helpPath: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="bs-callout bs-callout-danger"> + <p class="js-failed-unmet-prerequisites append-bottom-0"> + {{ + s__(`Job|This job failed because the necessary resources were not successfully created.`) + }} + + <gl-link :href="helpPath" class="js-help-path"> + <strong> {{ __('More information') }} </strong> + </gl-link> + </p> + </div> +</template> diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index a32e945627c..25132449458 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -12,6 +12,7 @@ export default () => { render(createElement) { return createElement('job-app', { props: { + deploymentHelpUrl: element.dataset.deploymentHelpUrl, runnerHelpUrl: element.dataset.runnerHelpUrl, runnerSettingsUrl: element.dataset.runnerSettingsUrl, endpoint: element.dataset.endpoint, diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 73c1cbc3a99..406b1a2e375 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -3,6 +3,9 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at); +export const hasUnmetPrerequisitesFailure = state => + state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites'; + export const shouldRenderCalloutMessage = state => !_.isEmpty(state.job.status) && !_.isEmpty(state.job.callout_message); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index cca4927c115..7d21a216443 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -11,6 +11,7 @@ import CreateLabelDropdown from './create_label'; import flash from './flash'; import ModalStore from './boards/stores/modal_store'; import boardsStore from './boards/stores/boards_store'; +import { isEE } from '~/lib/utils/common_utils'; export default class LabelsSelect { constructor(els, options = {}) { @@ -86,8 +87,9 @@ export default class LabelsSelect { return this.value; }) .get(); + const scopedLabels = $dropdown.data('scopedLabels'); + const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink'); const { handleClick } = options; - $sidebarLabelTooltip.tooltip(); if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) { @@ -132,8 +134,49 @@ export default class LabelsSelect { template = LabelsSelect.getLabelTemplate({ labels: data.labels, issueUpdateURL, + enableScopedLabels: scopedLabels, + scopedLabelsDocumentationLink, }); labelCount = data.labels.length; + + // EE Specific + if (isEE) { + /** + * For Scoped labels, the last label selected with the + * same key will be applied to the current issueable. + * + * If these are the labels - priority::1, priority::2; and if + * we apply them in the same order, only priority::2 will stick + * with the issuable. + * + * In the current dropdown implementation, we keep track of all + * the labels selected via a hidden DOM element. Since a User + * can select priority::1 and priority::2 at the same time, the + * DOM will have 2 hidden input and the dropdown will show both + * the items selected but in reality server only applied + * priority::2. + * + * We find all the labels then find all the labels server accepted + * and then remove the excess ones. + */ + const toRemoveIds = Array.from( + $form.find(`input[type="hidden"][name="${fieldName}"]`), + ) + .map(el => el.value) + .map(Number); + + data.labels.forEach(label => { + const index = toRemoveIds.indexOf(label.id); + toRemoveIds.splice(index, 1); + }); + + toRemoveIds.forEach(id => { + $form + .find(`input[type="hidden"][name="${fieldName}"][value="${id}"]`) + .last() + .remove(); + }); + } } else { template = '<span class="no-value">None</span>'; } @@ -358,6 +401,7 @@ export default class LabelsSelect { } else { if (!$dropdown.hasClass('js-filter-bulk-update')) { saveLabelData(); + $dropdown.data('glDropdown').clearMenu(); } } } @@ -471,19 +515,62 @@ export default class LabelsSelect { // so best approach is to use traditional way of // concatenation // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays - const tpl = _.template( + + const labelTemplate = _.template( [ - '<% _.each(labels, function(label){ %>', '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">', - '<span class="badge label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">', + '<span class="badge label has-tooltip color-label" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">', '<%- label.title %>', '</span>', '</a>', + ].join(''), + ); + + const infoIconTemplate = _.template( + [ + '<a href="<%= scopedLabelsDocumentationLink %>" class="label scoped-label" target="_blank" rel="noopener">', + '<i class="fa fa-question-circle" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;"></i>', + '</a>', + ].join(''), + ); + + const tooltipTitleTemplate = _.template( + [ + '<% if (isScopedLabel(label) && enableScopedLabels) { %>', + "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>", + '<br />', + '<%= escapeStr(label.description) %>', + '<% } else { %>', + '<%= escapeStr(label.description) %>', + '<% } %>', + ].join(''), + ); + + const isScopedLabel = label => label.title.indexOf('::') !== -1; + + const tpl = _.template( + [ + '<% _.each(labels, function(label){ %>', + '<% if (isScopedLabel(label) && enableScopedLabels) { %>', + '<span class="d-inline-block position-relative scoped-label-wrapper">', + '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', + '<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>', + '</span>', + '<% } else { %>', + '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>', + '<% } %>', '<% }); %>', ].join(''), ); - return tpl(tplData); + return tpl({ + ...tplData, + labelTemplate, + infoIconTemplate, + tooltipTitleTemplate, + isScopedLabel, + escapeStr: _.escape, + }); } bindEvents() { diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 59930f8d4a3..2906604da57 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -7,7 +7,7 @@ import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase } from './text_utility'; import { isObject } from './type_utility'; -import BreakpointInstance from '../../breakpoints'; +import breakpointInstance from '../../breakpoints'; export const getPagePath = (index = 0) => { const page = $('body').attr('data-page') || ''; @@ -198,11 +198,10 @@ export const contentTop = () => { const mrTabsHeight = $('.merge-request-tabs').outerHeight() || 0; const headerHeight = $('.navbar-gitlab').outerHeight() || 0; const diffFilesChanged = $('.js-diff-files-changed').outerHeight() || 0; - const mdScreenOrBigger = ['lg', 'md'].includes(BreakpointInstance.getBreakpointSize()); + const isDesktop = breakpointInstance.isDesktop(); const diffFileTitleBar = - (mdScreenOrBigger && $('.diff-file .file-title-flex-parent:visible').outerHeight()) || 0; - const compareVersionsHeaderHeight = - (mdScreenOrBigger && $('.mr-version-controls').outerHeight()) || 0; + (isDesktop && $('.diff-file .file-title-flex-parent:visible').outerHeight()) || 0; + const compareVersionsHeaderHeight = (isDesktop && $('.mr-version-controls').outerHeight()) || 0; return ( perfBar + diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js new file mode 100644 index 00000000000..4f7eff2cca1 --- /dev/null +++ b/app/assets/javascripts/lib/utils/highlight.js @@ -0,0 +1,44 @@ +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import _ from 'underscore'; +import sanitize from 'sanitize-html'; + +/** + * Wraps substring matches with HTML `<span>` elements. + * Inputs are sanitized before highlighting, so this + * filter is safe to use with `v-html` (as long as `matchPrefix` + * and `matchSuffix` are not being dynamically generated). + * + * Note that this function can't be used inside `v-html` as a filter + * (Vue filters cannot be used inside `v-html`). + * + * @param {String} string The string to highlight + * @param {String} match The substring match to highlight in the string + * @param {String} matchPrefix The string to insert at the beginning of a match + * @param {String} matchSuffix The string to insert at the end of a match + */ +export default function highlight(string, match = '', matchPrefix = '<b>', matchSuffix = '</b>') { + if (_.isUndefined(string) || _.isNull(string)) { + return ''; + } + + if (_.isUndefined(match) || _.isNull(match) || match === '') { + return string; + } + + const sanitizedValue = sanitize(string.toString(), { allowedTags: [] }); + + // occurences is an array of character indices that should be + // highlighted in the original string, i.e. [3, 4, 5, 7] + const occurences = fuzzaldrinPlus.match(sanitizedValue, match.toString()); + + return sanitizedValue + .split('') + .map((character, i) => { + if (_.contains(occurences, i)) { + return `${matchPrefix}${character}${matchSuffix}`; + } + + return character; + }) + .join(''); +} diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index c49b1bb5a2f..1b7f8732c65 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,3 +1,5 @@ +import _ from 'underscore'; + /** * Adds a , to a string composed by numbers, at every 3 chars. * @@ -160,3 +162,33 @@ export const splitCamelCase = string => .replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2') .replace(/([a-z\d])([A-Z])/g, '$1 $2') .trim(); + +/** + * Intelligently truncates an item's namespace by doing two things: + * 1. Only include group names in path by removing the item name + * 2. Only include the first and last group names in the path + * when the namespace includes more than 2 groups + * + * @param {String} string A string namespace, + * i.e. "My Group / My Subgroup / My Project" + */ +export const truncateNamespace = (string = '') => { + if (_.isNull(string) || !_.isString(string)) { + return ''; + } + + const namespaceArray = string.split(' / '); + + if (namespaceArray.length === 1) { + return string; + } + + namespaceArray.splice(-1, 1); + let namespace = namespaceArray.join(' / '); + + if (namespaceArray.length > 2) { + namespace = `${namespaceArray[0]} / ... / ${namespaceArray.pop()}`; + } + + return namespace; +}; diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index d453dc1fdb7..afe8d87a8d6 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -1,16 +1,18 @@ <script> -import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; import { chartHeight, graphTypes, lineTypes } from '../../constants'; +import { makeDataSeries } from '~/helpers/monitor_helper'; let debouncedResize; export default { components: { GlAreaChart, + GlChartSeriesLabel, Icon, }, inheritAttrs: false, @@ -41,10 +43,10 @@ export default { required: false, default: () => [], }, - alertData: { - type: Object, + thresholds: { + type: Array, required: false, - default: () => ({}), + default: () => [], }, }, data() { @@ -63,7 +65,10 @@ export default { }, computed: { chartData() { - return this.graphData.queries.map(query => { + // Transforms & supplements query data to render appropriate labels & styles + // Input: [{ queryAttributes1 }, { queryAttributes2 }] + // Output: [{ seriesAttributes1 }, { seriesAttributes2 }] + return this.graphData.queries.reduce((acc, query) => { const { appearance } = query; const lineType = appearance && appearance.line && appearance.line.type @@ -74,9 +79,8 @@ export default { ? appearance.line.width : undefined; - return { + const series = makeDataSeries(query.result, { name: this.formatLegendLabel(query), - data: this.concatenateResults(query.result), lineStyle: { type: lineType, width: lineWidth, @@ -87,8 +91,10 @@ export default { ? appearance.area.opacity : undefined, }, - }; - }); + }); + + return acc.concat(series); + }, []); }, chartOptions() { return { @@ -119,6 +125,9 @@ export default { }, earliestDatapoint() { return this.chartData.reduce((acc, series) => { + if (!series.data.length) { + return acc; + } const [[timestamp]] = series.data.sort(([a], [b]) => { if (a < b) { return -1; @@ -129,6 +138,9 @@ export default { return timestamp < acc || acc === null ? timestamp : acc; }, null); }, + isMultiSeries() { + return this.tooltip.content.length > 1; + }, recentDeployments() { return this.deploymentData.reduce((acc, deployment) => { if (deployment.created_at >= this.earliestDatapoint) { @@ -175,9 +187,6 @@ export default { this.setSvg('scroll-handle'); }, methods: { - concatenateResults(results) { - return results.reduce((acc, result) => acc.concat(result.values), []); - }, formatLegendLabel(query) { return `${query.label}`; }, @@ -192,7 +201,7 @@ export default { ); this.tooltip.sha = deploy.sha.substring(0, 8); } else { - const { seriesName } = seriesData; + const { seriesName, color } = seriesData; // seriesData.value contains the chart's [x, y] value pair // seriesData.value[1] is threfore the chart y value const value = seriesData.value[1].toFixed(3); @@ -200,6 +209,7 @@ export default { this.tooltip.content.push({ name: seriesName, value, + color, }); } }); @@ -236,29 +246,38 @@ export default { :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" - :thresholds="alertData" + :thresholds="thresholds" :width="width" :height="height" @updated="onChartUpdated" > - <template slot="tooltipTitle"> - <div v-if="tooltip.isDeployment"> + <template v-if="tooltip.isDeployment"> + <template slot="tooltipTitle"> {{ __('Deployed') }} - </div> - {{ tooltip.title }} - </template> - <template slot="tooltipContent"> - <div v-if="tooltip.isDeployment" class="d-flex align-items-center"> + </template> + <div slot="tooltipContent" class="d-flex align-items-center"> <icon name="commit" class="mr-2" /> {{ tooltip.sha }} </div> - <template v-else> + </template> + <template v-else> + <template slot="tooltipTitle"> + <div class="text-nowrap"> + {{ tooltip.title }} + </div> + </template> + <template slot="tooltipContent"> <div v-for="(content, key) in tooltip.content" :key="key" class="d-flex justify-content-between" > - {{ content.name }} {{ content.value }} + <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ content.value }} + </div> </div> </template> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index ba6a17827f7..f2bd4150b6d 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,5 +1,6 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import _ from 'underscore'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import '~/vue_shared/mixins/is_ee'; @@ -9,6 +10,8 @@ import MonitorAreaChart from './charts/area.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import MonitoringStore from '../stores/monitoring_store'; +import { timeWindows } from '../constants'; +import { getTimeDiff } from '../utils'; const sidebarAnimationDuration = 150; let sidebarMutationObserver; @@ -87,6 +90,10 @@ export default { type: String, required: true, }, + showTimeWindowDropdown: { + type: Boolean, + required: true, + }, }, data() { return { @@ -94,6 +101,7 @@ export default { state: 'gettingStarted', showEmptyState: true, elWidth: 0, + selectedTimeWindow: '', }; }, created() { @@ -102,6 +110,9 @@ export default { deploymentEndpoint: this.deploymentEndpoint, environmentsEndpoint: this.environmentsEndpoint, }); + + this.timeWindows = timeWindows; + this.selectedTimeWindow = this.timeWindows.eightHours; }, beforeDestroy() { if (sidebarMutationObserver) { @@ -142,8 +153,13 @@ export default { } }, methods: { - getGraphAlerts(graphId) { - return this.alertData ? this.alertData[graphId] || {} : {}; + getGraphAlerts(queries) { + if (!this.allAlerts) return {}; + const metricIdsForChart = queries.map(q => q.metricId); + return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId)); + }, + getGraphAlertValues(queries) { + return Object.values(this.getGraphAlerts(queries)); }, getGraphsData() { this.state = 'loading'; @@ -160,33 +176,73 @@ export default { this.state = 'unableToConnect'; }); }, + getGraphsDataWithTime(timeFrame) { + this.state = 'loading'; + this.showEmptyState = true; + this.service + .getGraphsData(getTimeDiff(this.timeWindows[timeFrame])) + .then(data => { + this.store.storeMetrics(data); + this.selectedTimeWindow = this.timeWindows[timeFrame]; + }) + .catch(() => { + Flash(s__('Metrics|Not enough data to display')); + }) + .finally(() => { + this.showEmptyState = false; + }); + }, onSidebarMutation() { setTimeout(() => { this.elWidth = this.$el.clientWidth; }, sidebarAnimationDuration); }, + activeTimeWindow(key) { + return this.timeWindows[key] === this.selectedTimeWindow; + }, }, }; </script> <template> <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default"> - <div v-if="environmentsEndpoint" class="environments d-flex align-items-center"> - <strong>{{ s__('Metrics|Environment') }}</strong> - <gl-dropdown - class="prepend-left-10 js-environments-dropdown" - toggle-class="dropdown-menu-toggle" - :text="currentEnvironmentName" - :disabled="store.environmentsData.length === 0" - > - <gl-dropdown-item - v-for="environment in store.environmentsData" - :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" - >{{ environment.name }}</gl-dropdown-item + <div + v-if="environmentsEndpoint" + class="dropdowns d-flex align-items-center justify-content-between" + > + <div class="d-flex align-items-center"> + <strong>{{ s__('Metrics|Environment') }}</strong> + <gl-dropdown + class="prepend-left-10 js-environments-dropdown" + toggle-class="dropdown-menu-toggle" + :text="currentEnvironmentName" + :disabled="store.environmentsData.length === 0" + > + <gl-dropdown-item + v-for="environment in store.environmentsData" + :key="environment.id" + :active="environment.name === currentEnvironmentName" + active-class="is-active" + >{{ environment.name }}</gl-dropdown-item + > + </gl-dropdown> + </div> + <div v-if="showTimeWindowDropdown" class="d-flex align-items-center"> + <strong>{{ s__('Metrics|Show last') }}</strong> + <gl-dropdown + class="prepend-left-10 js-time-window-dropdown" + toggle-class="dropdown-menu-toggle" + :text="selectedTimeWindow" > - </gl-dropdown> + <gl-dropdown-item + v-for="(value, key) in timeWindows" + :key="key" + :active="activeTimeWindow(key)" + @click="getGraphsDataWithTime(key)" + >{{ value }}</gl-dropdown-item + > + </gl-dropdown> + </div> </div> <graph-group v-for="(groupData, index) in store.groups" @@ -199,17 +255,15 @@ export default { :key="graphIndex" :graph-data="graphData" :deployment-data="store.deploymentData" - :alert-data="getGraphAlerts(graphData.id)" + :thresholds="getGraphAlertValues(graphData.queries)" :container-width="elWidth" group-id="monitor-area-chart" > <alert-widget - v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData.id" + v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData" :alerts-endpoint="alertsEndpoint" - :label="getGraphLabel(graphData)" - :current-alerts="getQueryAlerts(graphData)" - :custom-metric-id="graphData.id" - :alert-data="alertData[graphData.id]" + :relevant-queries="graphData.queries" + :alerts-to-manage="getGraphAlerts(graphData.queries)" @setAlerts="setAlerts" /> </monitor-area-chart> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 55ecf3b5334..9e5d0d0fd28 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const chartHeight = 300; export const graphTypes = { @@ -7,3 +9,14 @@ export const graphTypes = { export const lineTypes = { default: 'solid', }; + +export const timeWindows = { + thirtyMinutes: __('30 minutes'), + threeHours: __('3 hours'), + eightHours: __('8 hours'), + oneDay: __('1 day'), + threeDays: __('3 days'), + oneWeek: __('1 week'), +}; + +export const msPerMinute = 60000; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 9d78b5ea110..2b4ddd7afbc 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -14,6 +14,7 @@ export default () => { props: { ...el.dataset, hasMetrics: parseBoolean(el.dataset.hasMetrics), + showTimeWindowDropdown: gon.features.metricsTimeWindow, }, }); }, diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js index 24b4acaf6da..5fcc2c8cfac 100644 --- a/app/assets/javascripts/monitoring/services/monitoring_service.js +++ b/app/assets/javascripts/monitoring/services/monitoring_service.js @@ -32,11 +32,11 @@ export default class MonitoringService { this.environmentsEndpoint = environmentsEndpoint; } - getGraphsData() { - return backOffRequest(() => axios.get(this.metricsEndpoint)) + getGraphsData(params = {}) { + return backOffRequest(() => axios.get(this.metricsEndpoint, { params })) .then(resp => resp.data) .then(response => { - if (!response || !response.data) { + if (!response || !response.data || !response.success) { throw new Error(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); } return response.data; diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 70635059bd9..9761fe168be 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -27,10 +27,47 @@ function removeTimeSeriesNoData(queries) { return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []); } +// Metrics and queries are currently stored 1:1, so `queries` is an array of length one. +// We want to group queries onto a single chart by title & y-axis label. +// This function will no longer be required when metrics:queries are 1:many, +// though there is no consequence if the function stays in use. +// @param metrics [Array<Object>] +// Ex) [ +// { id: 1, title: 'title', y_label: 'MB', queries: [{ ...query1Attrs }] }, +// { id: 2, title: 'title', y_label: 'MB', queries: [{ ...query2Attrs }] }, +// { id: 3, title: 'new title', y_label: 'MB', queries: [{ ...query3Attrs }] } +// ] +// @return [Array<Object>] +// Ex) [ +// { title: 'title', y_label: 'MB', queries: [{ metricId: 1, ...query1Attrs }, +// { metricId: 2, ...query2Attrs }] }, +// { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]} +// ] +function groupQueriesByChartInfo(metrics) { + const metricsByChart = metrics.reduce((accumulator, metric) => { + const { id, queries, ...chart } = metric; + + const chartKey = `${chart.title}|${chart.y_label}`; + accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] }; + + queries.forEach(queryAttrs => + accumulator[chartKey].queries.push({ metricId: id.toString(), ...queryAttrs }), + ); + + return accumulator; + }, {}); + + return Object.values(metricsByChart); +} + function normalizeMetrics(metrics) { - return metrics.map(metric => { + const groupedMetrics = groupQueriesByChartInfo(metrics); + + return groupedMetrics.map(metric => { const queries = metric.queries.map(query => ({ ...query, + // custom metrics do not require a label, so we should ensure this attribute is defined + label: query.label || metric.y_label, result: query.result.map(result => ({ ...result, values: result.values.map(([timestamp, value]) => [ diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js new file mode 100644 index 00000000000..e379827b769 --- /dev/null +++ b/app/assets/javascripts/monitoring/utils.js @@ -0,0 +1,34 @@ +import { timeWindows, msPerMinute } from './constants'; + +/** + * method that converts a predetermined time window to minutes + * defaults to 8 hours as the default option + * @param {String} timeWindow - The time window to convert to minutes + * @returns {number} The time window in minutes + */ +const getTimeDifferenceMinutes = timeWindow => { + switch (timeWindow) { + case timeWindows.thirtyMinutes: + return 30; + case timeWindows.threeHours: + return 60 * 3; + case timeWindows.oneDay: + return 60 * 24 * 1; + case timeWindows.threeDays: + return 60 * 24 * 3; + case timeWindows.oneWeek: + return 60 * 24 * 7 * 1; + default: + return 60 * 8; + } +}; + +export const getTimeDiff = selectedTimeWindow => { + const end = Date.now(); + const timeDifferenceMinutes = getTimeDifferenceMinutes(selectedTimeWindow); + const start = new Date(end - timeDifferenceMinutes * msPerMinute).getTime(); + + return { start, end }; +}; + +export default {}; diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js index d3d125a1859..ad7276132b9 100644 --- a/app/assets/javascripts/pages/admin/groups/edit/index.js +++ b/app/assets/javascripts/pages/admin/groups/edit/index.js @@ -1,3 +1,3 @@ -import groupAvatar from '~/group_avatar'; +import initAvatarPicker from '~/avatar_picker'; -document.addEventListener('DOMContentLoaded', groupAvatar); +document.addEventListener('DOMContentLoaded', initAvatarPicker); diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js index 21f1ce222ac..6de740ee9ce 100644 --- a/app/assets/javascripts/pages/admin/groups/new/index.js +++ b/app/assets/javascripts/pages/admin/groups/new/index.js @@ -1,9 +1,9 @@ import BindInOut from '../../../../behaviors/bind_in_out'; import Group from '../../../../group'; -import groupAvatar from '../../../../group_avatar'; +import initAvatarPicker from '~/avatar_picker'; document.addEventListener('DOMContentLoaded', () => { BindInOut.initAll(); new Group(); // eslint-disable-line no-new - groupAvatar(); + initAvatarPicker(); }); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 01ef445c901..d036ff07d89 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -1,4 +1,4 @@ -import groupAvatar from '~/group_avatar'; +import initAvatarPicker from '~/avatar_picker'; import TransferDropdown from '~/groups/transfer_dropdown'; import initConfirmDangerModal from '~/confirm_danger_modal'; import initSettingsPanels from '~/settings_panels'; @@ -9,7 +9,7 @@ import groupsSelect from '~/groups_select'; import projectSelect from '~/project_select'; document.addEventListener('DOMContentLoaded', () => { - groupAvatar(); + initAvatarPicker(); new TransferDropdown(); // eslint-disable-line no-new initConfirmDangerModal(); initSettingsPanels(); diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/groups/labels/edit/index.js +++ b/app/assets/javascripts/pages/groups/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/groups/labels/new/index.js +++ b/app/assets/javascripts/pages/groups/labels/new/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index b2f275dc5ea..57b53eb9e5d 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,9 +1,9 @@ import BindInOut from '~/behaviors/bind_in_out'; import Group from '~/group'; -import groupAvatar from '~/group_avatar'; +import initAvatarPicker from '~/avatar_picker'; document.addEventListener('DOMContentLoaded', () => { BindInOut.initAll(); new Group(); // eslint-disable-line no-new - groupAvatar(); + initAvatarPicker(); }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 899d5925956..278c35d3846 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -3,7 +3,7 @@ import initSettingsPanels from '~/settings_panels'; import setupProjectEdit from '~/project_edit'; import initConfirmDangerModal from '~/confirm_danger_modal'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; -import fileUpload from '~/lib/utils/file_upload'; +import initAvatarPicker from '~/avatar_picker'; import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectPermissionsSettings from '../shared/permissions'; @@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { setupProjectEdit(); // Initialize expandable settings panels initSettingsPanels(); - fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input'); + initAvatarPicker(); initProjectPermissionsSettings(); initConfirmDangerModal(); mountBadgeSettings(PROJECT_BADGE); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 8987c8e3f47..0447d1f79fb 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -4,9 +4,11 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; import initIssueableApp from '~/issue_show'; +import initRelatedMergeRequestsApp from '~/related_merge_requests'; export default function() { initIssueableApp(); + initRelatedMergeRequestsApp(); new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/projects/labels/edit/index.js +++ b/app/assets/javascripts/pages/projects/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/projects/labels/new/index.js +++ b/app/assets/javascripts/pages/projects/labels/new/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 2b32a6e4a98..0d5afe04e8e 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -57,6 +57,9 @@ export default { }, }, computed: { + boundary() { + return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; + }, status() { return this.job && this.job.status ? this.job.status : {}; }, @@ -104,7 +107,7 @@ export default { <div class="ci-job-component"> <gl-link v-if="status.has_details" - v-gl-tooltip + v-gl-tooltip="{ boundary, placement: 'bottom' }" :href="status.details_path" :title="tooltipText" :class="cssClassJobName" @@ -115,7 +118,7 @@ export default { <div v-else - v-gl-tooltip + v-gl-tooltip="{ boundary, placement: 'bottom' }" :title="tooltipText" :class="cssClassJobName" class="js-job-component-tooltip non-details-job-component" diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue index 1bfab2a7fc0..02451839330 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -27,7 +27,8 @@ export default { <template> <span class="ci-job-name-component"> <ci-icon :status="status" /> - - <span class="ci-status-text"> {{ name }} </span> + <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom"> + {{ name }} + </span> </span> </template> diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue new file mode 100644 index 00000000000..52d4b75a3a1 --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue @@ -0,0 +1,121 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { sprintf, n__, s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import { parseIssuableData } from '../../issue_show/utils/parse_data'; + +export default { + name: 'RelatedMergeRequests', + components: { + Icon, + GlLoadingIcon, + RelatedIssuableItem, + }, + props: { + endpoint: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['isFetchingMergeRequests', 'mergeRequests', 'totalCount']), + closingMergeRequestsText() { + if (!this.hasClosingMergeRequest) { + return ''; + } + + const mrText = n__( + 'When this merge request is accepted', + 'When these merge requests are accepted', + this.totalCount, + ); + + return sprintf(s__('%{mrText}, this issue will be closed automatically.'), { mrText }); + }, + }, + mounted() { + this.setInitialState({ apiEndpoint: this.endpoint }); + this.fetchMergeRequests(); + }, + created() { + this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest; + }, + methods: { + ...mapActions(['setInitialState', 'fetchMergeRequests']), + getAssignees(mr) { + if (mr.assignees) { + return mr.assignees; + } + + return mr.assignee ? [mr.assignee] : []; + }, + }, +}; +</script> + +<template> + <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"> + <div id="merge-requests" class="card-slim mt-3"> + <div class="card-header"> + <div class="card-title mt-0 mb-0 h5 merge-requests-title"> + <span class="mr-1"> + {{ __('Related merge requests') }} + </span> + <div v-if="totalCount" class="d-inline-flex lh-100 align-middle"> + <div class="mr-count-badge"> + <div class="mr-count-badge-count"> + <svg class="s16 mr-1 text-secondary"> + <icon name="merge-request" class="mr-1 text-secondary" /> + </svg> + <span class="js-items-count">{{ totalCount }}</span> + </div> + </div> + </div> + </div> + </div> + <div> + <div + v-if="isFetchingMergeRequests" + class="related-related-merge-requests-icon qa-related-merge-requests-loading-icon" + > + <gl-loading-icon label="Fetching related merge requests" class="py-2" /> + </div> + <ul v-else class="content-list related-items-list"> + <li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0"> + <related-issuable-item + :id-key="mr.id" + :display-reference="mr.reference" + :title="mr.title" + :milestone="mr.milestone" + :assignees="getAssignees(mr)" + :created-at="mr.created_at" + :closed-at="mr.closed_at" + :merged-at="mr.merged_at" + :path="mr.web_url" + :state="mr.state" + :is-merge-request="true" + :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status" + path-id-separator="!" + /> + </li> + </ul> + </div> + </div> + <div + v-if="hasClosingMergeRequest && !isFetchingMergeRequests" + class="issue-closed-by-widget second-block" + > + {{ closingMergeRequestsText }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/related_merge_requests/index.js b/app/assets/javascripts/related_merge_requests/index.js new file mode 100644 index 00000000000..092ff1df00f --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import RelatedMergeRequests from './components/related_merge_requests.vue'; +import createStore from './store'; + +export default function initRelatedMergeRequests() { + const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests'); + + if (relatedMergeRequestsElement) { + const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: relatedMergeRequestsElement, + components: { + RelatedMergeRequests, + }, + store: createStore(), + render: createElement => + createElement('related-merge-requests', { + props: { endpoint, projectNamespace, projectPath }, + }), + }); + } +} diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js new file mode 100644 index 00000000000..69abeaaf7db --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/actions.js @@ -0,0 +1,37 @@ +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; + +const REQUEST_PAGE_COUNT = 100; + +export const setInitialState = ({ commit }, props) => { + commit(types.SET_INITIAL_STATE, props); +}; + +export const requestData = ({ commit }) => commit(types.REQUEST_DATA); + +export const receiveDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DATA_SUCCESS, data); + +export const receiveDataError = ({ commit }) => commit(types.RECEIVE_DATA_ERROR); + +export const fetchMergeRequests = ({ state, dispatch }) => { + dispatch('requestData'); + + return axios + .get(`${state.apiEndpoint}?per_page=${REQUEST_PAGE_COUNT}`) + .then(res => { + const { headers, data } = res; + const total = Number(normalizeHeaders(headers)['X-TOTAL']) || 0; + + dispatch('receiveDataSuccess', { data, total }); + }) + .catch(() => { + dispatch('receiveDataError'); + createFlash(s__('Something went wrong while fetching related merge requests.')); + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/related_merge_requests/store/index.js b/app/assets/javascripts/related_merge_requests/store/index.js new file mode 100644 index 00000000000..dcb70c22bcb --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + state: createState(), + actions, + mutations, + }); diff --git a/app/assets/javascripts/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/related_merge_requests/store/mutation_types.js new file mode 100644 index 00000000000..31d4fe032e1 --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; +export const REQUEST_DATA = 'REQUEST_DATA'; +export const RECEIVE_DATA_SUCCESS = 'RECEIVE_DATA_SUCCESS'; +export const RECEIVE_DATA_ERROR = 'RECEIVE_DATA_ERROR'; diff --git a/app/assets/javascripts/related_merge_requests/store/mutations.js b/app/assets/javascripts/related_merge_requests/store/mutations.js new file mode 100644 index 00000000000..11ca28a5fb9 --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/mutations.js @@ -0,0 +1,19 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, { apiEndpoint }) { + state.apiEndpoint = apiEndpoint; + }, + [types.REQUEST_DATA](state) { + state.isFetchingMergeRequests = true; + }, + [types.RECEIVE_DATA_SUCCESS](state, { data, total }) { + state.isFetchingMergeRequests = false; + state.mergeRequests = data; + state.totalCount = total; + }, + [types.RECEIVE_DATA_ERROR](state) { + state.isFetchingMergeRequests = false; + state.hasErrorFetchingMergeRequests = true; + }, +}; diff --git a/app/assets/javascripts/related_merge_requests/store/state.js b/app/assets/javascripts/related_merge_requests/store/state.js new file mode 100644 index 00000000000..bc3468a025b --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/state.js @@ -0,0 +1,7 @@ +export default () => ({ + apiEndpoint: '', + isFetchingMergeRequests: false, + hasErrorFetchingMergeRequests: false, + mergeRequests: [], + totalCount: 0, +}); diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue index 2946fbc6a1f..04fba43b2f3 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/reports/components/issue_status_icon.vue @@ -13,6 +13,11 @@ export default { type: String, required: true, }, + statusIconSize: { + type: Number, + required: false, + default: 32, + }, }, computed: { iconName() { @@ -45,6 +50,6 @@ export default { }" class="report-block-list-icon" > - <icon :name="iconName" :size="32" /> + <icon :name="iconName" :size="statusIconSize" /> </div> </template> diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index 839e86bdf17..d2106f9ad2e 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -24,6 +24,11 @@ export default { type: String, required: true, }, + statusIconSize: { + type: Number, + required: false, + default: 32, + }, isNew: { type: Boolean, required: false, @@ -34,7 +39,7 @@ export default { </script> <template> <li :class="{ 'is-dismissed': issue.isDismissed }" class="report-block-list-issue"> - <issue-status-icon :status="status" class="append-right-5" /> + <issue-status-icon :status="status" :status-icon-size="statusIconSize" class="append-right-5" /> <component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" /> </li> diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue new file mode 100644 index 00000000000..32c9d6eccb8 --- /dev/null +++ b/app/assets/javascripts/serverless/components/area.vue @@ -0,0 +1,146 @@ +<script> +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import dateFormat from 'dateformat'; +import { X_INTERVAL } from '../constants'; +import { validateGraphData } from '../utils'; + +let debouncedResize; + +export default { + components: { + GlAreaChart, + }, + inheritAttrs: false, + props: { + graphData: { + type: Object, + required: true, + validator: validateGraphData, + }, + containerWidth: { + type: Number, + required: true, + }, + }, + data() { + return { + tooltipPopoverTitle: '', + tooltipPopoverContent: '', + width: this.containerWidth, + }; + }, + computed: { + chartData() { + return this.graphData.queries.reduce((accumulator, query) => { + accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []); + return accumulator; + }, {}); + }, + extractTimeData() { + return this.chartData.requests.map(data => data.time); + }, + generateSeries() { + return { + name: 'Invocations', + type: 'line', + data: this.chartData.requests.map(data => [data.time, data.value]), + symbolSize: 0, + }; + }, + getInterval() { + const { result } = this.graphData.queries[0]; + + if (result.length === 0) { + return 1; + } + + const split = result[0].values.reduce( + (acc, pair) => (pair.value > acc ? pair.value : acc), + 1, + ); + + return split < X_INTERVAL ? split : X_INTERVAL; + }, + chartOptions() { + return { + xAxis: { + name: 'time', + type: 'time', + axisLabel: { + formatter: date => dateFormat(date, 'h:MM TT'), + }, + data: this.extractTimeData, + nameTextStyle: { + padding: [18, 0, 0, 0], + }, + }, + yAxis: { + name: this.yAxisLabel, + nameTextStyle: { + padding: [0, 0, 36, 0], + }, + splitNumber: this.getInterval, + }, + legend: { + formatter: this.xAxisLabel, + }, + series: this.generateSeries, + }; + }, + xAxisLabel() { + return this.graphData.queries.map(query => query.label).join(', '); + }, + yAxisLabel() { + const [query] = this.graphData.queries; + return `${this.graphData.y_label} (${query.unit})`; + }, + }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', debouncedResize); + }, + created() { + debouncedResize = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', debouncedResize); + }, + methods: { + formatTooltipText(params) { + const [seriesData] = params.seriesData; + this.tooltipPopoverTitle = dateFormat(params.value, 'dd mmm yyyy, h:MMTT'); + this.tooltipPopoverContent = `${this.yAxisLabel}: ${seriesData.value[1]}`; + }, + onResize() { + const { width } = this.$refs.areaChart.$el.getBoundingClientRect(); + this.width = width; + }, + }, +}; +</script> + +<template> + <div class="prometheus-graph"> + <div class="prometheus-graph-header"> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> + </div> + <gl-area-chart + ref="areaChart" + v-bind="$attrs" + :data="[]" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + :width="width" + :include-legend-avg-max="false" + > + <template slot="tooltipTitle"> + {{ tooltipPopoverTitle }} + </template> + <template slot="tooltipContent"> + {{ tooltipPopoverContent }} + </template> + </gl-area-chart> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index 4f89ad69129..b8906cfca4e 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -1,39 +1,77 @@ <script> +import _ from 'underscore'; +import { mapState, mapActions, mapGetters } from 'vuex'; import PodBox from './pod_box.vue'; import Url from './url.vue'; +import AreaChart from './area.vue'; +import MissingPrometheus from './missing_prometheus.vue'; export default { components: { PodBox, Url, + AreaChart, + MissingPrometheus, }, props: { func: { type: Object, required: true, }, + hasPrometheus: { + type: Boolean, + required: false, + default: false, + }, + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + }, + data() { + return { + elWidth: 0, + }; }, computed: { name() { return this.func.name; }, description() { - return this.func.description; + return _.isString(this.func.description) ? this.func.description : ''; }, funcUrl() { return this.func.url; }, podCount() { - return this.func.podcount || 0; + return Number(this.func.podcount) || 0; }, + ...mapState(['graphData', 'hasPrometheusData']), + ...mapGetters(['hasPrometheusMissingData']), + }, + created() { + this.fetchMetrics({ + metricsPath: this.func.metricsUrl, + hasPrometheus: this.hasPrometheus, + }); + }, + mounted() { + this.elWidth = this.$el.clientWidth; + }, + methods: { + ...mapActions(['fetchMetrics']), }, }; </script> <template> <section id="serverless-function-details"> - <h3>{{ name }}</h3> - <div class="append-bottom-default"> + <h3 class="serverless-function-name">{{ name }}</h3> + <div class="append-bottom-default serverless-function-description"> <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> </div> <url :uri="funcUrl" /> @@ -52,5 +90,13 @@ export default { </p> </div> <div v-else><p>No pods loaded at this time.</p></div> + + <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" /> + <missing-prometheus + v-if="!hasPrometheus || hasPrometheusMissingData" + :help-path="helpPath" + :clusters-path="clustersPath" + :missing-data="hasPrometheusMissingData" + /> </section> </template> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index 773d18781fd..4b3bb078eae 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import Url from './url.vue'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -19,6 +20,10 @@ export default { return this.func.name; }, description() { + if (!_.isString(this.func.description)) { + return ''; + } + const desc = this.func.description.split('\n'); if (desc.length > 1) { return desc[1]; diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 4bde409f906..f9b4e789563 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,5 +1,6 @@ <script> -import { GlSkeletonLoading } from '@gitlab/ui'; +import { mapState, mapActions, mapGetters } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; @@ -9,14 +10,9 @@ export default { EnvironmentRow, FunctionRow, EmptyState, - GlSkeletonLoading, + GlLoadingIcon, }, props: { - functions: { - type: Object, - required: true, - default: () => ({}), - }, installed: { type: Boolean, required: true, @@ -29,17 +25,23 @@ export default { type: String, required: true, }, - loadingData: { - type: Boolean, - required: false, - default: true, - }, - hasFunctionData: { - type: Boolean, - required: false, - default: true, + statusPath: { + type: String, + required: true, }, }, + computed: { + ...mapState(['isLoading', 'hasFunctionData']), + ...mapGetters(['getFunctions']), + }, + created() { + this.fetchFunctions({ + functionsPath: this.statusPath, + }); + }, + methods: { + ...mapActions(['fetchFunctions']), + }, }; </script> @@ -47,14 +49,16 @@ export default { <section id="serverless-functions"> <div v-if="installed"> <div v-if="hasFunctionData"> - <template v-if="loadingData"> - <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div> - </template> + <gl-loading-icon + v-if="isLoading" + :size="2" + class="prepend-top-default append-bottom-default" + /> <template v-else> <div class="groups-list-tree-container"> <ul class="content-list group-list-tree"> <environment-row - v-for="(env, index) in functions" + v-for="(env, index) in getFunctions" :key="index" :env="env" :env-name="index" diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue new file mode 100644 index 00000000000..6c19434f202 --- /dev/null +++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue @@ -0,0 +1,63 @@ +<script> +import { GlButton, GlLink } from '@gitlab/ui'; +import { s__ } from '../../locale'; + +export default { + components: { + GlButton, + GlLink, + }, + props: { + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + missingData: { + type: Boolean, + required: true, + }, + }, + computed: { + missingStateClass() { + return this.missingData ? 'missing-prometheus-state' : 'empty-prometheus-state'; + }, + prometheusHelpPath() { + return `${this.helpPath}#prometheus-support`; + }, + description() { + return this.missingData + ? s__(`ServerlessDetails|Invocation metrics loading or not available at this time.`) + : s__( + `ServerlessDetails|Function invocation metrics require Prometheus to be installed first.`, + ); + }, + }, +}; +</script> + +<template> + <div class="row" :class="missingStateClass"> + <div class="col-12"> + <div class="text-content"> + <h4 class="state-title text-left">{{ s__(`ServerlessDetails|Invocations`) }}</h4> + <p class="state-description"> + {{ description }} + <gl-link :href="prometheusHelpPath">{{ + s__(`ServerlessDetails|More information`) + }}</gl-link + >. + </p> + + <div v-if="!missingData" class="text-left"> + <gl-button :href="clustersPath" variant="success"> + {{ s__('ServerlessDetails|Install Prometheus') }} + </gl-button> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js new file mode 100644 index 00000000000..35f77205f2c --- /dev/null +++ b/app/assets/javascripts/serverless/constants.js @@ -0,0 +1,3 @@ +export const MAX_REQUESTS = 3; // max number of times to retry + +export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js index 47a510d5fb5..2d3f086ffee 100644 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -1,13 +1,7 @@ -import Visibility from 'visibilityjs'; import Vue from 'vue'; -import { s__ } from '../locale'; -import Flash from '../flash'; -import Poll from '../lib/utils/poll'; -import ServerlessStore from './stores/serverless_store'; -import ServerlessDetailsStore from './stores/serverless_details_store'; -import GetFunctionsService from './services/get_functions_service'; import Functions from './components/functions.vue'; import FunctionDetails from './components/function_details.vue'; +import { createStore } from './store'; export default class Serverless { constructor() { @@ -19,10 +13,12 @@ export default class Serverless { serviceUrl, serviceNamespace, servicePodcount, + serviceMetricsUrl, + prometheus, + clustersPath, + helpPath, } = document.querySelector('.js-serverless-function-details-page').dataset; const el = document.querySelector('#js-serverless-function-details'); - this.store = new ServerlessDetailsStore(); - const { store } = this; const service = { name: serviceName, @@ -31,20 +27,19 @@ export default class Serverless { url: serviceUrl, namespace: serviceNamespace, podcount: servicePodcount, + metricsUrl: serviceMetricsUrl, }; - this.store.updateDetailedFunction(service); this.functionDetails = new Vue({ el, - data() { - return { - state: store.state, - }; - }, + store: createStore(), render(createElement) { return createElement(FunctionDetails, { props: { - func: this.state.functionDetail, + func: service, + hasPrometheus: prometheus !== undefined, + clustersPath, + helpPath, }, }); }, @@ -54,95 +49,27 @@ export default class Serverless { '.js-serverless-functions-page', ).dataset; - this.service = new GetFunctionsService(statusPath); - this.knativeInstalled = installed !== undefined; - this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); - this.initServerless(); - this.functionLoadCount = 0; - - if (statusPath && this.knativeInstalled) { - this.initPolling(); - } - } - } - - initServerless() { - const { store } = this; - const el = document.querySelector('#js-serverless-functions'); - - this.functions = new Vue({ - el, - data() { - return { - state: store.state, - }; - }, - render(createElement) { - return createElement(Functions, { - props: { - functions: this.state.functions, - installed: this.state.installed, - clustersPath: this.state.clustersPath, - helpPath: this.state.helpPath, - loadingData: this.state.loadingData, - hasFunctionData: this.state.hasFunctionData, - }, - }); - }, - }); - } - - initPolling() { - this.poll = new Poll({ - resource: this.service, - method: 'fetchData', - successCallback: data => this.handleSuccess(data), - errorCallback: () => Serverless.handleError(), - }); - - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } else { - this.service - .fetchData() - .then(data => this.handleSuccess(data)) - .catch(() => Serverless.handleError()); - } - - Visibility.change(() => { - if (!Visibility.hidden() && !this.destroyed) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - } - - handleSuccess(data) { - if (data.status === 200) { - this.store.updateFunctionsFromServer(data.data); - this.store.updateLoadingState(false); - } else if (data.status === 204) { - /* Time out after 3 attempts to retrieve data */ - this.functionLoadCount += 1; - if (this.functionLoadCount === 3) { - this.poll.stop(); - this.store.toggleNoFunctionData(); - } + const el = document.querySelector('#js-serverless-functions'); + this.functions = new Vue({ + el, + store: createStore(), + render(createElement) { + return createElement(Functions, { + props: { + installed: installed !== undefined, + clustersPath, + helpPath, + statusPath, + }, + }); + }, + }); } } - static handleError() { - Flash(s__('Serverless|An error occurred while retrieving serverless components')); - } - destroy() { this.destroyed = true; - if (this.poll) { - this.poll.stop(); - } - this.functions.$destroy(); this.functionDetails.$destroy(); } diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js deleted file mode 100644 index 303b42dc66c..00000000000 --- a/app/assets/javascripts/serverless/services/get_functions_service.js +++ /dev/null @@ -1,11 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -export default class GetFunctionsService { - constructor(endpoint) { - this.endpoint = endpoint; - } - - fetchData() { - return axios.get(this.endpoint); - } -} diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js new file mode 100644 index 00000000000..826501c9022 --- /dev/null +++ b/app/assets/javascripts/serverless/store/actions.js @@ -0,0 +1,113 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { MAX_REQUESTS } from '../constants'; + +export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING); +export const receiveFunctionsSuccess = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_SUCCESS, data); +export const receiveFunctionsNoDataSuccess = ({ commit }) => + commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS); +export const receiveFunctionsError = ({ commit }, error) => + commit(types.RECEIVE_FUNCTIONS_ERROR, error); + +export const receiveMetricsSuccess = ({ commit }, data) => + commit(types.RECEIVE_METRICS_SUCCESS, data); +export const receiveMetricsNoPrometheus = ({ commit }) => + commit(types.RECEIVE_METRICS_NO_PROMETHEUS); +export const receiveMetricsNoDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_METRICS_NODATA_SUCCESS, data); +export const receiveMetricsError = ({ commit }, error) => + commit(types.RECEIVE_METRICS_ERROR, error); + +export const fetchFunctions = ({ dispatch }, { functionsPath }) => { + let retryCount = 0; + + dispatch('requestFunctionsLoading'); + + backOff((next, stop) => { + axios + .get(functionsPath) + .then(response => { + if (response.status === statusCodes.NO_CONTENT) { + retryCount += 1; + if (retryCount < MAX_REQUESTS) { + next(); + } else { + stop(null); + } + } else { + stop(response.data); + } + }) + .catch(stop); + }) + .then(data => { + if (data !== null) { + dispatch('receiveFunctionsSuccess', data); + } else { + dispatch('receiveFunctionsNoDataSuccess'); + } + }) + .catch(error => { + dispatch('receiveFunctionsError', error); + createFlash(error); + }); +}; + +export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => { + let retryCount = 0; + + if (!hasPrometheus) { + dispatch('receiveMetricsNoPrometheus'); + return; + } + + backOff((next, stop) => { + axios + .get(metricsPath) + .then(response => { + if (response.status === statusCodes.NO_CONTENT) { + retryCount += 1; + if (retryCount < MAX_REQUESTS) { + next(); + } else { + dispatch('receiveMetricsNoDataSuccess'); + stop(null); + } + } else { + stop(response.data); + } + }) + .catch(stop); + }) + .then(data => { + if (data === null) { + return; + } + + const updatedMetric = data.metrics; + const queries = data.metrics.queries.map(query => ({ + ...query, + result: query.result.map(result => ({ + ...result, + values: result.values.map(([timestamp, value]) => ({ + time: new Date(timestamp * 1000).toISOString(), + value: Number(value), + })), + })), + })); + + updatedMetric.queries = queries; + dispatch('receiveMetricsSuccess', updatedMetric); + }) + .catch(error => { + dispatch('receiveMetricsError', error); + createFlash(error); + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/serverless/store/getters.js b/app/assets/javascripts/serverless/store/getters.js new file mode 100644 index 00000000000..071f663d9d2 --- /dev/null +++ b/app/assets/javascripts/serverless/store/getters.js @@ -0,0 +1,10 @@ +import { translate } from '../utils'; + +export const hasPrometheusMissingData = state => state.hasPrometheus && !state.hasPrometheusData; + +// Convert the function list into a k/v grouping based on the environment scope + +export const getFunctions = state => translate(state.functions); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/serverless/store/index.js b/app/assets/javascripts/serverless/store/index.js new file mode 100644 index 00000000000..5f72060633e --- /dev/null +++ b/app/assets/javascripts/serverless/store/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(), + }); + +export default createStore(); diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js new file mode 100644 index 00000000000..25b2f7ac38a --- /dev/null +++ b/app/assets/javascripts/serverless/store/mutation_types.js @@ -0,0 +1,9 @@ +export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING'; +export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS'; +export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS'; +export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR'; + +export const RECEIVE_METRICS_NO_PROMETHEUS = 'RECEIVE_METRICS_NO_PROMETHEUS'; +export const RECEIVE_METRICS_SUCCESS = 'RECEIVE_METRICS_SUCCESS'; +export const RECEIVE_METRICS_NODATA_SUCCESS = 'RECEIVE_METRICS_NODATA_SUCCESS'; +export const RECEIVE_METRICS_ERROR = 'RECEIVE_METRICS_ERROR'; diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js new file mode 100644 index 00000000000..991f32a275d --- /dev/null +++ b/app/assets/javascripts/serverless/store/mutations.js @@ -0,0 +1,38 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_FUNCTIONS_LOADING](state) { + state.isLoading = true; + }, + [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) { + state.functions = data; + state.isLoading = false; + state.hasFunctionData = true; + }, + [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state) { + state.isLoading = false; + state.hasFunctionData = false; + }, + [types.RECEIVE_FUNCTIONS_ERROR](state, error) { + state.error = error; + state.hasFunctionData = false; + state.isLoading = false; + }, + [types.RECEIVE_METRICS_SUCCESS](state, data) { + state.isLoading = false; + state.hasPrometheusData = true; + state.graphData = data; + }, + [types.RECEIVE_METRICS_NODATA_SUCCESS](state) { + state.isLoading = false; + state.hasPrometheusData = false; + }, + [types.RECEIVE_METRICS_ERROR](state, error) { + state.hasPrometheusData = false; + state.error = error; + }, + [types.RECEIVE_METRICS_NO_PROMETHEUS](state) { + state.hasPrometheusData = false; + state.hasPrometheus = false; + }, +}; diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js new file mode 100644 index 00000000000..afc3f37d7ba --- /dev/null +++ b/app/assets/javascripts/serverless/store/state.js @@ -0,0 +1,13 @@ +export default () => ({ + error: null, + isLoading: true, + + // functions + functions: [], + hasFunctionData: true, + + // function_details + hasPrometheus: true, + hasPrometheusData: false, + graphData: {}, +}); diff --git a/app/assets/javascripts/serverless/stores/serverless_details_store.js b/app/assets/javascripts/serverless/stores/serverless_details_store.js deleted file mode 100644 index 5394d2cded1..00000000000 --- a/app/assets/javascripts/serverless/stores/serverless_details_store.js +++ /dev/null @@ -1,11 +0,0 @@ -export default class ServerlessDetailsStore { - constructor() { - this.state = { - functionDetail: {}, - }; - } - - updateDetailedFunction(func) { - this.state.functionDetail = func; - } -} diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js deleted file mode 100644 index 816d55a03f9..00000000000 --- a/app/assets/javascripts/serverless/stores/serverless_store.js +++ /dev/null @@ -1,29 +0,0 @@ -export default class ServerlessStore { - constructor(knativeInstalled = false, clustersPath, helpPath) { - this.state = { - functions: {}, - hasFunctionData: true, - loadingData: true, - installed: knativeInstalled, - clustersPath, - helpPath, - }; - } - - updateFunctionsFromServer(upstreamFunctions = []) { - this.state.functions = upstreamFunctions.reduce((rv, func) => { - const envs = rv; - envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]); - - return envs; - }, {}); - } - - updateLoadingState(loadingData) { - this.state.loadingData = loadingData; - } - - toggleNoFunctionData() { - this.state.hasFunctionData = false; - } -} diff --git a/app/assets/javascripts/serverless/utils.js b/app/assets/javascripts/serverless/utils.js new file mode 100644 index 00000000000..8b9e96ce9aa --- /dev/null +++ b/app/assets/javascripts/serverless/utils.js @@ -0,0 +1,23 @@ +// Validate that the object coming in has valid query details and results +export const validateGraphData = data => + data.queries && + Array.isArray(data.queries) && + data.queries.filter(query => { + if (Array.isArray(query.result)) { + return query.result.filter(res => Array.isArray(res.values)).length === query.result.length; + } + + return false; + }).length === data.queries.length; + +export const translate = functions => + functions.reduce( + (acc, func) => + Object.assign(acc, { + [func.environment_scope]: (acc[func.environment_scope] || []).concat([func]), + }), + {}, + ); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 780ced4d382..392eb6fb425 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -33,7 +33,7 @@ export default { </script> <template> <div class="space-children d-flex append-right-10 widget-status-icon"> - <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon /></div> + <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon size="md" /></div> <ci-icon v-else :status="statusObj" :size="24" /> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue index b25aebd7c98..2b5b2269ec8 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue @@ -40,12 +40,15 @@ export default { }, beforeDestroy() { document.body.removeEventListener('mouseup', this.stopDrag); - this.$refs.dragger.removeEventListener('mousedown', this.startDrag); + document.body.removeEventListener('touchend', this.stopDrag); + document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchmove', this.dragMove); }, methods: { dragMove(e) { if (!this.dragging) return; - const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left; + const moveX = e.pageX || e.touches[0].pageX; + const left = moveX - this.$refs.dragTrack.getBoundingClientRect().left; const dragTrackWidth = this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; @@ -60,11 +63,13 @@ export default { this.dragging = true; document.body.style.userSelect = 'none'; document.body.addEventListener('mousemove', this.dragMove); + document.body.addEventListener('touchmove', this.dragMove); }, stopDrag() { this.dragging = false; document.body.style.userSelect = ''; document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchmove', this.dragMove); }, prepareOnionSkin() { if (this.onionOldImgInfo && this.onionNewImgInfo) { @@ -82,6 +87,7 @@ export default { this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; document.body.addEventListener('mouseup', this.stopDrag); + document.body.addEventListener('touchend', this.stopDrag); } }, onionNewImgLoaded(imgInfo) { @@ -140,7 +146,14 @@ export default { </div> <div class="controls"> <div class="transparent"></div> - <div ref="dragTrack" class="drag-track" @mousedown="startDrag" @mouseup="stopDrag"> + <div + ref="dragTrack" + class="drag-track" + @mousedown="startDrag" + @mouseup="stopDrag" + @touchstart="startDrag" + @touchend="stopDrag" + > <div ref="dragger" :style="{ left: onionDraggerPixelPos }" class="dragger"></div> </div> <div class="opaque"></div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue index eddafc759a2..ad3b3b81ac5 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue @@ -46,6 +46,8 @@ export default { window.removeEventListener('resize', this.resizeThrottled, false); document.body.removeEventListener('mouseup', this.stopDrag); document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchend', this.stopDrag); + document.body.removeEventListener('touchmove', this.dragMove); }, mounted() { window.addEventListener('resize', this.resize, false); @@ -54,7 +56,8 @@ export default { dragMove(e) { if (!this.dragging) return; - let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left; + const moveX = e.pageX || e.touches[0].pageX; + let leftValue = moveX - this.$refs.swipeFrame.getBoundingClientRect().left; const spaceLeft = 20; const { clientWidth } = this.$refs.swipeFrame; if (leftValue <= 0) { @@ -69,10 +72,12 @@ export default { startDrag() { this.dragging = true; document.body.addEventListener('mousemove', this.dragMove); + document.body.addEventListener('touchmove', this.dragMove); }, stopDrag() { this.dragging = false; document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchmove', this.dragMove); }, prepareSwipe() { if (this.swipeOldImgInfo && this.swipeNewImgInfo) { @@ -83,6 +88,7 @@ export default { Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2; document.body.addEventListener('mouseup', this.stopDrag); + document.body.addEventListener('touchend', this.stopDrag); } }, swipeNewImgLoaded(imgInfo) { @@ -143,6 +149,8 @@ export default { class="swipe-bar" @mousedown="startDrag" @mouseup="stopDrag" + @touchstart="startDrag" + @touchend="stopDrag" > <span class="top-handle"></span> <span class="bottom-handle"></span> </span> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 0cbcdbf2eb4..1bfa91500cb 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -39,7 +39,7 @@ export default { }, data() { return { - mouseOver: false, + dropdownOpen: false, }; }, computed: { @@ -123,8 +123,8 @@ export default { return this.$router.currentRoute.path === `/project${this.file.url}`; }, - toggleHover(over) { - this.mouseOver = over; + toggleDropdown(val) { + this.dropdownOpen = val; }, }, }; @@ -140,8 +140,7 @@ export default { class="file-row" role="button" @click="clickFile" - @mouseover="toggleHover(true)" - @mouseout="toggleHover(false)" + @mouseleave="toggleDropdown(false)" > <div class="file-row-name-container"> <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> @@ -160,7 +159,8 @@ export default { :is="extraComponent" v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')" :file="file" - :mouse-over="mouseOver" + :dropdown-open="dropdownOpen" + @toggle="toggleDropdown($event)" /> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index 27cfa8abb24..d4d18614f93 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -1,15 +1,17 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; -import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; -import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; -import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; +import { sprintf } from '~/locale'; +import IssueMilestone from '../../components/issue/issue_milestone.vue'; +import IssueAssignees from '../../components/issue/issue_assignees.vue'; +import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; +import CiIcon from '../ci_icon.vue'; export default { name: 'IssueItem', components: { IssueMilestone, IssueAssignees, + CiIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -27,9 +29,9 @@ export default { return sprintf( '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>', { - state: this.isOpen ? __('Opened') : __('Closed'), - timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords, - timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp, + state: this.stateText, + timeInWords: this.stateTimeInWords, + timestamp: this.stateTimestamp, }, ); }, @@ -84,6 +86,11 @@ export default { {{ pathIdSeparator }}{{ itemId }} </div> <div class="item-meta-child d-flex align-items-center"> + <span v-if="hasPipeline" class="mr-ci-status pr-2"> + <a :href="pipelineStatus.details_path"> + <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" /> + </a> + </span> <issue-milestone v-if="hasMilestone" :milestone="milestone" diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue new file mode 100644 index 00000000000..071bae7f665 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -0,0 +1,74 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue'; +import highlight from '~/lib/utils/highlight'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import _ from 'underscore'; + +export default { + name: 'ProjectListItem', + components: { + Icon, + ProjectAvatar, + GlButton, + }, + props: { + project: { + type: Object, + required: true, + validator: p => _.isFinite(p.id) && _.isString(p.name) && _.isString(p.name_with_namespace), + }, + selected: { + type: Boolean, + required: true, + }, + matcher: { + type: String, + required: false, + default: '', + }, + }, + computed: { + truncatedNamespace() { + return truncateNamespace(this.project.name_with_namespace); + }, + highlightedProjectName() { + return highlight(this.project.name, this.matcher); + }, + }, + methods: { + onClick() { + this.$emit('click'); + }, + }, +}; +</script> +<template> + <gl-button + class="d-flex align-items-center btn pt-1 pb-1 border-0 project-list-item" + @click="onClick" + > + <icon + class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon" + :class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }" + name="mobile-issue-close" + /> + <project-avatar class="flex-shrink-0 js-project-avatar" :project="project" :size="32" /> + <div class="d-flex flex-wrap project-namespace-name-container"> + <div + v-if="truncatedNamespace" + :title="project.name_with_namespace" + class="text-secondary text-truncate js-project-namespace" + > + {{ truncatedNamespace }} + <span v-if="truncatedNamespace" class="text-secondary">/ </span> + </div> + <div + :title="project.name" + class="js-project-name text-truncate" + v-html="highlightedProjectName" + ></div> + </div> + </gl-button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue new file mode 100644 index 00000000000..596fd48f96a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -0,0 +1,103 @@ +<script> +import _ from 'underscore'; +import { GlLoadingIcon } from '@gitlab/ui'; +import ProjectListItem from './project_list_item.vue'; + +const SEARCH_INPUT_TIMEOUT_MS = 500; + +export default { + name: 'ProjectSelector', + components: { + GlLoadingIcon, + ProjectListItem, + }, + props: { + projectSearchResults: { + type: Array, + required: true, + }, + selectedProjects: { + type: Array, + required: true, + }, + showNoResultsMessage: { + type: Boolean, + required: false, + default: false, + }, + showMinimumSearchQueryMessage: { + type: Boolean, + required: false, + default: false, + }, + showLoadingIndicator: { + type: Boolean, + required: false, + default: false, + }, + showSearchErrorMessage: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + searchQuery: '', + }; + }, + methods: { + projectClicked(project) { + this.$emit('projectClicked', project); + }, + isSelected(project) { + return Boolean(_.findWhere(this.selectedProjects, { id: project.id })); + }, + focusSearchInput() { + this.$refs.searchInput.focus(); + }, + onInput: _.debounce(function debouncedOnInput() { + this.$emit('searched', this.searchQuery); + }, SEARCH_INPUT_TIMEOUT_MS), + }, +}; +</script> +<template> + <div> + <input + ref="searchInput" + v-model="searchQuery" + :placeholder="__('Search your projects')" + type="search" + class="form-control mb-3 js-project-selector-input" + autofocus + @input="onInput" + /> + <div class="d-flex flex-column"> + <gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" /> + <div v-if="!showLoadingIndicator" class="d-flex flex-column"> + <project-list-item + v-for="project in projectSearchResults" + :key="project.id" + :selected="isSelected(project)" + :project="project" + :matcher="searchQuery" + class="js-project-list-item" + @click="projectClicked(project)" + /> + </div> + <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message"> + {{ __('Sorry, no projects matched your search') }} + </div> + <div + v-if="showMinimumSearchQueryMessage" + class="text-muted ml-2 js-minimum-search-query-message" + > + {{ __('Enter at least three characters to search') }} + </div> + <div v-if="showSearchErrorMessage" class="text-danger ml-2 js-search-error-message"> + {{ __('Something went wrong, unable to search projects') }} + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue new file mode 100644 index 00000000000..1f3d248e991 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue @@ -0,0 +1,40 @@ +<script> +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import $ from 'jquery'; + +export default { + data() { + return { + width: 0, + height: 0, + }; + }, + beforeDestroy() { + this.contentResizeHandler.off('content.resize', this.debouncedResize); + window.removeEventListener('resize', this.debouncedResize); + }, + created() { + this.debouncedResize = debounceByAnimationFrame(this.onResize); + + // Handle when we explicictly trigger a custom resize event + this.contentResizeHandler = $(document).on('content.resize', this.debouncedResize); + + // Handle window resize + window.addEventListener('resize', this.debouncedResize); + }, + methods: { + onResize() { + // Slot dimensions + const { clientWidth, clientHeight } = this.$refs.chartWrapper; + this.width = clientWidth; + this.height = clientHeight; + }, + }, +}; +</script> + +<template> + <div ref="chartWrapper"> + <slot :width="width" :height="height"> </slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index f66e81b1e08..9c258c4651f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -75,6 +75,16 @@ export default { required: false, default: false, }, + enableScopedLabels: { + type: Boolean, + require: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + require: false, + default: '#', + }, }, computed: { hiddenInputName() { @@ -123,7 +133,12 @@ export default { @onValueClick="handleCollapsedValueClick" /> <dropdown-title :can-edit="canEdit" /> - <dropdown-value :labels="context.labels" :label-filter-base-path="labelFilterBasePath"> + <dropdown-value + :labels="context.labels" + :label-filter-base-path="labelFilterBasePath" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + :enable-scoped-labels="enableScopedLabels" + > <slot></slot> </dropdown-value> <div v-if="canEdit" class="selectbox js-selectbox" style="display: none;"> @@ -142,6 +157,8 @@ export default { :namespace="namespace" :labels="context.labels" :show-extra-options="!showCreate" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + :enable-scoped-labels="enableScopedLabels" /> <div class="dropdown-menu dropdown-select dropdown-menu-paging diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue index 498b507d11d..1eed8907bb7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -31,6 +31,16 @@ export default { type: Boolean, required: true, }, + enableScopedLabels: { + type: Boolean, + require: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + require: false, + default: '#', + }, }, computed: { dropdownToggleText() { @@ -61,6 +71,8 @@ export default { :data-labels="labelsPath" :data-namespace-path="namespace" :data-show-any="showExtraOptions" + :data-scoped-labels="enableScopedLabels" + :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink" type="button" class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal" data-toggle="dropdown" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue index 6faf3fafad1..ddc488adbcb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue @@ -1,9 +1,11 @@ <script> -import tooltip from '~/vue_shared/directives/tooltip'; +import DropdownValueScopedLabel from './dropdown_value_scoped_label.vue'; +import DropdownValueRegularLabel from './dropdown_value_regular_label.vue'; export default { - directives: { - tooltip, + components: { + DropdownValueScopedLabel, + DropdownValueRegularLabel, }, props: { labels: { @@ -14,6 +16,16 @@ export default { type: String, required: true, }, + enableScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + required: false, + default: '#', + }, }, computed: { isEmpty() { @@ -30,6 +42,12 @@ export default { backgroundColor: label.color, }; }, + scopedLabelsDescription({ description = '' }) { + return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`; + }, + showScopedLabels({ title = '' }) { + return this.enableScopedLabels && title.indexOf('::') !== -1; + }, }, }; </script> @@ -44,17 +62,24 @@ export default { <span v-if="isEmpty" class="text-secondary"> <slot>{{ __('None') }}</slot> </span> - <a v-for="label in labels" v-else :key="label.id" :href="labelFilterUrl(label)"> - <span - v-tooltip - :style="labelStyle(label)" - :title="label.description" - class="badge color-label" - data-placement="bottom" - data-container="body" - > - {{ label.title }} - </span> - </a> + + <template v-for="label in labels" v-else> + <dropdown-value-scoped-label + v-if="showScopedLabels(label)" + :key="label.id" + :label="label" + :label-filter-url="labelFilterUrl(label)" + :label-style="labelStyle(label)" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + /> + + <dropdown-value-regular-label + v-else + :key="label.id" + :label="label" + :label-filter-url="labelFilterUrl(label)" + :label-style="labelStyle(label)" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue new file mode 100644 index 00000000000..282b181f11e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue @@ -0,0 +1,35 @@ +<script> +import { GlLink, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlTooltip, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + labelStyle: { + type: Object, + required: true, + }, + labelFilterUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <a ref="regularLabelRef" :href="labelFilterUrl"> + <span :style="labelStyle" class="badge color-label"> + {{ label.title }} + </span> + <gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport"> + {{ label.description }} + </gl-tooltip> + </a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue new file mode 100644 index 00000000000..ad5a86de166 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue @@ -0,0 +1,47 @@ +<script> +import { GlLink, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlTooltip, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + labelStyle: { + type: Object, + required: true, + }, + scopedLabelsDocumentationLink: { + type: String, + required: true, + }, + labelFilterUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span class="d-inline-block position-relative scoped-label-wrapper"> + <a :href="labelFilterUrl"> + <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label"> + {{ label.title }} + </span> + <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport"> + <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span + ><br /> + {{ label.description }} + </gl-tooltip> + </a> + + <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label" + ><i class="fa fa-question-circle" :style="labelStyle"></i + ></gl-link> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js index 455ae832234..8e0e4baa75a 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import { sprintf, __ } from '~/locale'; import { formatDate } from '~/lib/utils/datetime_utility'; import tooltip from '~/vue_shared/directives/tooltip'; import icon from '~/vue_shared/components/icon.vue'; @@ -58,6 +59,11 @@ const mixins = { required: false, default: '', }, + mergedAt: { + type: String, + required: false, + default: '', + }, milestone: { type: Object, required: false, @@ -83,6 +89,16 @@ const mixins = { required: false, default: false, }, + isMergeRequest: { + type: Boolean, + required: false, + default: false, + }, + pipelineStatus: { + type: Object, + required: false, + default: () => ({}), + }, }, components: { icon, @@ -95,12 +111,18 @@ const mixins = { hasState() { return this.state && this.state.length > 0; }, + hasPipeline() { + return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length; + }, isOpen() { return this.state === 'opened'; }, isClosed() { return this.state === 'closed'; }, + isMerged() { + return this.state === 'merged'; + }, hasTitle() { return this.title.length > 0; }, @@ -108,9 +130,17 @@ const mixins = { return !_.isEmpty(this.milestone); }, iconName() { + if (this.isMergeRequest && this.isMerged) { + return 'merge'; + } + return this.isOpen ? 'issue-open-m' : 'issue-close'; }, iconClass() { + if (this.isMergeRequest && this.isClosed) { + return 'merge-request-status closed issue-token-state-icon-closed'; + } + return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed'; }, computedLinkElementType() { @@ -131,12 +161,44 @@ const mixins = { createdAtTimestamp() { return this.createdAt ? formatDate(new Date(this.createdAt)) : ''; }, + mergedAtTimestamp() { + return this.mergedAt ? formatDate(new Date(this.mergedAt)) : ''; + }, + mergedAtInWords() { + return this.mergedAt ? this.timeFormated(this.mergedAt) : ''; + }, closedAtInWords() { return this.closedAt ? this.timeFormated(this.closedAt) : ''; }, closedAtTimestamp() { return this.closedAt ? formatDate(new Date(this.closedAt)) : ''; }, + stateText() { + if (this.isMerged) { + return __('Merged'); + } + + return this.isOpen ? __('Opened') : __('Closed'); + }, + stateTimeInWords() { + if (this.isMerged) { + return this.mergedAtInWords; + } + + return this.isOpen ? this.createdAtInWords : this.closedAtInWords; + }, + stateTimestamp() { + if (this.isMerged) { + return this.mergedAtTimestamp; + } + + return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp; + }, + pipelineStatusTooltip() { + return this.hasPipeline + ? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label }) + : ''; + }, }, methods: { onRemoveRequest() { |