diff options
Diffstat (limited to 'app/assets/javascripts')
271 files changed, 6597 insertions, 2930 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 908dc730aa4..aee9990bc0b 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -2,6 +2,8 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; import { joinPaths } from './lib/utils/url_utility'; +import flash from '~/flash'; +import { __ } from '~/locale'; const Api = { groupsPath: '/api/:version/groups.json', @@ -29,6 +31,7 @@ const Api = { usersPath: '/api/:version/users.json', userPath: '/api/:version/users/:id', userStatusPath: '/api/:version/users/:id/status', + userProjectsPath: '/api/:version/users/:id/projects', userPostStatusPath: '/api/:version/user/status', commitPath: '/api/:version/projects/:id/repository/commits', applySuggestionPath: '/api/:version/suggestions/:id/apply', @@ -110,10 +113,9 @@ const Api = { .get(url, { params: Object.assign(defaults, options), }) - .then(({ data }) => { + .then(({ data, headers }) => { callback(data); - - return data; + return { data, headers }; }); }, @@ -239,7 +241,8 @@ const Api = { .get(url, { params: Object.assign({}, defaults, options), }) - .then(({ data }) => callback(data)); + .then(({ data }) => callback(data)) + .catch(() => flash(__('Something went wrong while fetching projects'))); }, commitMultiple(id, data) { @@ -348,6 +351,20 @@ const Api = { }); }, + userProjects(userId, query, options, callback) { + const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId); + const defaults = { + search: query, + per_page: 20, + }; + return axios + .get(url, { + params: Object.assign({}, defaults, options), + }) + .then(({ data }) => callback(data)) + .catch(() => flash(__('Something went wrong while fetching projects'))); + }, + branches(id, query = '', options = {}) { const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index a07942d87cb..ca91400eac7 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var */ +/* eslint-disable func-names */ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; @@ -12,11 +12,8 @@ import { __ } from '~/locale'; // more than `x` users are referenced. // -var lastTextareaPreviewed; -var lastTextareaHeight = null; -var markdownPreview; -var previewButtonSelector; -var writeButtonSelector; +let lastTextareaHeight; +let lastTextareaPreviewed; function MarkdownPreview() {} @@ -27,14 +24,13 @@ MarkdownPreview.prototype.emptyMessage = __('Nothing to preview.'); MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function($form) { - var mdText; - var preview = $form.find('.js-md-preview'); - var url = preview.data('url'); + const preview = $form.find('.js-md-preview'); + const url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } - mdText = $form.find('textarea.markdown-area').val(); + const mdText = $form.find('textarea.markdown-area').val(); if (mdText === undefined) { return; @@ -46,7 +42,7 @@ MarkdownPreview.prototype.showPreview = function($form) { } else { preview.addClass('md-preview-loading').text(__('Loading...')); this.fetchMarkdownPreview(mdText, url, response => { - var body; + let body; if (response.body.length > 0) { ({ body } = response); } else { @@ -91,8 +87,7 @@ MarkdownPreview.prototype.hideReferencedUsers = function($form) { }; MarkdownPreview.prototype.renderReferencedUsers = function(users, $form) { - var referencedUsers; - referencedUsers = $form.find('.referenced-users'); + const referencedUsers = $form.find('.referenced-users'); if (referencedUsers.length) { if (users.length >= this.referenceThreshold) { referencedUsers.show(); @@ -108,8 +103,7 @@ MarkdownPreview.prototype.hideReferencedCommands = function($form) { }; MarkdownPreview.prototype.renderReferencedCommands = function(commands, $form) { - var referencedCommands; - referencedCommands = $form.find('.referenced-commands'); + const referencedCommands = $form.find('.referenced-commands'); if (commands.length > 0) { referencedCommands.html(commands); referencedCommands.show(); @@ -119,15 +113,15 @@ MarkdownPreview.prototype.renderReferencedCommands = function(commands, $form) { } }; -markdownPreview = new MarkdownPreview(); +const markdownPreview = new MarkdownPreview(); -previewButtonSelector = '.js-md-preview-button'; -writeButtonSelector = '.js-md-write-button'; +const previewButtonSelector = '.js-md-preview-button'; +const writeButtonSelector = '.js-md-write-button'; lastTextareaPreviewed = null; const markdownToolbar = $('.md-header-toolbar'); $.fn.setupMarkdownPreview = function() { - var $form = $(this); + const $form = $(this); $form.find('textarea.markdown-area').on('input', () => { markdownPreview.hideReferencedUsers($form); }); @@ -188,7 +182,7 @@ $(document).on('markdown-preview:hide', (e, $form) => { }); $(document).on('markdown-preview:toggle', (e, keyboardEvent) => { - var $target; + let $target; $target = $(keyboardEvent.target); if ($target.is('textarea.markdown-area')) { $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]); @@ -201,16 +195,14 @@ $(document).on('markdown-preview:toggle', (e, keyboardEvent) => { }); $(document).on('click', previewButtonSelector, function(e) { - var $form; e.preventDefault(); - $form = $(this).closest('form'); + const $form = $(this).closest('form'); $(document).triggerHandler('markdown-preview:show', [$form]); }); $(document).on('click', writeButtonSelector, function(e) { - var $form; e.preventDefault(); - $form = $(this).closest('form'); + const $form = $(this).closest('form'); $(document).triggerHandler('markdown-preview:hide', [$form]); }); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index b371f6be268..aedd8004ea5 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -118,8 +118,6 @@ export default class FileTemplateMediator { } }); - this.setFilename(item.name); - if (this.editor.getValue() !== '') { this.setTypeSelectorToggleText(item.name); } @@ -133,14 +131,16 @@ export default class FileTemplateMediator { selectTemplateFile(selector, query, data) { const self = this; + const { name } = selector.config; selector.renderLoading(); this.fetchFileTemplate(selector.config.type, query, data) .then(file => { this.setEditorContent(file); + this.setFilename(name); selector.renderLoaded(); - this.typeSelector.setToggleText(selector.config.name); + this.typeSelector.setToggleText(name); toast(__(`${query} template applied`), { action: { text: __('Undo'), diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 34560560756..c0df8b72095 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -133,7 +133,7 @@ export default { if (this.board.name.length === 0) return; this.isLoading = true; if (this.isDeleteForm) { - gl.boardService + boardsStore .deleteBoard(this.currentBoard) .then(() => { visitUrl(boardsStore.rootPath); @@ -143,7 +143,7 @@ export default { this.isLoading = false; }); } else { - gl.boardService + boardsStore .createBoard(this.board) .then(resp => resp.data) .then(data => { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1273fcc6a91..b8439bc8741 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -84,7 +84,8 @@ export default { this.$nextTick(() => { if ( this.scrollHeight() <= this.listHeight() && - this.list.issuesSize > this.list.issues.length + this.list.issuesSize > this.list.issues.length && + this.list.isExpanded ) { this.list.page += 1; this.list.getIssues(false).catch(() => { diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 334c162954e..32491dfbcb6 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -168,7 +168,7 @@ export default { } const recentBoardsPromise = new Promise((resolve, reject) => - gl.boardService + boardsStore .recentBoards() .then(resolve) .catch(err => { @@ -184,7 +184,7 @@ export default { }), ); - Promise.all([gl.boardService.allBoards(), recentBoardsPromise]) + Promise.all([boardsStore.allBoards(), recentBoardsPromise]) .then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data]) .then(([allBoardsJson, recentBoardsJson]) => { this.loading = false; diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 40d75d53f75..d37e49bab46 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -1,5 +1,6 @@ <script> import _ from 'underscore'; +import { mapState } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; @@ -63,6 +64,7 @@ export default { }; }, computed: { + ...mapState(['isShowingLabels']), numberOverLimit() { return this.issue.assignees.length - this.limitBeforeCounter; }, @@ -92,7 +94,7 @@ export default { return false; }, showLabelFooter() { - return this.issue.labels.find(l => this.showLabel(l)) !== undefined; + return this.isShowingLabels && this.issue.labels.find(this.showLabel); }, issueReferencePath() { const { referencePath, groupId } = this.issue; diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index defa1f75ba2..618c2ada1f8 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -1,6 +1,7 @@ <script> /* global ListIssue */ import { urlParamsToObject } from '~/lib/utils/common_utils'; +import boardsStore from '~/boards/stores/boards_store'; import ModalHeader from './header.vue'; import ModalList from './list.vue'; import ModalFooter from './footer.vue'; @@ -109,7 +110,7 @@ export default { loadIssues(clearIssues = false) { if (!this.showAddIssuesModal) return false; - return gl.boardService + return boardsStore .getBacklog({ ...urlParamsToObject(this.filter.path), page: this.page, diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index befca70eeae..e76e2341dfd 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -13,6 +13,7 @@ import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; import '~/boards/models/milestone'; import '~/boards/models/project'; +import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import ModalStore from '~/boards/stores/modal_store'; import BoardService from 'ee_else_ce/boards/services/board_service'; @@ -29,6 +30,7 @@ import { } from '~/lib/utils/common_utils'; import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; import toggleFocusMode from 'ee_else_ce/boards/toggle_focus'; +import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import { setPromotionState, setWeigthFetchingState, @@ -67,6 +69,7 @@ export default () => { BoardSidebar, BoardAddIssuesModal, }, + store, data: { state: boardsStore.state, loading: true, @@ -314,5 +317,6 @@ export default () => { } toggleFocusMode(ModalStore, boardsStore, $boardApp); + toggleLabels(); mountMultipleBoardsSwitcher(); }; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 1e213c324eb..bb8c8e68297 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -50,8 +50,8 @@ class List { this.page = 1; this.loading = true; this.loadingMore = false; - this.issues = []; - this.issuesSize = 0; + this.issues = obj.issues || []; + this.issuesSize = obj.issuesSize ? obj.issuesSize : 0; this.defaultAvatar = defaultAvatar; if (obj.label) { diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js new file mode 100644 index 00000000000..4de1576099d --- /dev/null +++ b/app/assets/javascripts/boards/stores/getters.js @@ -0,0 +1,3 @@ +export default { + getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), +}; diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js index f70395a3771..471b952a212 100644 --- a/app/assets/javascripts/boards/stores/index.js +++ b/app/assets/javascripts/boards/stores/index.js @@ -1,14 +1,18 @@ import Vue from 'vue'; import Vuex from 'vuex'; import state from 'ee_else_ce/boards/stores/state'; +import getters from 'ee_else_ce/boards/stores/getters'; import actions from 'ee_else_ce/boards/stores/actions'; import mutations from 'ee_else_ce/boards/stores/mutations'; Vue.use(Vuex); -export default () => +export const createStore = () => new Vuex.Store({ state, + getters, actions, mutations, }); + +export default createStore(); diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index dd16abb01a5..24f44dc5629 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,3 +1,3 @@ export default () => ({ - // ... + isShowingLabels: true, }); diff --git a/app/assets/javascripts/boards/toggle_labels.js b/app/assets/javascripts/boards/toggle_labels.js new file mode 100644 index 00000000000..2d1ec238274 --- /dev/null +++ b/app/assets/javascripts/boards/toggle_labels.js @@ -0,0 +1 @@ +export default () => {}; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 7ea8901ecbb..75909dd9d20 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -8,11 +8,12 @@ import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } from './constants'; +import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX, CROSSPLANE } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; import setupToggleButtons from '../toggle_buttons'; +import initProjectSelectDropdown from '~/project_select'; const Environments = () => import('ee_component/clusters/components/environments.vue'); @@ -37,6 +38,8 @@ export default class Clusters { installJupyterPath, installKnativePath, updateKnativePath, + installElasticStackPath, + installCrossplanePath, installPrometheusPath, managePrometheusPath, clusterEnvironmentsPath, @@ -81,11 +84,13 @@ export default class Clusters { installHelmEndpoint: installHelmPath, installIngressEndpoint: installIngressPath, installCertManagerEndpoint: installCertManagerPath, + installCrossplaneEndpoint: installCrossplanePath, installRunnerEndpoint: installRunnerPath, installPrometheusEndpoint: installPrometheusPath, installJupyterEndpoint: installJupyterPath, installKnativeEndpoint: installKnativePath, updateKnativeEndpoint: updateKnativePath, + installElasticStackEndpoint: installElasticStackPath, clusterEnvironmentsEndpoint: clusterEnvironmentsPath, }); @@ -108,8 +113,10 @@ export default class Clusters { this.ingressDomainHelpText && this.ingressDomainHelpText.querySelector('.js-ingress-domain-snippet'); + initProjectSelectDropdown(); Clusters.initDismissableCallout(); initSettingsPanels(); + const toggleButtonsContainer = document.querySelector('.js-cluster-enable-toggle-area'); if (toggleButtonsContainer) { setupToggleButtons(toggleButtonsContainer); @@ -222,6 +229,7 @@ export default class Clusters { eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data)); eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data)); eventHub.$on('uninstallApplication', data => this.uninstallApplication(data)); + eventHub.$on('setCrossplaneProviderStack', data => this.setCrossplaneProviderStack(data)); // Add event listener to all the banner close buttons this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); @@ -233,6 +241,7 @@ export default class Clusters { eventHub.$off('updateApplication', this.updateApplication); eventHub.$off('saveKnativeDomain'); eventHub.$off('setKnativeHostname'); + eventHub.$off('setCrossplaneProviderStack'); eventHub.$off('uninstallApplication'); } @@ -399,18 +408,33 @@ export default class Clusters { } installApplication({ id: appId, params }) { - this.store.updateAppProperty(appId, 'requestReason', null); - this.store.updateAppProperty(appId, 'statusReason', null); + return Clusters.validateInstallation(appId, params) + .then(() => { + this.store.updateAppProperty(appId, 'requestReason', null); + this.store.updateAppProperty(appId, 'statusReason', null); + this.store.installApplication(appId); + + // eslint-disable-next-line promise/no-nesting + this.service.installApplication(appId, params).catch(() => { + this.store.notifyInstallFailure(appId); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin installing failed'), + ); + }); + }) + .catch(error => this.store.updateAppProperty(appId, 'validationError', error)); + } - this.store.installApplication(appId); + static validateInstallation(appId, params) { + return new Promise((resolve, reject) => { + if (appId === CROSSPLANE && !params.stack) { + reject(s__('ClusterIntegration|Select a stack to install Crossplane.')); + return; + } - return this.service.installApplication(appId, params).catch(() => { - this.store.notifyInstallFailure(appId); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin installing failed'), - ); + resolve(); }); } @@ -458,6 +482,12 @@ export default class Clusters { this.store.updateAppProperty(appId, 'hostname', data.hostname); } + setCrossplaneProviderStack(data) { + const appId = data.id; + this.store.updateAppProperty(appId, 'stack', data.stack.code); + this.store.updateAppProperty(appId, 'validationError', null); + } + destroy() { this.destroyed = true; diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index b95f97077f6..a951a6bfeea 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -9,9 +9,11 @@ import jeagerLogo from 'images/cluster_app_logos/jeager.png'; import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png'; import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png'; import certManagerLogo from 'images/cluster_app_logos/cert_manager.png'; +import crossplaneLogo from 'images/cluster_app_logos/crossplane.png'; import knativeLogo from 'images/cluster_app_logos/knative.png'; import meltanoLogo from 'images/cluster_app_logos/meltano.png'; import prometheusLogo from 'images/cluster_app_logos/prometheus.png'; +import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png'; import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; @@ -19,6 +21,7 @@ import KnativeDomainEditor from './knative_domain_editor.vue'; import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '~/clusters/event_hub'; +import CrossplaneProviderStack from './crossplane_provider_stack.vue'; export default { components: { @@ -27,6 +30,7 @@ export default { LoadingButton, GlLoadingIcon, KnativeDomainEditor, + CrossplaneProviderStack, }, props: { type: { @@ -88,9 +92,11 @@ export default { jupyterhubLogo, kubernetesLogo, certManagerLogo, + crossplaneLogo, knativeLogo, meltanoLogo, prometheusLogo, + elasticStackLogo, }), computed: { isProjectCluster() { @@ -114,6 +120,15 @@ export default { certManagerInstalled() { return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED; }, + crossplaneInstalled() { + return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED; + }, + enableClusterApplicationCrossplane() { + return gon.features && gon.features.enableClusterApplicationCrossplane; + }, + enableClusterApplicationElasticStack() { + return gon.features && gon.features.enableClusterApplicationElasticStack; + }, ingressDescription() { return sprintf( _.escape( @@ -146,6 +161,24 @@ export default { false, ); }, + crossplaneDescription() { + return sprintf( + _.escape( + s__( + `ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{kubectl} or %{gitlabIntegrationLink}. +Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`, + ), + ), + { + gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/crossplane.html" + target="_blank" rel="noopener noreferrer"> + ${_.escape(s__('ClusterIntegration|Gitlab Integration'))}</a>`, + kubectl: `<code>kubectl</code>`, + }, + false, + ); + }, + prometheusDescription() { return sprintf( _.escape( @@ -168,9 +201,18 @@ export default { jupyterHostname() { return this.applications.jupyter.hostname; }, + elasticStackInstalled() { + return this.applications.elastic_stack.status === APPLICATION_STATUS.INSTALLED; + }, + elasticStackKibanaHostname() { + return this.applications.elastic_stack.kibana_hostname; + }, knative() { return this.applications.knative; }, + crossplane() { + return this.applications.crossplane; + }, cloudRun() { return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative; }, @@ -207,6 +249,12 @@ export default { hostname, }); }, + setCrossplaneProviderStack(stack) { + eventHub.$emit('setCrossplaneProviderStack', { + id: 'crossplane', + stack, + }); + }, }, }; </script> @@ -217,7 +265,7 @@ export default { <p class="append-bottom-0"> {{ s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster. - Helm Tiller is required to install any of the following applications.`) + Helm Tiller is required to install any of the following applications.`) }} <a :href="helpPath">{{ __('More information') }}</a> </p> @@ -242,9 +290,9 @@ export default { <div slot="description"> {{ s__(`ClusterIntegration|Helm streamlines installing - and managing Kubernetes applications. - Tiller runs inside of your Kubernetes Cluster, - and manages releases of your charts.`) + and managing Kubernetes applications. + Tiller runs inside of your Kubernetes Cluster, + and manages releases of your charts.`) }} </div> </application-row> @@ -252,7 +300,7 @@ export default { <div class="svg-container" v-html="helmInstallIllustration"></div> {{ s__(`ClusterIntegration|You must first install Helm Tiller before - installing the applications below`) + installing the applications below`) }} </div> <application-row @@ -275,8 +323,8 @@ export default { <p> {{ s__(`ClusterIntegration|Ingress gives you a way to route - requests to services based on the request host or path, - centralizing a number of services into a single entrypoint.`) + requests to services based on the request host or path, + centralizing a number of services into a single entrypoint.`) }} </p> @@ -308,8 +356,8 @@ export default { <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Point a wildcard DNS to this - generated endpoint in order to access - your application after it has been deployed.`) + generated endpoint in order to access + your application after it has been deployed.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -320,8 +368,8 @@ export default { <p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message"> {{ s__(`ClusterIntegration|The endpoint is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) + the process of being assigned. Please check your Kubernetes + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -368,7 +416,7 @@ export default { <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Issuers represent a certificate authority. - You must provide an email address for your Issuer. `) + You must provide an email address for your Issuer. `) }} <a href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email" @@ -424,13 +472,41 @@ export default { <div slot="description"> {{ s__(`ClusterIntegration|GitLab Runner connects to the - repository and executes CI/CD jobs, - pushing results back and deploying - applications to production.`) + repository and executes CI/CD jobs, + pushing results back and deploying + applications to production.`) }} </div> </application-row> <application-row + v-if="enableClusterApplicationCrossplane" + id="crossplane" + :logo-url="crossplaneLogo" + :title="applications.crossplane.title" + :status="applications.crossplane.status" + :status-reason="applications.crossplane.statusReason" + :request-status="applications.crossplane.requestStatus" + :request-reason="applications.crossplane.requestReason" + :installed="applications.crossplane.installed" + :install-failed="applications.crossplane.installFailed" + :uninstallable="applications.crossplane.uninstallable" + :uninstall-successful="applications.crossplane.uninstallSuccessful" + :uninstall-failed="applications.crossplane.uninstallFailed" + :install-application-request-params="{ stack: applications.crossplane.stack }" + :disabled="!helmInstalled" + title-link="https://crossplane.io" + > + <template> + <div slot="description"> + <p v-html="crossplaneDescription"></p> + <div class="form-group"> + <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" /> + </div> + </div> + </template> + </application-row> + + <application-row id="jupyter" :logo-url="jupyterhubLogo" :title="applications.jupyter.title" @@ -451,10 +527,10 @@ export default { <p> {{ s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns, - manages, and proxies multiple instances of the single-user - Jupyter notebook server. JupyterHub can be used to serve - notebooks to a class of students, a corporate data science group, - or a scientific research group.`) + manages, and proxies multiple instances of the single-user + Jupyter notebook server. JupyterHub can be used to serve + notebooks to a class of students, a corporate data science group, + or a scientific research group.`) }} </p> @@ -481,7 +557,7 @@ export default { <p v-if="ingressInstalled" class="form-text text-muted"> {{ s__(`ClusterIntegration|Replace this with your own hostname if you want. - If you do so, point hostname to Ingress IP Address from above.`) + If you do so, point hostname to Ingress IP Address from above.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -527,9 +603,9 @@ export default { <p> {{ s__(`ClusterIntegration|Knative extends Kubernetes to provide - a set of middleware components that are essential to build modern, - source-centric, and container-based applications that can run - anywhere: on premises, in the cloud, or even in a third-party data center.`) + a set of middleware components that are essential to build modern, + source-centric, and container-based applications that can run + anywhere: on premises, in the cloud, or even in a third-party data center.`) }} </p> @@ -542,6 +618,75 @@ export default { /> </div> </application-row> + <application-row + v-if="enableClusterApplicationElasticStack" + id="elastic_stack" + :logo-url="elasticStackLogo" + :title="applications.elastic_stack.title" + :status="applications.elastic_stack.status" + :status-reason="applications.elastic_stack.statusReason" + :request-status="applications.elastic_stack.requestStatus" + :request-reason="applications.elastic_stack.requestReason" + :version="applications.elastic_stack.version" + :chart-repo="applications.elastic_stack.chartRepo" + :update-available="applications.elastic_stack.updateAvailable" + :installed="applications.elastic_stack.installed" + :install-failed="applications.elastic_stack.installFailed" + :update-successful="applications.elastic_stack.updateSuccessful" + :update-failed="applications.elastic_stack.updateFailed" + :uninstallable="applications.elastic_stack.uninstallable" + :uninstall-successful="applications.elastic_stack.uninstallSuccessful" + :uninstall-failed="applications.elastic_stack.uninstallFailed" + :disabled="!helmInstalled" + :install-application-request-params="{ + kibana_hostname: applications.elastic_stack.kibana_hostname, + }" + title-link="https://github.com/helm/charts/tree/master/stable/elastic-stack" + > + <div slot="description"> + <p> + {{ + s__( + `ClusterIntegration|The elastic stack collects logs from all pods in your cluster`, + ) + }} + </p> + + <template v-if="ingressExternalEndpoint"> + <div class="form-group"> + <label for="elastic-stack-kibana-hostname">{{ + s__('ClusterIntegration|Kibana Hostname') + }}</label> + + <div class="input-group"> + <input + v-model="applications.elastic_stack.kibana_hostname" + :readonly="elasticStackInstalled" + type="text" + class="form-control js-hostname" + /> + <span class="input-group-btn"> + <clipboard-button + :text="elasticStackKibanaHostname" + :title="s__('ClusterIntegration|Copy Kibana Hostname')" + class="js-clipboard-btn" + /> + </span> + </div> + + <p v-if="ingressInstalled" class="form-text text-muted"> + {{ + s__(`ClusterIntegration|Replace this with your own hostname if you want. + If you do so, point hostname to Ingress IP Address from above.`) + }} + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> + </p> + </div> + </template> + </div> + </application-row> </div> </section> </template> diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue new file mode 100644 index 00000000000..966918ae636 --- /dev/null +++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue @@ -0,0 +1,93 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { s__ } from '../../locale'; + +export default { + name: 'CrossplaneProviderStack', + components: { + GlDropdown, + GlDropdownItem, + Icon, + }, + props: { + stacks: { + type: Array, + required: false, + default: () => [ + { + name: s__('Google Cloud Platform'), + code: 'gcp', + }, + { + name: s__('Amazon Web Services'), + code: 'aws', + }, + { + name: s__('Microsoft Azure'), + code: 'azure', + }, + { + name: s__('Rook'), + code: 'rook', + }, + ], + }, + crossplane: { + type: Object, + required: true, + }, + }, + computed: { + dropdownText() { + const result = this.stacks.reduce((map, obj) => { + // eslint-disable-next-line no-param-reassign + map[obj.code] = obj.name; + return map; + }, {}); + const { stack } = this.crossplane; + if (stack !== '') { + return result[stack]; + } + return s__('Select Stack'); + }, + validationError() { + return this.crossplane.validationError; + }, + }, + methods: { + selectStack(stack) { + this.$emit('set', stack); + }, + }, +}; +</script> + +<template> + <div> + <label> + {{ s__('ClusterIntegration|Enabled stack') }} + </label> + <gl-dropdown + :disabled="crossplane.installed" + :text="dropdownText" + toggle-class="dropdown-menu-toggle gl-field-error-outline" + class="w-100" + :class="{ 'gl-show-field-errors': validationError }" + > + <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)"> + <span class="ml-1">{{ stack.name }}</span> + </gl-dropdown-item> + </gl-dropdown> + <span v-if="validationError" class="gl-field-error">{{ validationError }}</span> + <p class="form-text text-muted"> + {{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }} + <a + href="https://crossplane.io/docs/master/stacks-guide.html" + target="_blank" + rel="noopener noreferrer" + >{{ __('Crossplane') }}</a + > + </p> + </div> +</template> diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index f1925c243f2..125bcaacc1c 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -2,7 +2,16 @@ import { GlModal } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click'; -import { HELM, INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants'; +import { + HELM, + INGRESS, + CERT_MANAGER, + PROMETHEUS, + RUNNER, + KNATIVE, + JUPYTER, + ELASTIC_STACK, +} from '../constants'; const CUSTOM_APP_WARNING_TEXT = { [HELM]: sprintf( @@ -28,6 +37,7 @@ const CUSTOM_APP_WARNING_TEXT = { [JUPYTER]: s__( 'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.', ), + [ELASTIC_STACK]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'), }; export default { diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index c6e4b7951cf..9f98f170fb0 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -50,8 +50,19 @@ export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; export const RUNNER = 'runner'; export const CERT_MANAGER = 'cert_manager'; +export const CROSSPLANE = 'crossplane'; export const PROMETHEUS = 'prometheus'; +export const ELASTIC_STACK = 'elastic_stack'; -export const APPLICATIONS = [HELM, INGRESS, JUPYTER, KNATIVE, RUNNER, CERT_MANAGER, PROMETHEUS]; +export const APPLICATIONS = [ + HELM, + INGRESS, + JUPYTER, + KNATIVE, + RUNNER, + CERT_MANAGER, + PROMETHEUS, + ELASTIC_STACK, +]; export const INGRESS_DOMAIN_SUFFIX = '.nip.io'; diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index fa12802b3de..333fb293a15 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -7,10 +7,12 @@ export default class ClusterService { helm: this.options.installHelmEndpoint, ingress: this.options.installIngressEndpoint, cert_manager: this.options.installCertManagerEndpoint, + crossplane: this.options.installCrossplaneEndpoint, runner: this.options.installRunnerEndpoint, prometheus: this.options.installPrometheusEndpoint, jupyter: this.options.installJupyterEndpoint, knative: this.options.installKnativeEndpoint, + elastic_stack: this.options.installElasticStackEndpoint, }; this.appUpdateEndpointMap = { knative: this.options.updateKnativeEndpoint, diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 6464461ea0c..35dbf951551 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -5,6 +5,8 @@ import { JUPYTER, KNATIVE, CERT_MANAGER, + ELASTIC_STACK, + CROSSPLANE, RUNNER, APPLICATION_INSTALLED_STATUSES, APPLICATION_STATUS, @@ -25,6 +27,7 @@ const applicationInitialState = { uninstallable: false, uninstallFailed: false, uninstallSuccessful: false, + validationError: null, }; export default class ClusterStore { @@ -57,6 +60,11 @@ export default class ClusterStore { title: s__('ClusterIntegration|Cert-Manager'), email: null, }, + crossplane: { + ...applicationInitialState, + title: s__('ClusterIntegration|Crossplane'), + stack: null, + }, runner: { ...applicationInitialState, title: s__('ClusterIntegration|GitLab Runner'), @@ -85,6 +93,11 @@ export default class ClusterStore { updateSuccessful: false, updateFailed: false, }, + elastic_stack: { + ...applicationInitialState, + title: s__('ClusterIntegration|Elastic Stack'), + kibana_hostname: null, + }, }, environments: [], fetchingEnvironments: false, @@ -197,13 +210,15 @@ export default class ClusterStore { } else if (appId === CERT_MANAGER) { this.state.applications.cert_manager.email = this.state.applications.cert_manager.email || serverAppEntry.email; + } else if (appId === CROSSPLANE) { + this.state.applications.crossplane.stack = + this.state.applications.crossplane.stack || serverAppEntry.stack; } else if (appId === JUPYTER) { - this.state.applications.jupyter.hostname = - this.state.applications.jupyter.hostname || - serverAppEntry.hostname || - (this.state.applications.ingress.externalIp - ? `jupyter.${this.state.applications.ingress.externalIp}.nip.io` - : ''); + this.state.applications.jupyter.hostname = this.updateHostnameIfUnset( + this.state.applications.jupyter.hostname, + serverAppEntry.hostname, + 'jupyter', + ); } else if (appId === KNATIVE) { if (!this.state.applications.knative.isEditingHostName) { this.state.applications.knative.hostname = @@ -216,10 +231,26 @@ export default class ClusterStore { } else if (appId === RUNNER) { this.state.applications.runner.version = version; this.state.applications.runner.updateAvailable = updateAvailable; + } else if (appId === ELASTIC_STACK) { + this.state.applications.elastic_stack.kibana_hostname = this.updateHostnameIfUnset( + this.state.applications.elastic_stack.kibana_hostname, + serverAppEntry.kibana_hostname, + 'kibana', + ); } }); } + updateHostnameIfUnset(current, updated, fallback) { + return ( + current || + updated || + (this.state.applications.ingress.externalIp + ? `${fallback}.${this.state.applications.ingress.externalIp}.nip.io` + : '') + ); + } + toggleFetchEnvironments(isFetching) { this.state.fetchingEnvironments = isFetching; } diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 6c04e0beb4d..60c2059a876 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign, no-unused-expressions, no-sequences */ +/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign */ import $ from 'jquery'; @@ -9,40 +9,29 @@ const viewModes = ['two-up', 'swipe']; export default class ImageFile { constructor(file) { this.file = file; - this.requestImageInfo( - $('.two-up.view .frame.deleted img', this.file), - (function(_this) { - return function() { - return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), () => { - _this.initViewModes(); - - // Load two-up view after images are loaded - // so that we can display the correct width and height information - const $images = $('.two-up.view img', _this.file); - - $images.waitForImages(() => { - _this.initView('two-up'); - }); - }); - }; - })(this), + this.requestImageInfo($('.two-up.view .frame.deleted img', this.file), () => + this.requestImageInfo($('.two-up.view .frame.added img', this.file), () => { + this.initViewModes(); + + // Load two-up view after images are loaded + // so that we can display the correct width and height information + const $images = $('.two-up.view img', this.file); + + $images.waitForImages(() => { + this.initView('two-up'); + }); + }), ); } initViewModes() { const viewMode = viewModes[0]; $('.view-modes', this.file).removeClass('hide'); - $('.view-modes-menu', this.file).on( - 'click', - 'li', - (function(_this) { - return function(event) { - if (!$(event.currentTarget).hasClass('active')) { - return _this.activateViewMode(event.currentTarget.className); - } - }; - })(this), - ); + $('.view-modes-menu', this.file).on('click', 'li', event => { + if (!$(event.currentTarget).hasClass('active')) { + return this.activateViewMode(event.currentTarget.className); + } + }); return this.activateViewMode(viewMode); } @@ -51,15 +40,10 @@ export default class ImageFile { .removeClass('active') .filter(`.${viewMode}`) .addClass('active'); - return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut( - 200, - (function(_this) { - return function() { - $(`.view.${viewMode}`, _this.file).fadeIn(200); - return _this.initView(viewMode); - }; - })(this), - ); + return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(200, () => { + $(`.view.${viewMode}`, this.file).fadeIn(200); + return this.initView(viewMode); + }); } initView(viewMode) { @@ -103,22 +87,18 @@ export default class ImageFile { .on('touchmove', dragMove); } - prepareFrames(view) { + static prepareFrames(view) { var maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; $('.frame', view) - .each( - (function() { - return function(index, frame) { - var height, width; - width = $(frame).width(); - height = $(frame).height(); - maxWidth = width > maxWidth ? width : maxWidth; - return (maxHeight = height > maxHeight ? height : maxHeight); - }; - })(this), - ) + .each((index, frame) => { + var height, width; + width = $(frame).width(); + height = $(frame).height(); + maxWidth = width > maxWidth ? width : maxWidth; + return (maxHeight = height > maxHeight ? height : maxHeight); + }) .css({ width: maxWidth, height: maxHeight, @@ -128,104 +108,95 @@ export default class ImageFile { views = { 'two-up': function() { - return $('.two-up.view .wrap', this.file).each( - (function(_this) { - return function(index, wrap) { - $('img', wrap).each(function() { - var currentWidth; - currentWidth = $(this).width(); - if (currentWidth > availWidth / 2) { - return $(this).width(availWidth / 2); - } - }); - return _this.requestImageInfo($('img', wrap), (width, height) => { - $('.image-info .meta-width', wrap).text(`${width}px`); - $('.image-info .meta-height', wrap).text(`${height}px`); - return $('.image-info', wrap).removeClass('hide'); - }); - }; - })(this), - ); + return $('.two-up.view .wrap', this.file).each((index, wrap) => { + $('img', wrap).each(function() { + var currentWidth; + currentWidth = $(this).width(); + if (currentWidth > availWidth / 2) { + return $(this).width(availWidth / 2); + } + }); + return this.requestImageInfo($('img', wrap), (width, height) => { + $('.image-info .meta-width', wrap).text(`${width}px`); + $('.image-info .meta-height', wrap).text(`${height}px`); + return $('.image-info', wrap).removeClass('hide'); + }); + }); }, swipe() { var maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; - return $('.swipe.view', this.file).each( - (function(_this) { - return function(index, view) { - var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding, ref; - (ref = _this.prepareFrames(view)), ([maxWidth, maxHeight] = ref); - $swipeFrame = $('.swipe-frame', view); - $swipeWrap = $('.swipe-wrap', view); - $swipeBar = $('.swipe-bar', view); - - $swipeFrame.css({ - width: maxWidth + 16, - height: maxHeight + 28, - }); - $swipeWrap.css({ - width: maxWidth + 1, - height: maxHeight + 2, - }); - // Set swipeBar left position to match image frame - $swipeBar.css({ - left: 1, - }); - - wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); - - _this.initDraggable($swipeBar, wrapPadding, (e, left) => { - if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) { - $swipeWrap.width(maxWidth + 1 - left); - $swipeBar.css('left', left); - } - }); - }; - })(this), - ); + return $('.swipe.view', this.file).each((index, view) => { + var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding; + const ref = ImageFile.prepareFrames(view); + [maxWidth, maxHeight] = ref; + $swipeFrame = $('.swipe-frame', view); + $swipeWrap = $('.swipe-wrap', view); + $swipeBar = $('.swipe-bar', view); + + $swipeFrame.css({ + width: maxWidth + 16, + height: maxHeight + 28, + }); + $swipeWrap.css({ + width: maxWidth + 1, + height: maxHeight + 2, + }); + // Set swipeBar left position to match image frame + $swipeBar.css({ + left: 1, + }); + + wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); + + this.initDraggable($swipeBar, wrapPadding, (e, left) => { + if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) { + $swipeWrap.width(maxWidth + 1 - left); + $swipeBar.css('left', left); + } + }); + }); }, 'onion-skin': function() { var dragTrackWidth, maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width(); - return $('.onion-skin.view', this.file).each( - (function(_this) { - return function(index, view) { - var $frame, $track, $dragger, $frameAdded, framePadding, ref; - (ref = _this.prepareFrames(view)), ([maxWidth, maxHeight] = ref); - $frame = $('.onion-skin-frame', view); - $frameAdded = $('.frame.added', view); - $track = $('.drag-track', view); - $dragger = $('.dragger', $track); - - $frame.css({ - width: maxWidth + 16, - height: maxHeight + 28, - }); - $('.swipe-wrap', view).css({ - width: maxWidth + 1, - height: maxHeight + 2, - }); - $dragger.css({ - left: dragTrackWidth, - }); - - $frameAdded.css('opacity', 1); - framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); - - _this.initDraggable($dragger, framePadding, (e, left) => { - var opacity = left / dragTrackWidth; - - if (opacity >= 0 && opacity <= 1) { - $dragger.css('left', left); - $frameAdded.css('opacity', opacity); - } - }); - }; - })(this), - ); + return $('.onion-skin.view', this.file).each((index, view) => { + var $frame, $track, $dragger, $frameAdded, framePadding; + + const ref = ImageFile.prepareFrames(view); + [maxWidth, maxHeight] = ref; + $frame = $('.onion-skin-frame', view); + $frameAdded = $('.frame.added', view); + $track = $('.drag-track', view); + $dragger = $('.dragger', $track); + + $frame.css({ + width: maxWidth + 16, + height: maxHeight + 28, + }); + $('.swipe-wrap', view).css({ + width: maxWidth + 1, + height: maxHeight + 2, + }); + $dragger.css({ + left: dragTrackWidth, + }); + + $frameAdded.css('opacity', 1); + framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); + + this.initDraggable($dragger, framePadding, (e, left) => { + var opacity = left / dragTrackWidth; + + if (opacity >= 0 && opacity <= 1) { + $dragger.css('left', left); + $frameAdded.css('opacity', opacity); + } + }); + }); }, }; @@ -235,14 +206,7 @@ export default class ImageFile { if (domImg.complete) { return callback.call(this, domImg.naturalWidth, domImg.naturalHeight); } else { - return img.on( - 'load', - (function(_this) { - return function() { - return callback.call(_this, domImg.naturalWidth, domImg.naturalHeight); - }; - })(this), - ); + return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight)); } } } diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 81ba15577fb..a23707209dc 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, one-var, no-var, no-else-return */ +/* eslint-disable func-names, no-else-return */ import $ from 'jquery'; import { __ } from './locale'; @@ -8,9 +8,8 @@ import { capitalizeFirstCharacter } from './lib/utils/text_utility'; export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { $('.js-compare-dropdown').each(function() { - var $dropdown, selected; - $dropdown = $(this); - selected = $dropdown.data('selected'); + const $dropdown = $(this); + const selected = $dropdown.data('selected'); const $dropdownContainer = $dropdown.closest('.dropdown'); const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer); const $filterInput = $('input[type="search"]', $dropdownContainer); @@ -44,17 +43,16 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( fieldName: $dropdown.data('fieldName'), filterInput: 'input[type="search"]', renderRow(ref) { - var link; + const link = $('<a />') + .attr('href', '#') + .addClass(ref === selected ? 'is-active' : '') + .text(ref) + .attr('data-ref', ref); if (ref.header != null) { return $('<li />') .addClass('dropdown-header') .text(ref.header); } else { - link = $('<a />') - .attr('href', '#') - .addClass(ref === selected ? 'is-active' : '') - .text(ref) - .attr('data-ref', ref); return $('<li />').append(link); } }, diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index 197a0706062..4fa18b19556 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -41,7 +41,7 @@ export default { noForkText() { return sprintf( __( - "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private.", + "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private.", ), { link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' }, false, diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue new file mode 100644 index 00000000000..7dd6b051cb4 --- /dev/null +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -0,0 +1,227 @@ +<script> +import { __ } from '~/locale'; +import _ from 'underscore'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { getDatesInRange } from '~/lib/utils/datetime_utility'; +import { xAxisLabelFormatter, dateFormatter } from '../utils'; + +export default { + components: { + GlAreaChart, + GlLoadingIcon, + }, + props: { + endpoint: { + type: String, + required: true, + }, + branch: { + type: String, + required: true, + }, + }, + data() { + return { + masterChart: null, + individualCharts: [], + svgs: {}, + masterChartHeight: 264, + individualChartHeight: 216, + }; + }, + computed: { + ...mapState(['chartData', 'loading']), + ...mapGetters(['showChart', 'parsedData']), + masterChartData() { + const data = {}; + this.xAxisRange.forEach(date => { + data[date] = this.parsedData.total[date] || 0; + }); + return [ + { + name: __('Commits'), + data: Object.entries(data), + }, + ]; + }, + masterChartOptions() { + return { + ...this.getCommonChartOptions(true), + yAxis: { + name: __('Number of commits'), + }, + grid: { + bottom: 64, + left: 64, + right: 20, + top: 20, + }, + }; + }, + individualChartsData() { + const maxNumberOfIndividualContributorsCharts = 100; + + return Object.keys(this.parsedData.byAuthor) + .map(name => { + const author = this.parsedData.byAuthor[name]; + return { + name, + email: author.email, + commits: author.commits, + dates: [ + { + name: __('Commits'), + data: this.xAxisRange.map(date => [date, author.dates[date] || 0]), + }, + ], + }; + }) + .sort((a, b) => b.commits - a.commits) + .slice(0, maxNumberOfIndividualContributorsCharts); + }, + individualChartOptions() { + return { + ...this.getCommonChartOptions(false), + yAxis: { + name: __('Commits'), + max: this.individualChartYAxisMax, + }, + grid: { + bottom: 27, + left: 64, + right: 20, + top: 8, + }, + }; + }, + individualChartYAxisMax() { + return this.individualChartsData.reduce((acc, item) => { + const values = item.dates[0].data.map(value => value[1]); + return Math.max(acc, ...values); + }, 0); + }, + xAxisRange() { + const dates = Object.keys(this.parsedData.total).sort((a, b) => new Date(a) - new Date(b)); + + const firstContributionDate = new Date(dates[0]); + const lastContributionDate = new Date(dates[dates.length - 1]); + + return getDatesInRange(firstContributionDate, lastContributionDate, dateFormatter); + }, + firstContributionDate() { + return this.xAxisRange[0]; + }, + lastContributionDate() { + return this.xAxisRange[this.xAxisRange.length - 1]; + }, + charts() { + return _.uniq(this.individualCharts); + }, + }, + mounted() { + this.fetchChartData(this.endpoint); + }, + methods: { + ...mapActions(['fetchChartData']), + getCommonChartOptions(isMasterChart) { + return { + xAxis: { + type: 'time', + name: '', + data: this.xAxisRange, + axisLabel: { + formatter: xAxisLabelFormatter, + showMaxLabel: false, + showMinLabel: false, + }, + boundaryGap: false, + splitNumber: isMasterChart ? 24 : 18, + // 28 days + minInterval: 28 * 86400 * 1000, + min: this.firstContributionDate, + max: this.lastContributionDate, + }, + }; + }, + setSvg(name) { + return getSvgIconPathContent(name) + .then(path => { + if (path) { + this.$set(this.svgs, name, `path://${path}`); + } + }) + .catch(() => {}); + }, + onMasterChartCreated(chart) { + this.masterChart = chart; + this.setSvg('scroll-handle') + .then(() => { + this.masterChart.setOption({ + dataZoom: [ + { + type: 'slider', + handleIcon: this.svgs['scroll-handle'], + }, + ], + }); + }) + .catch(() => {}); + this.masterChart.on('datazoom', _.debounce(this.setIndividualChartsZoom, 200)); + }, + onIndividualChartCreated(chart) { + this.individualCharts.push(chart); + }, + setIndividualChartsZoom(options) { + this.charts.forEach(chart => + chart.setOption( + { + dataZoom: { + start: options.start, + end: options.end, + show: false, + }, + }, + { lazyUpdate: true }, + ), + ); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="loading" class="contributors-loader text-center"> + <gl-loading-icon :inline="true" :size="4" /> + </div> + + <div v-else-if="showChart" class="contributors-charts"> + <h4>{{ __('Commits to') }} {{ branch }}</h4> + <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> + <div> + <gl-area-chart + :data="masterChartData" + :option="masterChartOptions" + :height="masterChartHeight" + @created="onMasterChartCreated" + /> + </div> + + <div class="row"> + <div v-for="contributor in individualChartsData" :key="contributor.name" class="col-6"> + <h4>{{ contributor.name }}</h4> + <p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p> + <gl-area-chart + :data="contributor.dates" + :option="individualChartOptions" + :height="individualChartHeight" + @created="onIndividualChartCreated" + /> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js new file mode 100644 index 00000000000..b6063589734 --- /dev/null +++ b/app/assets/javascripts/contributors/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import ContributorsGraphs from './components/contributors.vue'; +import store from './stores'; + +export default () => { + const el = document.querySelector('.js-contributors-graph'); + + if (!el) return null; + + return new Vue({ + el, + store, + + render(createElement) { + return createElement(ContributorsGraphs, { + props: { + endpoint: el.dataset.projectGraphPath, + branch: el.dataset.projectBranch, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/contributors/services/contributors_service.js b/app/assets/javascripts/contributors/services/contributors_service.js new file mode 100644 index 00000000000..5a8bbb66511 --- /dev/null +++ b/app/assets/javascripts/contributors/services/contributors_service.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + fetchChartData(endpoint) { + return axios.get(endpoint); + }, +}; diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js new file mode 100644 index 00000000000..4138ff24f1d --- /dev/null +++ b/app/assets/javascripts/contributors/stores/actions.js @@ -0,0 +1,20 @@ +import flash from '~/flash'; +import { __ } from '~/locale'; +import service from '../services/contributors_service'; +import * as types from './mutation_types'; + +export const fetchChartData = ({ commit }, endpoint) => { + commit(types.SET_LOADING_STATE, true); + + return service + .fetchChartData(endpoint) + .then(res => res.data) + .then(data => { + commit(types.SET_CHART_DATA, data); + commit(types.SET_LOADING_STATE, false); + }) + .catch(() => flash(__('An error occurred while loading chart data'))); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js new file mode 100644 index 00000000000..9e02e3ed9e7 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/getters.js @@ -0,0 +1,33 @@ +export const showChart = state => Boolean(!state.loading && state.chartData); + +export const parsedData = state => { + const byAuthor = {}; + const total = {}; + + state.chartData.forEach(({ date, author_name, author_email }) => { + total[date] = total[date] ? total[date] + 1 : 1; + + const authorData = byAuthor[author_name]; + + if (!authorData) { + byAuthor[author_name] = { + email: author_email.toLowerCase(), + commits: 1, + dates: { + [date]: 1, + }, + }; + } else { + authorData.commits += 1; + authorData.dates[date] = authorData.dates[date] ? authorData.dates[date] + 1 : 1; + } + }); + + return { + total, + byAuthor, + }; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js new file mode 100644 index 00000000000..bc739851aa7 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import mutations from './mutations'; +import * as getters from './getters'; +import * as actions from './actions'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + mutations, + getters, + state: state(), + }); + +export default createStore(); diff --git a/app/assets/javascripts/contributors/stores/mutation_types.js b/app/assets/javascripts/contributors/stores/mutation_types.js new file mode 100644 index 00000000000..62e0a51d5f8 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/mutation_types.js @@ -0,0 +1,3 @@ +export const SET_CHART_DATA = 'SET_CHART_DATA'; +export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_ACTIVE_BRANCH = 'SET_ACTIVE_BRANCH'; diff --git a/app/assets/javascripts/contributors/stores/mutations.js b/app/assets/javascripts/contributors/stores/mutations.js new file mode 100644 index 00000000000..f1f460d072d --- /dev/null +++ b/app/assets/javascripts/contributors/stores/mutations.js @@ -0,0 +1,17 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_LOADING_STATE](state, value) { + state.loading = value; + }, + [types.SET_CHART_DATA](state, chartData) { + Object.assign(state, { + chartData, + }); + }, + [types.SET_ACTIVE_BRANCH](state, branch) { + Object.assign(state, { + branch, + }); + }, +}; diff --git a/app/assets/javascripts/contributors/stores/state.js b/app/assets/javascripts/contributors/stores/state.js new file mode 100644 index 00000000000..1dc1a3c7b75 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/state.js @@ -0,0 +1,5 @@ +export default () => ({ + loading: false, + chartData: null, + branch: 'master', +}); diff --git a/app/assets/javascripts/contributors/utils.js b/app/assets/javascripts/contributors/utils.js new file mode 100644 index 00000000000..7d8932ce495 --- /dev/null +++ b/app/assets/javascripts/contributors/utils.js @@ -0,0 +1,30 @@ +import { getMonthNames } from '~/lib/utils/datetime_utility'; + +/** + * Converts provided string to date and returns formatted value as a year for date in January and month name for the rest + * @param {String} + * @returns {String} - formatted value + * + * xAxisLabelFormatter('01-12-2019') will return '2019' + * xAxisLabelFormatter('02-12-2019') will return 'Feb' + * xAxisLabelFormatter('07-12-2019') will return 'Jul' + */ +export const xAxisLabelFormatter = val => { + const date = new Date(val); + const month = date.getUTCMonth(); + const year = date.getUTCFullYear(); + return month === 0 ? `${year}` : getMonthNames(true)[month]; +}; + +/** + * Formats provided date to YYYY-MM-DD format + * @param {Date} + * @returns {String} - formatted value + */ +export const dateFormatter = date => { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + + return `${year}-${`0${month + 1}`.slice(-2)}-${`0${day}`.slice(-2)}`; +}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue index 3c6da43c4c4..e6893c14cda 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue @@ -2,14 +2,19 @@ import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import { GlIcon } from '@gitlab/ui'; -const findItem = (items, valueProp, value) => items.find(item => item[valueProp] === value); +const toArray = value => [].concat(value); +const itemsProp = (items, prop) => items.map(item => item[prop]); +const defaultSearchFn = (searchQuery, labelProp) => item => + item[labelProp].toLowerCase().indexOf(searchQuery) > -1; export default { components: { DropdownButton, DropdownSearchInput, DropdownHiddenInput, + GlIcon, }, props: { fieldName: { @@ -28,7 +33,7 @@ export default { default: '', }, value: { - type: [Object, String], + type: [Object, Array, String], required: false, default: () => null, }, @@ -72,6 +77,11 @@ export default { required: false, default: false, }, + multiple: { + type: Boolean, + required: false, + default: false, + }, errorMessage: { type: String, required: false, @@ -90,12 +100,11 @@ export default { searchFn: { type: Function, required: false, - default: searchQuery => item => item.name.toLowerCase().indexOf(searchQuery) > -1, + default: defaultSearchFn, }, }, data() { return { - selectedItem: findItem(this.items, this.value), searchQuery: '', }; }, @@ -109,36 +118,52 @@ export default { return this.disabledText; } - if (!this.selectedItem) { + if (!this.selectedItems.length) { return this.placeholder; } - return this.selectedItemLabel; + return this.selectedItemsLabels; }, results() { - if (!this.items) { - return []; - } - - return this.items.filter(this.searchFn(this.searchQuery)); + return this.getItemsOrEmptyList().filter(this.searchFn(this.searchQuery, this.labelProperty)); }, - selectedItemLabel() { - return this.selectedItem && this.selectedItem[this.labelProperty]; + selectedItems() { + const valueProp = this.valueProperty; + const valueList = toArray(this.value); + const items = this.getItemsOrEmptyList(); + + return items.filter(item => valueList.some(value => item[valueProp] === value)); }, - selectedItemValue() { - return (this.selectedItem && this.selectedItem[this.valueProperty]) || ''; + selectedItemsLabels() { + return itemsProp(this.selectedItems, this.labelProperty).join(', '); }, - }, - watch: { - value(value) { - this.selectedItem = findItem(this.items, this.valueProperty, value); + selectedItemsValues() { + return itemsProp(this.selectedItems, this.valueProperty).join(', '); }, }, methods: { - select(item) { - this.selectedItem = item; + getItemsOrEmptyList() { + return this.items || []; + }, + selectSingle(item) { this.$emit('input', item[this.valueProperty]); }, + selectMultiple(item) { + const value = toArray(this.value); + const itemValue = item[this.valueProperty]; + const itemValueIndex = value.indexOf(itemValue); + + if (itemValueIndex > -1) { + value.splice(itemValueIndex, 1); + } else { + value.push(itemValue); + } + + this.$emit('input', value); + }, + isSelected(item) { + return this.selectedItems.includes(item); + }, }, }; </script> @@ -146,7 +171,7 @@ export default { <template> <div> <div class="js-gcp-machine-type-dropdown dropdown"> - <dropdown-hidden-input :name="fieldName" :value="selectedItemValue" /> + <dropdown-hidden-input :name="fieldName" :value="selectedItemsValues" /> <dropdown-button :class="{ 'border-danger': hasErrors }" :is-disabled="disabled" @@ -158,15 +183,28 @@ export default { <div class="dropdown-content"> <ul> <li v-if="!results.length"> - <span class="js-empty-text menu-item"> - {{ emptyText }} - </span> + <span class="js-empty-text menu-item">{{ emptyText }}</span> </li> <li v-for="item in results" :key="item.id"> - <button class="js-dropdown-item" type="button" @click.prevent="select(item)"> - <slot name="item" :item="item"> - {{ item.name }} - </slot> + <button + v-if="multiple" + class="js-dropdown-item d-flex align-items-center" + type="button" + @click.stop.prevent="selectMultiple(item)" + > + <gl-icon + :class="[{ invisible: !isSelected(item) }, 'mr-1']" + name="mobile-issue-close" + /> + <slot name="item" :item="item">{{ item.name }}</slot> + </button> + <button + v-else + class="js-dropdown-item" + type="button" + @click.prevent="selectSingle(item)" + > + <slot name="item" :item="item">{{ item.name }}</slot> </button> </li> </ul> @@ -182,8 +220,7 @@ export default { 'text-muted': !hasErrors, }, ]" + >{{ errorMessage }}</span > - {{ errorMessage }} - </span> </div> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue index 22ee368b8e0..3f7c2204b9f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue @@ -1,4 +1,5 @@ <script> +import { mapState } from 'vuex'; import ServiceCredentialsForm from './service_credentials_form.vue'; import EksClusterConfigurationForm from './eks_cluster_configuration_form.vue'; @@ -16,14 +17,37 @@ export default { type: String, required: true, }, + accountAndExternalIdsHelpPath: { + type: String, + required: true, + }, + createRoleArnHelpPath: { + type: String, + required: true, + }, + externalLinkIcon: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['hasCredentials']), }, }; </script> <template> <div class="js-create-eks-cluster"> <eks-cluster-configuration-form + v-if="hasCredentials" :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath" :kubernetes-integration-help-path="kubernetesIntegrationHelpPath" + :external-link-icon="externalLinkIcon" + /> + <service-credentials-form + v-else + :create-role-arn-help-path="createRoleArnHelpPath" + :account-and-external-ids-help-path="accountAndExternalIdsHelpPath" + :external-link-icon="externalLinkIcon" /> </div> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 1188cf08850..57d5f4f541b 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -4,8 +4,8 @@ import { sprintf, s__ } from '~/locale'; import _ from 'underscore'; import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import ClusterFormDropdown from './cluster_form_dropdown.vue'; -import RegionDropdown from './region_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles'); const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers( @@ -22,13 +22,17 @@ const { mapState: mapSecurityGroupsState, mapActions: mapSecurityGroupsActions, } = createNamespacedHelpers('securityGroups'); +const { + mapState: mapInstanceTypesState, + mapActions: mapInstanceTypesActions, +} = createNamespacedHelpers('instanceTypes'); export default { components: { ClusterFormDropdown, - RegionDropdown, GlFormInput, GlFormCheckbox, + LoadingButton, }, props: { gitlabManagedClusterHelpPath: { @@ -39,6 +43,10 @@ export default { type: String, required: true, }, + externalLinkIcon: { + type: String, + required: true, + }, }, computed: { ...mapState([ @@ -51,7 +59,10 @@ export default { 'selectedSubnet', 'selectedRole', 'selectedSecurityGroup', + 'selectedInstanceType', + 'nodeCount', 'gitlabManagedCluster', + 'isCreatingCluster', ]), ...mapRolesState({ roles: 'items', @@ -83,6 +94,11 @@ export default { isLoadingSecurityGroups: 'isLoadingItems', loadingSecurityGroupsError: 'loadingItemsError', }), + ...mapInstanceTypesState({ + instanceTypes: 'items', + isLoadingInstanceTypes: 'isLoadingItems', + loadingInstanceTypesError: 'loadingItemsError', + }), kubernetesVersions() { return KUBERNETES_VERSIONS; }, @@ -98,6 +114,27 @@ export default { securityGroupDropdownDisabled() { return !this.selectedVpc; }, + createClusterButtonDisabled() { + return ( + !this.clusterName || + !this.environmentScope || + !this.kubernetesVersion || + !this.selectedRegion || + !this.selectedKeyPair || + !this.selectedVpc || + !this.selectedSubnet || + !this.selectedRole || + !this.selectedSecurityGroup || + !this.selectedInstanceType || + !this.nodeCount || + this.isCreatingCluster + ); + }, + createClusterButtonLabel() { + return this.isCreatingCluster + ? s__('ClusterIntegration|Creating Kubernetes cluster') + : s__('ClusterIntegration|Create Kubernetes cluster'); + }, kubernetesIntegrationHelpText() { const escapedUrl = _.escape(this.kubernetesIntegrationHelpPath); @@ -115,11 +152,26 @@ export default { roleDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.', + 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', + ), + { + startLink: + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#role-create" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + regionsDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}.', ), { startLink: - '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -128,11 +180,12 @@ export default { keyPairDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services%{endLink}.', + 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', ), { startLink: '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -141,11 +194,12 @@ export default { vpcDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services%{endLink}.', + 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', ), { startLink: - '<a href="https://console.aws.amazon.com/vpc/home?#vpc" target="_blank" rel="noopener noreferrer">', + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -154,11 +208,12 @@ export default { subnetDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run.', + 'ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run.', ), { startLink: '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -167,11 +222,26 @@ export default { securityGroupDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', + 'ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', ), { startLink: '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + instanceTypesDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}.', + ), + { + startLink: + '<a href="https://aws.amazon.com/ec2/instance-types" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -195,9 +265,12 @@ export default { mounted() { this.fetchRegions(); this.fetchRoles(); + this.fetchInstanceTypes(); }, methods: { ...mapActions([ + 'createCluster', + 'signOut', 'setClusterName', 'setEnvironmentScope', 'setKubernetesVersion', @@ -207,6 +280,8 @@ export default { 'setRole', 'setKeyPair', 'setSecurityGroup', + 'setInstanceType', + 'setNodeCount', 'setGitlabManagedCluster', ]), ...mapRegionsActions({ fetchRegions: 'fetchItems' }), @@ -215,15 +290,22 @@ export default { ...mapRolesActions({ fetchRoles: 'fetchItems' }), ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }), ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }), + ...mapInstanceTypesActions({ fetchInstanceTypes: 'fetchItems' }), setRegionAndFetchVpcsAndKeyPairs(region) { this.setRegion({ region }); + this.setVpc({ vpc: null }); + this.setKeyPair({ keyPair: null }); + this.setSubnet({ subnet: null }); + this.setSecurityGroup({ securityGroup: null }); this.fetchVpcs({ region }); this.fetchKeyPairs({ region }); }, setVpcAndFetchSubnets(vpc) { this.setVpc({ vpc }); - this.fetchSubnets({ vpc }); - this.fetchSecurityGroups({ vpc }); + this.setSubnet({ subnet: null }); + this.setSecurityGroup({ securityGroup: null }); + this.fetchSubnets({ vpc, region: this.selectedRegion }); + this.fetchSecurityGroups({ vpc, region: this.selectedRegion }); }, }, }; @@ -233,7 +315,12 @@ export default { <h2> {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} </h2> - <p v-html="kubernetesIntegrationHelpText"></p> + <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div> + <div class="mb-3"> + <button class="btn btn-link js-sign-out" @click.prevent="signOut()"> + {{ s__('ClusterIntegration|Select a different AWS role') }} + </button> + </div> <div class="form-group"> <label class="label-bold" for="eks-cluster-name">{{ s__('ClusterIntegration|Kubernetes cluster name') @@ -273,7 +360,7 @@ export default { <cluster-form-dropdown field-id="eks-role" field-name="eks-role" - :input="selectedRole" + :value="selectedRole" :items="roles" :loading="isLoadingRoles" :loading-text="s__('ClusterIntegration|Loading IAM Roles')" @@ -288,13 +375,21 @@ export default { </div> <div class="form-group"> <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label> - <region-dropdown + <cluster-form-dropdown + field-id="eks-region" + field-name="eks-region" :value="selectedRegion" - :regions="regions" - :error="loadingRegionsError" + :items="regions" :loading="isLoadingRegions" + :loading-text="s__('ClusterIntegration|Loading Regions')" + :placeholder="s__('ClusterIntergation|Select a region')" + :search-field-placeholder="s__('ClusterIntegration|Search regions')" + :empty-text="s__('ClusterIntegration|No region found')" + :has-errors="Boolean(loadingRegionsError)" + :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" @input="setRegionAndFetchVpcsAndKeyPairs($event)" /> + <p class="form-text text-muted" v-html="regionsDropdownHelpText"></p> </div> <div class="form-group"> <label class="label-bold" for="eks-key-pair">{{ @@ -303,7 +398,7 @@ export default { <cluster-form-dropdown field-id="eks-key-pair" field-name="eks-key-pair" - :input="selectedKeyPair" + :value="selectedKeyPair" :items="keyPairs" :disabled="keyPairDropdownDisabled" :disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')" @@ -323,7 +418,7 @@ export default { <cluster-form-dropdown field-id="eks-vpc" field-name="eks-vpc" - :input="selectedVpc" + :value="selectedVpc" :items="vpcs" :loading="isLoadingVpcs" :disabled="vpcDropdownDisabled" @@ -339,11 +434,12 @@ export default { <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p> </div> <div class="form-group"> - <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnet') }}</label> + <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label> <cluster-form-dropdown field-id="eks-subnet" field-name="eks-subnet" - :input="selectedSubnet" + multiple + :value="selectedSubnet" :items="subnets" :loading="isLoadingSubnets" :disabled="subnetDropdownDisabled" @@ -360,12 +456,12 @@ export default { </div> <div class="form-group"> <label class="label-bold" for="eks-security-group">{{ - s__('ClusterIntegration|Security groups') + s__('ClusterIntegration|Security group') }}</label> <cluster-form-dropdown field-id="eks-security-group" field-name="eks-security-group" - :input="selectedSecurityGroup" + :value="selectedSecurityGroup" :items="securityGroups" :loading="isLoadingSecurityGroups" :disabled="securityGroupDropdownDisabled" @@ -383,6 +479,39 @@ export default { <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p> </div> <div class="form-group"> + <label class="label-bold" for="eks-instance-type">{{ + s__('ClusterIntegration|Instance type') + }}</label> + <cluster-form-dropdown + field-id="eks-instance-type" + field-name="eks-instance-type" + :value="selectedInstanceType" + :items="instanceTypes" + :loading="isLoadingInstanceTypes" + :loading-text="s__('ClusterIntegration|Loading instance types')" + :placeholder="s__('ClusterIntergation|Select an instance type')" + :search-field-placeholder="s__('ClusterIntegration|Search instance types')" + :empty-text="s__('ClusterIntegration|No instance type found')" + :has-errors="Boolean(loadingInstanceTypesError)" + :error-message="s__('ClusterIntegration|Could not load instance types')" + @input="setInstanceType({ instanceType: $event })" + /> + <p class="form-text text-muted" v-html="instanceTypesDropdownHelpText"></p> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-node-count">{{ + s__('ClusterIntegration|Number of nodes') + }}</label> + <gl-form-input + id="eks-node-count" + type="number" + min="1" + step="1" + :value="nodeCount" + @input="setNodeCount({ nodeCount: $event })" + /> + </div> + <div class="form-group"> <gl-form-checkbox :checked="gitlabManagedCluster" @input="setGitlabManagedCluster({ gitlabManagedCluster: $event })" @@ -390,5 +519,14 @@ export default { > <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p> </div> + <div class="form-group"> + <loading-button + class="js-create-cluster btn-success" + :disabled="createClusterButtonDisabled" + :loading="isCreatingCluster" + :label="createClusterButtonLabel" + @click="createCluster()" + /> + </div> </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue deleted file mode 100644 index 765955305c8..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { sprintf, s__ } from '~/locale'; - -import ClusterFormDropdown from './cluster_form_dropdown.vue'; - -export default { - components: { - ClusterFormDropdown, - }, - props: { - regions: { - type: Array, - required: false, - default: () => [], - }, - loading: { - type: Boolean, - required: false, - default: false, - }, - error: { - type: Object, - required: false, - default: null, - }, - }, - computed: { - hasErrors() { - return Boolean(this.error); - }, - helpText() { - return sprintf( - s__('ClusterIntegration|Learn more about %{startLink}Regions%{endLink}.'), - { - startLink: - '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', - endLink: '</a>', - }, - false, - ); - }, - }, -}; -</script> -<template> - <div> - <cluster-form-dropdown - field-id="eks-region" - field-name="eks-region" - :items="regions" - :loading="loading" - :loading-text="s__('ClusterIntegration|Loading Regions')" - :placeholder="s__('ClusterIntergation|Select a region')" - :search-field-placeholder="s__('ClusterIntegration|Search regions')" - :empty-text="s__('ClusterIntegration|No region found')" - :has-errors="hasErrors" - :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" - v-bind="$attrs" - v-on="$listeners" - /> - <p class="form-text text-muted" v-html="helpText"></p> - </div> -</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index 79029b8cfa8..ab33e9fbc95 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -1,3 +1,141 @@ +<script> +import { GlFormInput } from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; +import _ from 'underscore'; +import { mapState, mapActions } from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; + +export default { + components: { + GlFormInput, + LoadingButton, + ClipboardButton, + }, + props: { + accountAndExternalIdsHelpPath: { + type: String, + required: true, + }, + createRoleArnHelpPath: { + type: String, + required: true, + }, + externalLinkIcon: { + type: String, + required: true, + }, + }, + data() { + return { + roleArn: '', + }; + }, + computed: { + ...mapState(['accountId', 'externalId', 'isCreatingRole', 'createRoleError']), + submitButtonDisabled() { + return this.isCreatingRole || !this.roleArn; + }, + submitButtonLabel() { + return this.isCreatingRole + ? __('Authenticating') + : s__('ClusterIntegration|Authenticate with AWS'); + }, + accountAndExternalIdsHelpText() { + const escapedUrl = _.escape(this.accountAndExternalIdsHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|Create a provision role on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the account and external ID above. %{startMoreInfoLink}More information%{endLink}', + ), + { + startAwsLink: + '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + provisionRoleArnHelpText() { + const escapedUrl = _.escape(this.createRoleArnHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provision role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}', + ), + { + startAwsLink: + '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + }, + methods: { + ...mapActions(['createRole']), + }, +}; +</script> <template> - <form name="service-credentials-form"></form> + <form name="service-credentials-form" @submit.prevent="createRole({ roleArn, externalId })"> + <h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2> + <p> + {{ + s__( + 'ClusterIntegration|You must grant access to your organization’s AWS resources in order to create a new EKS cluster. To grant access, create a provision role using the account and external ID below and provide us the ARN.', + ) + }} + </p> + <div v-if="createRoleError" class="js-invalid-credentials bs-callout bs-callout-danger"> + {{ createRoleError }} + </div> + <div class="form-row"> + <div class="form-group col-md-6"> + <label for="gitlab-account-id">{{ __('Account ID') }}</label> + <div class="input-group"> + <gl-form-input id="gitlab-account-id" type="text" readonly :value="accountId" /> + <div class="input-group-append"> + <clipboard-button + :text="accountId" + :title="__('Copy Account ID to clipboard')" + class="input-group-text js-copy-account-id-button" + /> + </div> + </div> + </div> + <div class="form-group col-md-6"> + <label for="eks-external-id">{{ __('External ID') }}</label> + <div class="input-group"> + <gl-form-input id="eks-external-id" type="text" readonly :value="externalId" /> + <div class="input-group-append"> + <clipboard-button + :text="externalId" + :title="__('Copy External ID to clipboard')" + class="input-group-text js-copy-external-id-button" + /> + </div> + </div> + </div> + <div class="col-12 mb-3 mt-n3"> + <p class="form-text text-muted" v-html="accountAndExternalIdsHelpText"></p> + </div> + </div> + <div class="form-group"> + <label for="eks-provision-role-arn">{{ s__('ClusterIntegration|Provision Role ARN') }}</label> + <gl-form-input id="eks-provision-role-arn" v-model="roleArn" /> + <p class="form-text text-muted" v-html="provisionRoleArnHelpText"></p> + </div> + <loading-button + class="js-submit-service-credentials btn-success" + type="submit" + :disabled="submitButtonDisabled" + :loading="isCreatingRole" + :label="submitButtonLabel" + /> + </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js index 339642f991e..a850ba89818 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js @@ -1,7 +1,2 @@ // eslint-disable-next-line import/prefer-default-export -export const KUBERNETES_VERSIONS = [ - { name: '1.14', value: '1.14' }, - { name: '1.13', value: '1.13' }, - { name: '1.12', value: '1.12' }, - { name: '1.11', value: '1.11' }, -]; +export const KUBERNETES_VERSIONS = [{ name: '1.14', value: '1.14' }]; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js index 1f595e9b2df..27f859d8972 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js @@ -1,16 +1,54 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; import CreateEksCluster from './components/create_eks_cluster.vue'; import createStore from './store'; Vue.use(Vuex); export default el => { - const { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath } = el.dataset; + const { + gitlabManagedClusterHelpPath, + kubernetesIntegrationHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, + externalId, + accountId, + hasCredentials, + createRolePath, + createClusterPath, + signOutPath, + externalLinkIcon, + } = el.dataset; return new Vue({ el, - store: createStore(), + store: createStore({ + initialState: { + hasCredentials: parseBoolean(hasCredentials), + externalId, + accountId, + createRolePath, + createClusterPath, + signOutPath, + }, + apiPaths: { + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, + }, + }), components: { CreateEksCluster, }, @@ -19,6 +57,9 @@ export default el => { props: { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + externalLinkIcon, }, }); }, diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js index d982e4db4c1..21b87d525cf 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js @@ -1,84 +1,58 @@ -import EC2 from 'aws-sdk/clients/ec2'; -import IAM from 'aws-sdk/clients/iam'; - -export const fetchRoles = () => { - const iam = new IAM(); - - return iam - .listRoles() - .promise() - .then(({ Roles: roles }) => roles.map(({ RoleName: name }) => ({ name }))); -}; - -export const fetchKeyPairs = () => { - const ec2 = new EC2(); - - return ec2 - .describeKeyPairs() - .promise() - .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ RegionName: name }) => ({ name }))); -}; - -export const fetchRegions = () => { - const ec2 = new EC2(); - - return ec2 - .describeRegions() - .promise() - .then(({ Regions: regions }) => - regions.map(({ RegionName: name }) => ({ - name, - value: name, +import axios from '~/lib/utils/axios_utils'; + +export default apiPaths => ({ + fetchRoles() { + return axios + .get(apiPaths.getRolesPath) + .then(({ data: { roles } }) => + roles.map(({ role_name: name, arn: value }) => ({ name, value })), + ); + }, + fetchKeyPairs({ region }) { + return axios + .get(apiPaths.getKeyPairsPath, { params: { region } }) + .then(({ data: { key_pairs: keyPairs } }) => + keyPairs.map(({ key_name }) => ({ name: key_name, value: key_name })), + ); + }, + fetchRegions() { + return axios.get(apiPaths.getRegionsPath).then(({ data: { regions } }) => + regions.map(({ region_name }) => ({ + name: region_name, + value: region_name, })), ); -}; - -export const fetchVpcs = () => { - const ec2 = new EC2(); - - return ec2 - .describeVpcs() - .promise() - .then(({ Vpcs: vpcs }) => - vpcs.map(({ VpcId: id }) => ({ - value: id, - name: id, + }, + fetchVpcs({ region }) { + return axios.get(apiPaths.getVpcsPath, { params: { region } }).then(({ data: { vpcs } }) => + vpcs.map(({ vpc_id }) => ({ + value: vpc_id, + name: vpc_id, })), ); -}; - -export const fetchSubnets = ({ vpc }) => { - const ec2 = new EC2(); - - return ec2 - .describeSubnets({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ id, name: id }))); -}; - -export const fetchSecurityGroups = ({ vpc }) => { - const ec2 = new EC2(); - - return ec2 - .describeSecurityGroups({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ SecurityGroups: securityGroups }) => - securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })), - ); -}; - -export default () => {}; + }, + fetchSubnets({ vpc, region }) { + return axios + .get(apiPaths.getSubnetsPath, { params: { vpc_id: vpc, region } }) + .then(({ data: { subnets } }) => + subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })), + ); + }, + fetchSecurityGroups({ vpc, region }) { + return axios + .get(apiPaths.getSecurityGroupsPath, { params: { vpc_id: vpc, region } }) + .then(({ data: { security_groups: securityGroups } }) => + securityGroups.map(({ group_name: name, group_id: value }) => ({ name, value })), + ); + }, + fetchInstanceTypes() { + return axios + .get(apiPaths.getInstanceTypesPath) + .then(({ data: { instance_types: instanceTypes } }) => + instanceTypes.map(({ instance_type_name }) => ({ + name: instance_type_name, + value: instance_type_name, + })), + ); + }, +}); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 917c8da6c3e..72f15263a8f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,4 +1,12 @@ import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; + +const getErrorMessage = data => { + const errorKey = Object.keys(data)[0]; + + return data[errorKey][0]; +}; export const setClusterName = ({ commit }, payload) => { commit(types.SET_CLUSTER_NAME, payload); @@ -12,6 +20,68 @@ export const setKubernetesVersion = ({ commit }, payload) => { commit(types.SET_KUBERNETES_VERSION, payload); }; +export const createRole = ({ dispatch, state: { createRolePath } }, payload) => { + dispatch('requestCreateRole'); + + return axios + .post(createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + }) + .then(() => dispatch('createRoleSuccess')) + .catch(error => dispatch('createRoleError', { error })); +}; + +export const requestCreateRole = ({ commit }) => { + commit(types.REQUEST_CREATE_ROLE); +}; + +export const createRoleSuccess = ({ commit }) => { + commit(types.CREATE_ROLE_SUCCESS); +}; + +export const createRoleError = ({ commit }, payload) => { + commit(types.CREATE_ROLE_ERROR, payload); +}; + +export const createCluster = ({ dispatch, state }) => { + dispatch('requestCreateCluster'); + + return axios + .post(state.createClusterPath, { + name: state.clusterName, + environment_scope: state.environmentScope, + managed: state.gitlabManagedCluster, + provider_aws_attributes: { + region: state.selectedRegion, + vpc_id: state.selectedVpc, + subnet_ids: state.selectedSubnet, + role_arn: state.selectedRole, + key_name: state.selectedKeyPair, + security_group_id: state.selectedSecurityGroup, + instance_type: state.selectedInstanceType, + num_nodes: state.nodeCount, + }, + }) + .then(({ headers: { location } }) => dispatch('createClusterSuccess', location)) + .catch(({ response: { data } }) => { + dispatch('createClusterError', data); + }); +}; + +export const requestCreateCluster = ({ commit }) => { + commit(types.REQUEST_CREATE_CLUSTER); +}; + +export const createClusterSuccess = (_, location) => { + window.location.assign(location); +}; + +export const createClusterError = ({ commit }, error) => { + commit(types.CREATE_CLUSTER_ERROR, error); + createFlash(getErrorMessage(error)); +}; + export const setRegion = ({ commit }, payload) => { commit(types.SET_REGION, payload); }; @@ -40,4 +110,16 @@ export const setGitlabManagedCluster = ({ commit }, payload) => { commit(types.SET_GITLAB_MANAGED_CLUSTER, payload); }; -export default () => {}; +export const setInstanceType = ({ commit }, payload) => { + commit(types.SET_INSTANCE_TYPE, payload); +}; + +export const setNodeCount = ({ commit }, payload) => { + commit(types.SET_NODE_COUNT, payload); +}; + +export const signOut = ({ commit, state: { signOutPath } }) => + axios + .delete(signOutPath) + .then(() => commit(types.SIGN_OUT)) + .catch(({ response: { data } }) => createFlash(getErrorMessage(data))); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js index d575deafd19..5982fc8a2fd 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -6,14 +6,16 @@ import state from './state'; import clusterDropdownStore from './cluster_dropdown'; -import * as awsServices from '../services/aws_services_facade'; +import awsServicesFactory from '../services/aws_services_facade'; -const createStore = () => - new Vuex.Store({ +const createStore = ({ initialState, apiPaths }) => { + const awsServices = awsServicesFactory(apiPaths); + + return new Vuex.Store({ actions, getters, mutations, - state: state(), + state: Object.assign(state(), initialState), modules: { roles: { namespaced: true, @@ -39,7 +41,12 @@ const createStore = () => namespaced: true, ...clusterDropdownStore(awsServices.fetchSecurityGroups), }, + instanceTypes: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchInstanceTypes), + }, }, }); +}; export default createStore; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js index 82eb512ac07..f9204cc2207 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js @@ -7,4 +7,13 @@ export const SET_KEY_PAIR = 'SET_KEY_PAIR'; export const SET_SUBNET = 'SET_SUBNET'; export const SET_ROLE = 'SET_ROLE'; export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP'; +export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE'; +export const SET_NODE_COUNT = 'SET_NODE_COUNT'; export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER'; +export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE'; +export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS'; +export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR'; +export const SIGN_OUT = 'SIGN_OUT'; +export const REQUEST_CREATE_CLUSTER = 'REQUEST_CREATE_CLUSTER'; +export const CREATE_CLUSTER_SUCCESS = 'CREATE_CLUSTER_SUCCESS'; +export const CREATE_CLUSTER_ERROR = 'CREATE_CLUSTER_ERROR'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js index 79950ac7dce..aa04c8f7079 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js @@ -28,7 +28,39 @@ export default { [types.SET_SECURITY_GROUP](state, { securityGroup }) { state.selectedSecurityGroup = securityGroup; }, + [types.SET_INSTANCE_TYPE](state, { instanceType }) { + state.selectedInstanceType = instanceType; + }, + [types.SET_NODE_COUNT](state, { nodeCount }) { + state.nodeCount = nodeCount; + }, [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) { state.gitlabManagedCluster = gitlabManagedCluster; }, + [types.REQUEST_CREATE_ROLE](state) { + state.isCreatingRole = true; + state.createRoleError = null; + state.hasCredentials = false; + }, + [types.CREATE_ROLE_SUCCESS](state) { + state.isCreatingRole = false; + state.createRoleError = null; + state.hasCredentials = true; + }, + [types.CREATE_ROLE_ERROR](state, { error }) { + state.isCreatingRole = false; + state.createRoleError = error; + state.hasCredentials = false; + }, + [types.REQUEST_CREATE_CLUSTER](state) { + state.isCreatingCluster = true; + state.createClusterError = null; + }, + [types.CREATE_CLUSTER_ERROR](state, { error }) { + state.isCreatingCluster = false; + state.createClusterError = error; + }, + [types.SIGN_OUT](state) { + state.hasCredentials = false; + }, }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js index bf74213bdce..2e3a05a9187 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js @@ -1,18 +1,31 @@ import { KUBERNETES_VERSIONS } from '../constants'; +const [{ value: kubernetesVersion }] = KUBERNETES_VERSIONS; + export default () => ({ - isValidatingCredentials: false, - validCredentials: false, + createRolePath: null, + + isCreatingRole: false, + roleCreated: false, + createRoleError: false, + + accountId: '', + externalId: '', clusterName: '', environmentScope: '*', - kubernetesVersion: [KUBERNETES_VERSIONS].value, + kubernetesVersion, selectedRegion: '', selectedRole: '', selectedKeyPair: '', selectedVpc: '', selectedSubnet: '', selectedSecurityGroup: '', + selectedInstanceType: 'm5.large', + nodeCount: '3', + + isCreatingCluster: false, + createClusterError: false, gitlabManagedCluster: true, }); diff --git a/app/assets/javascripts/projects/gke_cluster_namespace/index.js b/app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js index 0ec4d8807b0..0ec4d8807b0 100644 --- a/app/assets/javascripts/projects/gke_cluster_namespace/index.js +++ b/app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js diff --git a/app/assets/javascripts/create_cluster/init_create_cluster.js b/app/assets/javascripts/create_cluster/init_create_cluster.js new file mode 100644 index 00000000000..7c984582fd8 --- /dev/null +++ b/app/assets/javascripts/create_cluster/init_create_cluster.js @@ -0,0 +1,37 @@ +import initGkeDropdowns from './gke_cluster'; +import initGkeNamespace from './gke_cluster_namespace'; +import PersistentUserCallout from '~/persistent_user_callout'; + +const newClusterViews = [':clusters:new', ':clusters:create_gcp', ':clusters:create_user']; + +const isProjectLevelCluster = page => page.startsWith('project:clusters'); + +export default (document, gon) => { + const { page } = document.body.dataset; + const isNewClusterView = newClusterViews.some(view => page.endsWith(view)); + + if (!isNewClusterView) { + return; + } + + const callout = document.querySelector('.gcp-signup-offer'); + PersistentUserCallout.factory(callout); + + initGkeDropdowns(); + + if (gon.features.createEksClusters) { + import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') + .then(({ default: initCreateEKSCluster }) => { + const el = document.querySelector('.js-create-eks-cluster-form-container'); + + if (el) { + initCreateEKSCluster(el); + } + }) + .catch(() => {}); + } + + if (isProjectLevelCluster(page)) { + initGkeNamespace(); + } +}; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue deleted file mode 100644 index fc6d83bf96c..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script> -import Icon from '~/vue_shared/components/icon.vue'; -import { GlButton } from '@gitlab/ui'; - -export default { - name: 'StageCardListItem', - components: { - Icon, - GlButton, - }, - props: { - isActive: { - type: Boolean, - required: true, - }, - canEdit: { - type: Boolean, - default: false, - required: false, - }, - }, -}; -</script> - -<template> - <div - :class="{ active: isActive }" - class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px" - > - <slot></slot> - <div v-if="canEdit" class="dropdown"> - <gl-button - :title="__('More actions')" - class="more-actions-toggle btn btn-transparent p-0" - data-toggle="dropdown" - > - <icon class="icon" name="ellipsis_v" /> - </gl-button> - <ul class="more-actions-dropdown dropdown-menu dropdown-open-left"> - <slot name="dropdown-options"></slot> - </ul> - </div> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue index 004d335f572..1b09fe1b370 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -1,11 +1,6 @@ <script> -import StageCardListItem from './stage_card_list_item.vue'; - export default { name: 'StageNavItem', - components: { - StageCardListItem, - }, props: { isDefaultStage: { type: Boolean, @@ -40,16 +35,16 @@ export default { hasValue() { return this.value && this.value.length > 0; }, - editable() { - return this.isUserAllowed && this.canEdit; - }, }, }; </script> <template> <li @click="$emit('select')"> - <stage-card-list-item :is-active="isActive" :can-edit="editable"> + <div + :class="{ active: isActive }" + class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px" + > <div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }"> {{ title }} </div> @@ -62,27 +57,6 @@ export default { <span class="not-available">{{ __('Not available') }}</span> </template> </div> - <template v-slot:dropdown-options> - <template v-if="isDefaultStage"> - <li> - <button type="button" class="btn-default btn-transparent"> - {{ __('Hide stage') }} - </button> - </li> - </template> - <template v-else> - <li> - <button type="button" class="btn-default btn-transparent"> - {{ __('Edit stage') }} - </button> - </li> - <li> - <button type="button" class="btn-danger danger"> - {{ __('Remove stage') }} - </button> - </li> - </template> - </template> - </stage-card-list-item> + </div> </li> </template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 9a1e59ec045..a5ffa84e3fb 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -124,8 +124,10 @@ export default { :diff-viewer-mode="diffViewerMode" :new-path="diffFile.new_path" :new-sha="diffFile.diff_refs.head_sha" + :new-size="diffFile.new_size" :old-path="diffFile.old_path" :old-sha="diffFile.diff_refs.base_sha" + :old-size="diffFile.old_size" :file-hash="diffFileHash" :project-path="projectPath" :a-mode="diffFile.a_mode" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 2514274224d..9236f0d5349 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -54,11 +54,12 @@ export default { showLoadingIcon() { return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); }, - hasDiffLines() { + hasDiff() { return ( - this.file.highlighted_diff_lines && - this.file.parallel_diff_lines && - this.file.parallel_diff_lines.length > 0 + (this.file.highlighted_diff_lines && + this.file.parallel_diff_lines && + this.file.parallel_diff_lines.length > 0) || + !this.file.blob.readable_text ); }, isFileTooLarge() { @@ -82,7 +83,7 @@ export default { }, watch: { isCollapsed: function fileCollapsedWatch(newVal, oldVal) { - if (!newVal && oldVal && !this.hasDiffLines) { + if (!newVal && oldVal && !this.hasDiff) { this.handleLoadCollapsedDiff(); } @@ -103,7 +104,7 @@ export default { 'setFileCollapsed', ]), handleToggle() { - if (!this.hasDiffLines) { + if (!this.hasDiff) { this.handleLoadCollapsedDiff(); } else { this.isCollapsed = !this.isCollapsed; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 0ff26445a6a..62b390a46d7 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -290,5 +290,5 @@ export default function dropzoneInput(form) { formTextarea.focus(); }); - return Dropzone.forElement($formDropzone.get(0)); + return $formDropzone.get(0) ? Dropzone.forElement($formDropzone.get(0)) : null; } diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue new file mode 100644 index 00000000000..37c9818f869 --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -0,0 +1,141 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import dateFormat from 'dateformat'; +import { __, sprintf } from '~/locale'; +import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import Stacktrace from './stacktrace.vue'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { trackClickErrorLinkToSentryOptions } from '../utils'; + +export default { + components: { + GlButton, + GlLink, + GlLoadingIcon, + TooltipOnTruncate, + Icon, + Stacktrace, + }, + directives: { + TrackEvent: TrackEventDirective, + }, + mixins: [timeagoMixin], + props: { + issueDetailsPath: { + type: String, + required: true, + }, + issueStackTracePath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']), + ...mapGetters('details', ['stacktrace']), + reported() { + return sprintf( + __('Reported %{timeAgo} by %{reportedBy}'), + { + reportedBy: `<strong>${this.error.culprit}</strong>`, + timeAgo: this.timeFormated(this.stacktraceData.date_received), + }, + false, + ); + }, + firstReleaseLink() { + return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`; + }, + lastReleaseLink() { + return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`; + }, + showDetails() { + return Boolean(!this.loading && this.error && this.error.id); + }, + showStacktrace() { + return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); + }, + }, + mounted() { + this.startPollingDetails(this.issueDetailsPath); + this.startPollingStacktrace(this.issueStackTracePath); + }, + methods: { + ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']), + trackClickErrorLinkToSentryOptions, + formatDate(date) { + return `${this.timeFormated(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; + }, + }, +}; +</script> + +<template> + <div> + <div v-if="loading" class="py-3"> + <gl-loading-icon :size="3" /> + </div> + + <div v-else-if="showDetails" class="error-details"> + <div class="top-area align-items-center justify-content-between py-3"> + <span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span> + <!-- <gl-button class="my-3 ml-auto" variant="success"> + {{ __('Create Issue') }} + </gl-button>--> + </div> + <div> + <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> + <h2 class="text-truncate">{{ error.title }}</h2> + </tooltip-on-truncate> + <h3>{{ __('Error details') }}</h3> + <ul> + <li> + <span class="bold">{{ __('Sentry event') }}:</span> + <gl-link + v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)" + :href="error.external_url" + target="_blank" + > + <span class="text-truncate">{{ error.external_url }}</span> + <icon name="external-link" class="ml-1 flex-shrink-0" /> + </gl-link> + </li> + <li v-if="error.first_release_short_version"> + <span class="bold">{{ __('First seen') }}:</span> + {{ formatDate(error.first_seen) }} + <gl-link :href="firstReleaseLink" target="_blank"> + <span>{{ __('Release') }}: {{ error.first_release_short_version }}</span> + </gl-link> + </li> + <li v-if="error.last_release_short_version"> + <span class="bold">{{ __('Last seen') }}:</span> + {{ formatDate(error.last_seen) }} + <gl-link :href="lastReleaseLink" target="_blank"> + <span>{{ __('Release') }}: {{ error.last_release_short_version }}</span> + </gl-link> + </li> + <li> + <span class="bold">{{ __('Events') }}:</span> + <span>{{ error.count }}</span> + </li> + <li> + <span class="bold">{{ __('Users') }}:</span> + <span>{{ error.user_count }}</span> + </li> + </ul> + + <div v-if="loadingStacktrace" class="py-3"> + <gl-loading-icon :size="3" /> + </div> + + <template v-if="showStacktrace"> + <h3 class="my-4">{{ __('Stack trace') }}</h3> + <stacktrace :entries="stacktrace" /> + </template> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index cd298e2c692..88139ce7403 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -1,11 +1,19 @@ <script> -import { mapActions, mapState } from 'vuex'; -import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { + GlEmptyState, + GlButton, + GlLink, + GlLoadingIcon, + GlTable, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { visitUrl } from '~/lib/utils/url_utility'; import Icon from '~/vue_shared/components/icon.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { trackViewInSentryOptions, trackClickErrorLinkToSentryOptions } from '../utils'; +import { trackViewInSentryOptions } from '../utils'; export default { fields: [ @@ -20,6 +28,7 @@ export default { GlLink, GlLoadingIcon, GlTable, + GlSearchBoxByType, Icon, TimeAgo, }, @@ -48,8 +57,17 @@ export default { required: true, }, }, + data() { + return { + errorSearchQuery: '', + }; + }, computed: { - ...mapState(['errors', 'externalUrl', 'loading']), + ...mapState('list', ['errors', 'externalUrl', 'loading']), + ...mapGetters('list', ['filterErrorsByTitle']), + filteredErrors() { + return this.errorSearchQuery ? this.filterErrorsByTitle(this.errorSearchQuery) : this.errors; + }, }, created() { if (this.errorTrackingEnabled) { @@ -57,9 +75,11 @@ export default { } }, methods: { - ...mapActions(['startPolling', 'restartPolling']), + ...mapActions('list', ['startPolling', 'restartPolling']), trackViewInSentryOptions, - trackClickErrorLinkToSentryOptions, + viewDetails(errorId) { + visitUrl(`error_tracking/${errorId}/details`); + }, }, }; </script> @@ -71,10 +91,17 @@ export default { <gl-loading-icon :size="3" /> </div> <div v-else> - <div class="d-flex justify-content-end"> + <div class="d-flex flex-row justify-content-around bg-secondary border"> + <gl-search-box-by-type + v-model="errorSearchQuery" + class="col-lg-10 m-3 p-0" + :placeholder="__('Search or filter results...')" + type="search" + autofocus + /> <gl-button v-track-event="trackViewInSentryOptions(externalUrl)" - class="my-3 ml-auto" + class="m-3" variant="primary" :href="externalUrl" target="_blank" @@ -84,7 +111,14 @@ export default { </gl-button> </div> - <gl-table :items="errors" :fields="$options.fields" :show-empty="true" fixed stacked="sm"> + <gl-table + class="mt-3" + :items="filteredErrors" + :fields="$options.fields" + :show-empty="true" + fixed + stacked="sm" + > <template slot="HEAD_events" slot-scope="data"> <div class="text-md-right">{{ data.label }}</div> </template> @@ -94,13 +128,11 @@ export default { <template slot="error" slot-scope="errors"> <div class="d-flex flex-column"> <gl-link - v-track-event="trackClickErrorLinkToSentryOptions(errors.item.externalUrl)" - :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank" + @click="viewDetails(errors.item.id)" > <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> - <icon name="external-link" class="ml-1 flex-shrink-0" /> </gl-link> <span class="text-secondary text-truncate"> {{ errors.item.culprit }} diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue new file mode 100644 index 00000000000..6b71967624f --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue @@ -0,0 +1,33 @@ +<script> +import StackTraceEntry from './stacktrace_entry.vue'; + +export default { + components: { + StackTraceEntry, + }, + props: { + entries: { + type: Array, + required: true, + }, + }, + methods: { + isFirstEntry(index) { + return index === 0; + }, + }, +}; +</script> + +<template> + <div class="stacktrace"> + <stack-trace-entry + v-for="(entry, index) in entries" + :key="`stacktrace-entry-${index}`" + :lines="entry.context" + :file-path="entry.filename" + :error-line="entry.lineNo" + :expanded="isFirstEntry(index)" + /> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue new file mode 100644 index 00000000000..ad542c579a9 --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -0,0 +1,110 @@ +<script> +import { GlTooltip } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + ClipboardButton, + FileIcon, + Icon, + }, + directives: { + GlTooltip, + }, + props: { + lines: { + type: Array, + required: true, + }, + filePath: { + type: String, + required: true, + }, + errorLine: { + type: Number, + required: true, + }, + expanded: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isExpanded: this.expanded, + }; + }, + computed: { + linesLength() { + return this.lines.length; + }, + collapseIcon() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + }, + methods: { + isHighlighted(lineNum) { + return lineNum === this.errorLine; + }, + toggle() { + this.isExpanded = !this.isExpanded; + }, + lineNum(line) { + return line[0]; + }, + lineCode(line) { + return line[1]; + }, + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> + +<template> + <div class="file-holder"> + <div ref="header" class="file-title file-title-flex-parent"> + <div class="file-header-content "> + <div class="d-inline-block cursor-pointer" @click="toggle()"> + <icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" /> + </div> + <div class="d-inline-block append-right-4"> + <file-icon + :file-name="filePath" + :size="18" + aria-hidden="true" + css-classes="append-right-5" + /> + <strong v-gl-tooltip :title="filePath" class="file-title-name" data-container="body"> + {{ filePath }} + </strong> + </div> + + <clipboard-button + :title="__('Copy file path')" + :text="filePath" + css-class="btn-default btn-transparent btn-clipboard" + /> + </div> + </div> + + <table v-if="isExpanded" :class="$options.userColorScheme" class="code js-syntax-highlight"> + <tbody> + <template v-for="(line, index) in lines"> + <tr :key="`stacktrace-line-${index}`" class="line_holder"> + <td class="diff-line-num" :class="{ old: isHighlighted(lineNum(line)) }"> + {{ lineNum(line) }} + </td> + <td + class="line_content" + :class="{ old: isHighlighted(lineNum(line)) }" + v-html="lineCode(line)" + ></td> + </tr> + </template> + </tbody> + </table> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js new file mode 100644 index 00000000000..b9b51a6539f --- /dev/null +++ b/app/assets/javascripts/error_tracking/details.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import store from './store'; +import ErrorDetails from './components/error_details.vue'; + +export default () => { + // eslint-disable-next-line no-new + new Vue({ + el: '#js-error_details', + components: { + ErrorDetails, + }, + store, + render(createElement) { + const domEl = document.querySelector(this.$options.el); + const { issueDetailsPath, issueStackTracePath } = domEl.dataset; + + return createElement('error-details', { + props: { + issueDetailsPath, + issueStackTracePath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/error_tracking/index.js b/app/assets/javascripts/error_tracking/list.js index 073e2c8f1c7..073e2c8f1c7 100644 --- a/app/assets/javascripts/error_tracking/index.js +++ b/app/assets/javascripts/error_tracking/list.js diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js index ab89521dc46..68988296cc2 100644 --- a/app/assets/javascripts/error_tracking/services/index.js +++ b/app/assets/javascripts/error_tracking/services/index.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; export default { - getErrorList({ endpoint }) { + getSentryData({ endpoint }) { return axios.get(endpoint); }, }; diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js new file mode 100644 index 00000000000..0390bca7175 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/actions.js @@ -0,0 +1,63 @@ +import service from '../../services'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import Poll from '~/lib/utils/poll'; +import { __ } from '~/locale'; + +let stackTracePoll; +let detailPoll; + +const stopPolling = poll => { + if (poll) poll.stop(); +}; + +export function startPollingDetails({ commit }, endpoint) { + detailPoll = new Poll({ + resource: service, + method: 'getSentryData', + data: { endpoint }, + successCallback: ({ data }) => { + if (!data) { + detailPoll.restart(); + return; + } + + commit(types.SET_ERROR, data.error); + commit(types.SET_LOADING, false); + + stopPolling(detailPoll); + }, + errorCallback: () => { + commit(types.SET_LOADING, false); + createFlash(__('Failed to load error details from Sentry.')); + }, + }); + + detailPoll.makeRequest(); +} + +export function startPollingStacktrace({ commit }, endpoint) { + stackTracePoll = new Poll({ + resource: service, + method: 'getSentryData', + data: { endpoint }, + successCallback: ({ data }) => { + if (!data) { + stackTracePoll.restart(); + return; + } + commit(types.SET_STACKTRACE_DATA, data.error); + commit(types.SET_LOADING_STACKTRACE, false); + + stopPolling(stackTracePoll); + }, + errorCallback: () => { + commit(types.SET_LOADING_STACKTRACE, false); + createFlash(__('Failed to load stacktrace.')); + }, + }); + + stackTracePoll.makeRequest(); +} + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/getters.js b/app/assets/javascripts/error_tracking/store/details/getters.js new file mode 100644 index 00000000000..7d13439d721 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/getters.js @@ -0,0 +1,3 @@ +export const stacktrace = state => state.stacktraceData.stack_trace_entries.reverse(); + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/mutation_types.js b/app/assets/javascripts/error_tracking/store/details/mutation_types.js new file mode 100644 index 00000000000..a2592253a2d --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_ERROR = 'SET_ERRORS'; +export const SET_LOADING = 'SET_LOADING'; +export const SET_LOADING_STACKTRACE = 'SET_LOADING_STACKTRACE'; +export const SET_STACKTRACE_DATA = 'SET_STACKTRACE_DATA'; diff --git a/app/assets/javascripts/error_tracking/store/details/mutations.js b/app/assets/javascripts/error_tracking/store/details/mutations.js new file mode 100644 index 00000000000..6f4720444e0 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/mutations.js @@ -0,0 +1,16 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ERROR](state, data) { + state.error = data; + }, + [types.SET_LOADING](state, loading) { + state.loading = loading; + }, + [types.SET_LOADING_STACKTRACE](state, data) { + state.loadingStacktrace = data; + }, + [types.SET_STACKTRACE_DATA](state, data) { + state.stacktraceData = data; + }, +}; diff --git a/app/assets/javascripts/error_tracking/store/details/state.js b/app/assets/javascripts/error_tracking/store/details/state.js new file mode 100644 index 00000000000..95fb0ba0558 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/state.js @@ -0,0 +1,6 @@ +export default () => ({ + error: {}, + stacktraceData: {}, + loading: true, + loadingStacktrace: true, +}); diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js index 3136682fb64..941c752e96a 100644 --- a/app/assets/javascripts/error_tracking/store/index.js +++ b/app/assets/javascripts/error_tracking/store/index.js @@ -1,19 +1,36 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import * as actions from './actions'; -import mutations from './mutations'; + +import * as listActions from './list/actions'; +import listMutations from './list/mutations'; +import listState from './list/state'; +import * as listGetters from './list/getters'; + +import * as detailsActions from './details/actions'; +import detailsMutations from './details/mutations'; +import detailsState from './details/state'; +import * as detailsGetters from './details/getters'; Vue.use(Vuex); export const createStore = () => new Vuex.Store({ - state: { - errors: [], - externalUrl: '', - loading: true, + modules: { + list: { + namespaced: true, + state: listState(), + actions: listActions, + mutations: listMutations, + getters: listGetters, + }, + details: { + namespaced: true, + state: detailsState(), + actions: detailsActions, + mutations: detailsMutations, + getters: detailsGetters, + }, }, - actions, - mutations, }); export default createStore(); diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index 1e754a4f54f..18c6e5e9695 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -1,4 +1,4 @@ -import Service from '../services'; +import Service from '../../services'; import * as types from './mutation_types'; import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; @@ -9,7 +9,7 @@ let eTagPoll; export function startPolling({ commit, dispatch }, endpoint) { eTagPoll = new Poll({ resource: Service, - method: 'getErrorList', + method: 'getSentryData', data: { endpoint }, successCallback: ({ data }) => { if (!data) { diff --git a/app/assets/javascripts/error_tracking/store/list/getters.js b/app/assets/javascripts/error_tracking/store/list/getters.js new file mode 100644 index 00000000000..1a2ec62f79f --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/list/getters.js @@ -0,0 +1,4 @@ +export const filterErrorsByTitle = state => errorQuery => + state.errors.filter(error => error.title.match(new RegExp(`${errorQuery}`, 'i'))); + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js index f9d77a6b08e..f9d77a6b08e 100644 --- a/app/assets/javascripts/error_tracking/store/mutation_types.js +++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js index e4bd81db9c9..e4bd81db9c9 100644 --- a/app/assets/javascripts/error_tracking/store/mutations.js +++ b/app/assets/javascripts/error_tracking/store/list/mutations.js diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js new file mode 100644 index 00000000000..d371350ef0e --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/list/state.js @@ -0,0 +1,5 @@ +export default () => ({ + errors: [], + externalUrl: '', + loading: true, +}); diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index 50eb3e63b7c..786abc8ce49 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -43,16 +43,7 @@ export default { 'isProjectInvalid', 'projectSelectionLabel', ]), - ...mapState([ - 'apiHost', - 'connectError', - 'connectSuccessful', - 'enabled', - 'projects', - 'selectedProject', - 'settingsLoading', - 'token', - ]), + ...mapState(['enabled', 'projects', 'selectedProject', 'settingsLoading', 'token']), }, created() { this.setInitialState({ @@ -65,15 +56,7 @@ export default { }); }, methods: { - ...mapActions([ - 'fetchProjects', - 'setInitialState', - 'updateApiHost', - 'updateEnabled', - 'updateSelectedProject', - 'updateSettings', - 'updateToken', - ]), + ...mapActions(['setInitialState', 'updateEnabled', 'updateSelectedProject', 'updateSettings']), handleSubmit() { this.updateSettings(); }, @@ -95,15 +78,7 @@ export default { s__('ErrorTracking|Active') }}</label> </div> - <error-tracking-form - :api-host="apiHost" - :connect-error="connectError" - :connect-successful="connectSuccessful" - :token="token" - @handle-connect="fetchProjects" - @update-api-host="updateApiHost" - @update-token="updateToken" - /> + <error-tracking-form /> <div class="form-group"> <project-dropdown :has-projects="hasProjects" diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index a734e8527dd..d86116aa315 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -1,32 +1,20 @@ <script> -import { GlButton, GlFormInput } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { GlFormInput } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; export default { - components: { GlButton, GlFormInput, Icon }, - props: { - apiHost: { - type: String, - required: true, - }, - connectError: { - type: Boolean, - required: true, - }, - connectSuccessful: { - type: Boolean, - required: true, - }, - token: { - type: String, - required: true, - }, - }, + components: { GlFormInput, Icon, LoadingButton }, computed: { + ...mapState(['apiHost', 'connectError', 'connectSuccessful', 'isLoadingProjects', 'token']), tokenInputState() { return this.connectError ? false : null; }, }, + methods: { + ...mapActions(['fetchProjects', 'updateApiHost', 'updateToken']), + }, }; </script> @@ -40,8 +28,9 @@ export default { <gl-form-input id="error-tracking-api-host" :value="apiHost" + :disabled="isLoadingProjects" placeholder="https://mysentryserver.com" - @input="$emit('update-api-host', $event)" + @input="updateApiHost" /> <!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings --> </div> @@ -60,15 +49,17 @@ export default { id="error-tracking-token" :value="token" :state="tokenInputState" - @input="$emit('update-token', $event)" + :disabled="isLoadingProjects" + @input="updateToken" /> </div> <div class="col-4 col-md-3 gl-pl-0"> - <gl-button - class="js-error-tracking-connect prepend-left-5" - @click="$emit('handle-connect')" - >{{ __('Connect') }}</gl-button - > + <loading-button + class="js-error-tracking-connect prepend-left-5 d-inline-flex" + :label="isLoadingProjects ? __('Connecting') : __('Connect')" + :loading="isLoadingProjects" + @click="fetchProjects" + /> <icon v-show="connectSuccessful" class="js-error-tracking-connect-success prepend-left-5 text-success align-middle" diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js index 95105797807..6b540ea7dfd 100644 --- a/app/assets/javascripts/error_tracking_settings/store/actions.js +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -6,17 +6,20 @@ import { transformFrontendSettings } from '../utils'; import * as types from './mutation_types'; export const requestProjects = ({ commit }) => { + commit(types.SET_PROJECTS_LOADING, true); commit(types.RESET_CONNECT); }; export const receiveProjectsSuccess = ({ commit }, projects) => { commit(types.UPDATE_CONNECT_SUCCESS); commit(types.RECEIVE_PROJECTS, projects); + commit(types.SET_PROJECTS_LOADING, false); }; export const receiveProjectsError = ({ commit }) => { commit(types.UPDATE_CONNECT_ERROR); commit(types.CLEAR_PROJECTS); + commit(types.SET_PROJECTS_LOADING, false); }; export const fetchProjects = ({ dispatch, state }) => { diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js index b4f8a237947..bf3df383ddc 100644 --- a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js +++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js @@ -9,3 +9,4 @@ export const UPDATE_ENABLED = 'UPDATE_ENABLED'; export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT'; export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING'; export const UPDATE_TOKEN = 'UPDATE_TOKEN'; +export const SET_PROJECTS_LOADING = 'SET_PROJECTS_LOADING'; diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js index 4089d1ee94e..133f25264b9 100644 --- a/app/assets/javascripts/error_tracking_settings/store/mutations.js +++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js @@ -58,4 +58,7 @@ export default { state.connectSuccessful = false; state.connectError = true; }, + [types.SET_PROJECTS_LOADING](state, loading) { + state.isLoadingProjects = loading; + }, }; diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js index 98219d33f4d..ab616f11e83 100644 --- a/app/assets/javascripts/error_tracking_settings/store/state.js +++ b/app/assets/javascripts/error_tracking_settings/store/state.js @@ -3,6 +3,7 @@ export default () => ({ enabled: false, token: '', projects: [], + isLoadingProjects: false, selectedProject: null, settingsLoading: false, connectSuccessful: false, diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index f280f3cd26c..5fa07045d5e 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -13,6 +13,7 @@ export default class AvailableDropdownMappings { runnerTagsEndpoint, labelsEndpoint, milestonesEndpoint, + releasesEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups, @@ -21,6 +22,7 @@ export default class AvailableDropdownMappings { this.runnerTagsEndpoint = runnerTagsEndpoint; this.labelsEndpoint = labelsEndpoint; this.milestonesEndpoint = milestonesEndpoint; + this.releasesEndpoint = releasesEndpoint; this.groupsOnly = groupsOnly; this.includeAncestorGroups = includeAncestorGroups; this.includeDescendantGroups = includeDescendantGroups; @@ -70,6 +72,19 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-milestone'), }, + release: { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getReleasesEndpoint(), + symbol: '', + + // The DropdownNonUser class is hardcoded to look for and display a + // "title" property, so we need to add this property to each release object + preprocessing: releases => releases.map(r => ({ ...r, title: r.tag })), + }, + element: this.container.querySelector('#js-dropdown-release'), + }, label: { reference: null, gl: DropdownNonUser, @@ -130,6 +145,10 @@ export default class AvailableDropdownMappings { return `${this.milestonesEndpoint}.json`; } + getReleasesEndpoint() { + return `${this.releasesEndpoint}.json`; + } + getLabelsEndpoint() { let endpoint = `${this.labelsEndpoint}.json?`; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 835d3bf8a53..5ff95f45be4 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -11,6 +11,7 @@ export default class FilteredSearchDropdownManager { runnerTagsEndpoint = '', labelsEndpoint = '', milestonesEndpoint = '', + releasesEndpoint = '', tokenizer, page, isGroup, @@ -18,10 +19,13 @@ export default class FilteredSearchDropdownManager { isGroupDecendent, filteredSearchTokenKeys, }) { + const removeTrailingSlash = url => url.replace(/\/$/, ''); + this.container = FilteredSearchContainer.container; - this.runnerTagsEndpoint = runnerTagsEndpoint.replace(/\/$/, ''); - this.labelsEndpoint = labelsEndpoint.replace(/\/$/, ''); - this.milestonesEndpoint = milestonesEndpoint.replace(/\/$/, ''); + this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint); + this.labelsEndpoint = removeTrailingSlash(labelsEndpoint); + this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint); + this.releasesEndpoint = removeTrailingSlash(releasesEndpoint); this.tokenizer = tokenizer; this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); @@ -54,6 +58,7 @@ export default class FilteredSearchDropdownManager { this.runnerTagsEndpoint, this.labelsEndpoint, this.milestonesEndpoint, + this.releasesEndpoint, this.groupsOnly, this.includeAncestorGroups, this.includeDescendantGroups, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index fd335362e5b..5c2d32f4e85 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -89,6 +89,7 @@ export default class FilteredSearchManager { this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '', labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '', milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '', + releasesEndpoint: this.filteredSearchInput.getAttribute('data-releases-endpoint') || '', tokenizer: this.tokenizer, page: this.page, isGroup: this.isGroup, diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index 6c3d9e33420..414bcf186a3 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -1,7 +1,9 @@ import FilteredSearchTokenKeys from './filtered_search_token_keys'; import { __ } from '~/locale'; -export const tokenKeys = [ +export const tokenKeys = []; + +tokenKeys.push( { key: 'author', type: 'string', @@ -26,15 +28,27 @@ export const tokenKeys = [ icon: 'clock', tag: '%milestone', }, - { - key: 'label', - type: 'array', - param: 'name[]', - symbol: '~', - icon: 'labels', - tag: '~label', - }, -]; +); + +if (gon && gon.features && gon.features.releaseSearchFilter) { + tokenKeys.push({ + key: 'release', + type: 'string', + param: 'tag', + symbol: '', + icon: 'rocket', + tag: __('tag name'), + }); +} + +tokenKeys.push({ + key: 'label', + type: 'array', + param: 'name[]', + symbol: '~', + icon: 'labels', + tag: '~label', +}); if (gon.current_user_id) { // Appending tokenkeys only logged-in @@ -89,6 +103,16 @@ export const conditions = [ value: __('Started'), }, { + url: 'release_tag=None', + tokenKey: 'release', + value: __('None'), + }, + { + url: 'release_tag=Any', + tokenKey: 'release', + value: __('Any'), + }, + { url: 'label_name[]=None', tokenKey: 'label', value: __('None'), diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index fc9c5827ed4..2c3320b5e79 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -37,7 +37,7 @@ const createAction = config => ` `; const createFlashEl = (message, type) => ` - <div class="flash-content flash-${type} rounded"> + <div class="flash-${type}"> <div class="flash-text"> ${_.escape(message)} <div class="close-icon-wrapper js-close-icon"> diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js index 41b660a243f..92ac3a2c94d 100644 --- a/app/assets/javascripts/frequent_items/store/mutations.js +++ b/app/assets/javascripts/frequent_items/store/mutations.js @@ -47,7 +47,8 @@ export default { hasSearchQuery: true, }); }, - [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) { + [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) { + const rawItems = results.data; Object.assign(state, { items: rawItems.map(rawItem => ({ id: rawItem.id, diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 4e1b4f2652c..045f77af7ea 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -617,7 +617,7 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.hidden = function(e) { var $input; this.resetRows(); - this.removeArrayKeyEvent(); + this.removeArrowKeyEvent(); $input = this.dropdown.find('.dropdown-input-field'); if (this.options.filterable) { $input.blur(); @@ -900,7 +900,7 @@ GitLabDropdown = (function() { ); }; - GitLabDropdown.prototype.removeArrayKeyEvent = function() { + GitLabDropdown.prototype.removeArrowKeyEvent = function() { return $('body').off('keydown'); }; diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue new file mode 100644 index 00000000000..bd504d95ee2 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -0,0 +1,103 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { mapState, mapActions } from 'vuex'; + +export default { + components: { + GlButton, + GlFormCheckbox, + GlFormGroup, + GlFormInput, + GlLink, + Icon, + }, + data() { + return { placeholderUrl: 'https://my-url.grafana.net/' }; + }, + computed: { + ...mapState(['operationsSettingsEndpoint', 'grafanaToken', 'grafanaUrl', 'grafanaEnabled']), + integrationEnabled: { + get() { + return this.grafanaEnabled; + }, + set(grafanaEnabled) { + this.setGrafanaEnabled(grafanaEnabled); + }, + }, + localGrafanaToken: { + get() { + return this.grafanaToken; + }, + set(token) { + this.setGrafanaToken(token); + }, + }, + localGrafanaUrl: { + get() { + return this.grafanaUrl; + }, + set(url) { + this.setGrafanaUrl(url); + }, + }, + }, + methods: { + ...mapActions([ + 'setGrafanaUrl', + 'setGrafanaToken', + 'setGrafanaEnabled', + 'updateGrafanaIntegration', + ]), + }, +}; +</script> + +<template> + <section id="grafana" class="settings no-animate js-grafana-integration"> + <div class="settings-header"> + <h4 class="js-section-header"> + {{ s__('GrafanaIntegration|Grafana Authentication') }} + </h4> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> + <p class="js-section-sub-header"> + {{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }} + </p> + </div> + <div class="settings-content"> + <form> + <gl-form-checkbox + id="grafana-integration-enabled" + v-model="integrationEnabled" + class="mb-4" + > + {{ s__('GrafanaIntegration|Active') }} + </gl-form-checkbox> + <gl-form-group + :label="s__('GrafanaIntegration|Grafana URL')" + label-for="grafana-url" + :description="s__('GrafanaIntegration|Enter the base URL of the Grafana instance.')" + > + <gl-form-input id="grafana-url" v-model="localGrafanaUrl" :placeholder="placeholderUrl" /> + </gl-form-group> + <gl-form-group :label="s__('GrafanaIntegration|API Token')" label-for="grafana-token"> + <gl-form-input id="grafana-token" v-model="localGrafanaToken" /> + <p class="form-text text-muted"> + {{ s__('GrafanaIntegration|Enter the Grafana API Token.') }} + <a + href="https://grafana.com/docs/http_api/auth/#create-api-token" + target="_blank" + rel="noopener noreferrer" + > + {{ __('More information') }} + <icon name="external-link" class="vertical-align-middle" /> + </a> + </p> + </gl-form-group> + <gl-button variant="success" @click="updateGrafanaIntegration"> + {{ __('Save Changes') }} + </gl-button> + </form> + </div> + </section> +</template> diff --git a/app/assets/javascripts/grafana_integration/index.js b/app/assets/javascripts/grafana_integration/index.js new file mode 100644 index 00000000000..a93edab4388 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import store from './store'; +import GrafanaIntegration from './components/grafana_integration.vue'; + +export default () => { + const el = document.querySelector('.js-grafana-integration'); + return new Vue({ + el, + store: store(el.dataset), + render(createElement) { + return createElement(GrafanaIntegration); + }, + }); +}; diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js new file mode 100644 index 00000000000..d83f1e0831c --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/actions.js @@ -0,0 +1,42 @@ +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import * as mutationTypes from './mutation_types'; + +export const setGrafanaUrl = ({ commit }, url) => commit(mutationTypes.SET_GRAFANA_URL, url); + +export const setGrafanaToken = ({ commit }, token) => + commit(mutationTypes.SET_GRAFANA_TOKEN, token); + +export const setGrafanaEnabled = ({ commit }, enabled) => + commit(mutationTypes.SET_GRAFANA_ENABLED, enabled); + +export const updateGrafanaIntegration = ({ state, dispatch }) => + axios + .patch(state.operationsSettingsEndpoint, { + project: { + grafana_integration_attributes: { + grafana_url: state.grafanaUrl, + token: state.grafanaToken, + enabled: state.grafanaEnabled, + }, + }, + }) + .then(() => dispatch('receiveGrafanaIntegrationUpdateSuccess')) + .catch(error => dispatch('receiveGrafanaIntegrationUpdateError', error)); + +export const receiveGrafanaIntegrationUpdateSuccess = () => { + /** + * The operations_controller currently handles successful requests + * by creating a flash banner messsage to notify the user. + */ + refreshCurrentPage(); +}; + +export const receiveGrafanaIntegrationUpdateError = (_, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); +}; diff --git a/app/assets/javascripts/grafana_integration/store/index.js b/app/assets/javascripts/grafana_integration/store/index.js new file mode 100644 index 00000000000..e96bb1e8aad --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/index.js @@ -0,0 +1,16 @@ +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 const createStore = initialState => + new Vuex.Store({ + state: createState(initialState), + actions, + mutations, + }); + +export default createStore; diff --git a/app/assets/javascripts/grafana_integration/store/mutation_types.js b/app/assets/javascripts/grafana_integration/store/mutation_types.js new file mode 100644 index 00000000000..314c3a4039a --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/mutation_types.js @@ -0,0 +1,3 @@ +export const SET_GRAFANA_URL = 'SET_GRAFANA_URL'; +export const SET_GRAFANA_TOKEN = 'SET_GRAFANA_TOKEN'; +export const SET_GRAFANA_ENABLED = 'SET_GRAFANA_ENABLED'; diff --git a/app/assets/javascripts/grafana_integration/store/mutations.js b/app/assets/javascripts/grafana_integration/store/mutations.js new file mode 100644 index 00000000000..0992030d404 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/mutations.js @@ -0,0 +1,13 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_GRAFANA_URL](state, url) { + state.grafanaUrl = url; + }, + [types.SET_GRAFANA_TOKEN](state, token) { + state.grafanaToken = token; + }, + [types.SET_GRAFANA_ENABLED](state, enabled) { + state.grafanaEnabled = enabled; + }, +}; diff --git a/app/assets/javascripts/grafana_integration/store/state.js b/app/assets/javascripts/grafana_integration/store/state.js new file mode 100644 index 00000000000..a912eb58327 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/state.js @@ -0,0 +1,8 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default (initialState = {}) => ({ + operationsSettingsEndpoint: initialState.operationsSettingsEndpoint, + grafanaToken: initialState.grafanaIntegrationToken || '', + grafanaUrl: initialState.grafanaIntegrationUrl || '', + grafanaEnabled: parseBoolean(initialState.grafanaIntegrationEnabled) || false, +}); diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index 460174caf4d..eda0f5d1d23 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,15 +1,23 @@ import $ from 'jquery'; import { slugify } from './lib/utils/text_utility'; +import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; +import flash from '~/flash'; +import { __ } from '~/locale'; export default class Group { constructor() { this.groupPath = $('#group_path'); this.groupName = $('#group_name'); + this.parentId = $('#group_parent_id'); this.updateHandler = this.update.bind(this); this.resetHandler = this.reset.bind(this); + this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this); if (this.groupName.val() === '') { this.groupName.on('keyup', this.updateHandler); this.groupPath.on('keydown', this.resetHandler); + if (!this.parentId.val()) { + this.groupName.on('blur', this.updateGroupPathSlugHandler); + } } } @@ -21,5 +29,21 @@ export default class Group { reset() { this.groupName.off('keyup', this.updateHandler); this.groupPath.off('keydown', this.resetHandler); + this.groupName.off('blur', this.checkPathHandler); + } + + updateGroupPathSlug() { + const slug = this.groupPath.val() || slugify(this.groupName.val()); + if (!slug) return; + + fetchGroupPathAvailability(slug) + .then(({ data }) => data) + .then(data => { + if (data.exists && data.suggests.length > 0) { + const suggestedSlug = data.suggests[0]; + this.groupPath.val(suggestedSlug); + } + }) + .catch(() => flash(__('An error occurred while checking group path'))); } } diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js index 2c2a04d5b5e..d172aa8a444 100644 --- a/app/assets/javascripts/helpers/monitor_helper.js +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -1,17 +1,31 @@ -/* eslint-disable import/prefer-default-export */ - +/** + * @param {Array} queryResults - Array of Result objects + * @param {Object} defaultConfig - Default chart config values (e.g. lineStyle, name) + * @returns {Array} The formatted values + */ +// eslint-disable-next-line 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}`; - } + queryResults + .map(result => { + const data = result.values.filter(([, value]) => !Number.isNaN(value)); + if (!data.length) { + return null; + } + const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_'); + const name = result.metric[relevantMetric]; + const series = { data }; + if (name) { + series.name = `${defaultConfig.name}: ${name}`; + } else { + series.name = defaultConfig.name; + Object.keys(result.metric).forEach(templateVar => { + const value = result.metric[templateVar]; + const regex = new RegExp(`{{\\s*${templateVar}\\s*}}`, 'g'); + + series.name = series.name.replace(regex, value); + }); + } - return acc.concat({ ...defaultConfig, ...series }); - }, []); + return { ...defaultConfig, ...series }; + }) + .filter(series => series !== null); diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 9ad9d4455b5..52ca61c06b0 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -58,6 +58,7 @@ export default { <template> <div class="ide-stage card prepend-top-default"> <div + ref="cardHeader" :class="{ 'border-bottom-0': stage.isCollapsed, }" @@ -79,7 +80,7 @@ export default { </div> <icon :name="collapseIcon" class="ide-stage-collapse-icon" /> </div> - <div v-show="!stage.isCollapsed" class="card-body"> + <div v-show="!stage.isCollapsed" ref="jobList" class="card-body"> <gl-loading-icon v-if="showLoadingIcon" /> <template v-else> <item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" /> diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index 6999746f115..beb179d0411 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -92,6 +92,7 @@ export default { }, methods: { ...mapActions(['getFileData', 'getRawFileData']), + ...mapActions('clientside', ['pingUsage']), loadFileContent(path) { return this.getFileData({ path, makeFileActive: false }).then(() => this.getRawFileData({ path }), @@ -100,6 +101,8 @@ export default { initPreview() { if (!this.mainEntry) return null; + this.pingUsage(); + return this.loadFileContent(this.mainEntry) .then(() => this.$nextTick()) .then(() => { diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 3bf8308ccea..08b3e8a34d6 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -301,6 +301,7 @@ export default { v-if="showContentViewer" :content="file.content || file.raw" :path="file.rawPath || file.path" + :file-path="file.path" :file-size="file.size" :project-path="file.projectId" :type="fileType" diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index ba33b6826d6..f6ad2f9c7d1 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,4 +1,6 @@ import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { escapeFileUrl } from '../stores/utils'; import Api from '~/api'; export default { @@ -23,18 +25,25 @@ export default { .then(({ data }) => data); }, getBaseRawFileData(file, sha) { - if (file.tempFile) { - return Promise.resolve(file.baseRaw); - } + if (file.tempFile || file.baseRaw) return Promise.resolve(file.baseRaw); - if (file.baseRaw) { - return Promise.resolve(file.baseRaw); - } + // if files are renamed, their base path has changed + const filePath = + file.mrChange && file.mrChange.renamed_file ? file.mrChange.old_path : file.path; return axios - .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), { - transformResponse: [f => f], - }) + .get( + joinPaths( + gon.relative_url_root || '/', + file.projectId, + 'raw', + sha, + escapeFileUrl(filePath), + ), + { + transformResponse: [f => f], + }, + ) .then(({ data }) => data); }, getProjectData(namespace, project) { @@ -58,8 +67,8 @@ export default { commit(projectId, payload) { return Api.commitMultiple(projectId, payload); }, - getFiles(projectUrl, branchId) { - const url = `${projectUrl}/files/${branchId}`; + getFiles(projectUrl, ref) { + const url = `${projectUrl}/files/${ref}`; return axios.get(url, { params: { format: 'json' } }); }, lastCommitPipelines({ getters }) { diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 59445afc7a4..9af0b50d1a5 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,11 +1,10 @@ import { joinPaths } from '~/lib/utils/url_utility'; -import { normalizeHeaders } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; -import { setPageTitle, replaceFileUrl } from '../utils'; +import { escapeFileUrl, addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils'; import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { @@ -58,7 +57,7 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { }; export const getFileData = ( - { state, commit, dispatch }, + { state, commit, dispatch, getters }, { path, makeFileActive = true, openFile = makeFileActive }, ) => { const file = state.entries[path]; @@ -67,15 +66,18 @@ export const getFileData = ( commit(types.TOGGLE_LOADING, { entry: file }); - const url = file.prevPath ? replaceFileUrl(file.url, file.path, file.prevPath) : file.url; + const url = joinPaths( + gon.relative_url_root || '/', + state.currentProjectId, + file.type, + getters.lastCommit && getters.lastCommit.id, + escapeFileUrl(file.prevPath || file.path), + ); return service - .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/'))) - .then(({ data, headers }) => { - const normalizedHeaders = normalizeHeaders(headers); - let title = normalizedHeaders['PAGE-TITLE']; - title = file.prevPath ? title.replace(file.prevPath, file.path) : title; - setPageTitle(decodeURI(title)); + .getFileData(url) + .then(({ data }) => { + setPageTitleForFile(state, file); if (data) commit(types.SET_FILE_DATA, { data, file }); if (openFile) commit(types.TOGGLE_FILE_OPEN, path); @@ -140,7 +142,10 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = export const changeFileContent = ({ commit, dispatch, state }, { path, content }) => { const file = state.entries[path]; - commit(types.UPDATE_FILE_CONTENT, { path, content }); + commit(types.UPDATE_FILE_CONTENT, { + path, + content: addFinalNewlineIfNeeded(content), + }); const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 1273e375859..6790c0fbdaa 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -152,15 +152,17 @@ export const openMergeRequest = ( .then(mr => { dispatch('setCurrentBranchId', mr.source_branch); - dispatch('getBranchData', { + // getFiles needs to be called after getting the branch data + // since files are fetched using the last commit sha of the branch + return dispatch('getBranchData', { projectId, branchId: mr.source_branch, - }); - - return dispatch('getFiles', { - projectId, - branchId: mr.source_branch, - }); + }).then(() => + dispatch('getFiles', { + projectId, + branchId: mr.source_branch, + }), + ); }) .then(() => dispatch('getMergeRequestVersions', { diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 75511574d3e..72cd099c5a5 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -46,7 +46,7 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL }); }; -export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => +export const getFiles = ({ state, commit, dispatch, getters }, { projectId, branchId } = {}) => new Promise((resolve, reject) => { if ( !state.trees[`${projectId}/${branchId}`] || @@ -54,10 +54,11 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = state.trees[`${projectId}/${branchId}`].tree.length === 0) ) { const selectedProject = state.projects[projectId]; - commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); + const selectedBranch = getters.findBranch(projectId, branchId); + commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); service - .getFiles(selectedProject.web_url, branchId) + .getFiles(selectedProject.web_url, selectedBranch.commit.id) .then(({ data }) => { const { entries, treeList } = decorateFiles({ data, diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 85fd45358be..a176fd0aca8 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -34,7 +34,9 @@ export const currentMergeRequest = state => { return null; }; -export const currentProject = state => state.projects[state.currentProjectId]; +export const findProject = state => projectId => state.projects[projectId]; + +export const currentProject = (state, getters) => getters.findProject(state.currentProjectId); export const emptyRepo = state => state.projects[state.currentProjectId] && state.projects[state.currentProjectId].empty_repo; @@ -94,8 +96,14 @@ export const lastCommit = (state, getters) => { return branch ? branch.commit : null; }; +export const findBranch = (state, getters) => (projectId, branchId) => { + const project = getters.findProject(projectId); + + return project && project.branches[branchId]; +}; + export const currentBranch = (state, getters) => - getters.currentProject && getters.currentProject.branches[state.currentBranchId]; + getters.findBranch(state.currentProjectId, state.currentBranchId); export const branchName = (_state, getters) => getters.currentBranch && getters.currentBranch.name; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index f1f544b52b2..85550578e94 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -10,6 +10,7 @@ import mergeRequests from './modules/merge_requests'; import branches from './modules/branches'; import fileTemplates from './modules/file_templates'; import paneModule from './modules/pane'; +import clientsideModule from './modules/clientside'; Vue.use(Vuex); @@ -26,6 +27,7 @@ export const createStore = () => branches, fileTemplates: fileTemplates(), rightPane: paneModule(), + clientside: clientsideModule(), }, }); diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js new file mode 100644 index 00000000000..eb3bcdff2ae --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js @@ -0,0 +1,12 @@ +import axios from '~/lib/utils/axios_utils'; + +export const pingUsage = ({ rootGetters }) => { + const { web_url: projectUrl } = rootGetters.currentProject; + + const url = `${projectUrl}/usage_ping/web_ide_clientside_preview`; + + return axios.post(url); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/clientside/index.js b/app/assets/javascripts/ide/stores/modules/clientside/index.js new file mode 100644 index 00000000000..b28f7b935a8 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/clientside/index.js @@ -0,0 +1,6 @@ +import * as actions from './actions'; + +export default () => ({ + namespaced: true, + actions, +}); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index a8d8ff31afe..be7ee80656f 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -113,6 +113,11 @@ export const setPageTitle = title => { document.title = title; }; +export const setPageTitleForFile = (state, file) => { + const title = [file.path, state.currentBranchId, state.currentProjectId, 'GitLab'].join(' · '); + setPageTitle(title); +}; + export const commitActionForFile = file => { if (file.prevPath) { return commitActionTypes.move; @@ -269,3 +274,7 @@ export const pathsAreEqual = (a, b) => { return cleanA === cleanB; }; + +// if the contents of a file dont end with a newline, this function adds a newline +export const addFinalNewlineIfNeeded = content => + content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index 74150ce3a8b..bd6e8433544 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,11 +1,13 @@ /* eslint-disable class-methods-use-this, no-new */ import $ from 'jquery'; +import { property } from 'underscore'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import MilestoneSelect from './milestone_select'; import issueStatusSelect from './issue_status_select'; import subscriptionSelect from './subscription_select'; import LabelsSelect from './labels_select'; +import issueableEventHub from './issuables_list/eventhub'; const HIDDEN_CLASS = 'hidden'; const DISABLED_CONTENT_CLASS = 'disabled-content'; @@ -14,6 +16,8 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si export default class IssuableBulkUpdateSidebar { constructor() { + this.vueIssuablesListFeature = property(['gon', 'features', 'vueIssuablesList'])(window); + this.initDomElements(); this.bindEvents(); this.initDropdowns(); @@ -41,6 +45,17 @@ export default class IssuableBulkUpdateSidebar { this.$issuesList.on('change', () => this.updateFormState()); this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); this.$checkAllContainer.on('click', () => this.updateFormState()); + + if (this.vueIssuablesListFeature) { + issueableEventHub.$on('issuables:updateBulkEdit', () => { + // Danger! Strong coupling ahead! + // The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue + // is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties + // explicitly, but this component is used in too many places right now to refactor straight away. + + this.updateFormState(); + }); + } } initDropdowns() { @@ -73,6 +88,8 @@ export default class IssuableBulkUpdateSidebar { toggleBulkEdit(e, enable) { e.preventDefault(); + issueableEventHub.$emit('issuables:toggleBulkEdit', enable); + this.toggleSidebarDisplay(enable); this.toggleBulkEditButtonDisabled(enable); this.toggleOtherFiltersDisabled(enable); @@ -106,7 +123,7 @@ export default class IssuableBulkUpdateSidebar { } toggleCheckboxDisplay(show) { - this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show); + this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature); this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); } diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue new file mode 100644 index 00000000000..eb924609a8a --- /dev/null +++ b/app/assets/javascripts/issuables_list/components/issuable.vue @@ -0,0 +1,335 @@ +<script> +/* + * This is tightly coupled to projects/issues/_issue.html.haml, + * any changes done to the haml need to be reflected here. + */ +import { escape, isNumber } from 'underscore'; +import { GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { + dateInWords, + formatDate, + getDayDifference, + getTimeago, + timeFor, + newDateAsLocaleTime, +} from '~/lib/utils/datetime_utility'; +import { sprintf, __ } from '~/locale'; +import initUserPopovers from '~/user_popovers'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import Icon from '~/vue_shared/components/icon.vue'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; + +const ISSUE_TOKEN = '#'; + +export default { + components: { + Icon, + IssueAssignees, + GlLink, + }, + directives: { + GlTooltip, + }, + props: { + issuable: { + type: Object, + required: true, + }, + isBulkEditing: { + type: Boolean, + required: false, + default: false, + }, + selected: { + type: Boolean, + required: false, + default: false, + }, + baseUrl: { + type: String, + required: false, + default() { + return window.location.href; + }, + }, + }, + computed: { + milestoneLink() { + const { title } = this.issuable.milestone; + + return this.issuableLink({ milestone_title: title }); + }, + hasLabels() { + return Boolean(this.issuable.labels && this.issuable.labels.length); + }, + hasWeight() { + return isNumber(this.issuable.weight); + }, + dueDate() { + return this.issuable.due_date ? newDateAsLocaleTime(this.issuable.due_date) : undefined; + }, + dueDateWords() { + return this.dueDate ? dateInWords(this.dueDate, true) : undefined; + }, + hasNoComments() { + return !this.userNotesCount; + }, + isOverdue() { + return this.dueDate ? this.dueDate < new Date() : false; + }, + isClosed() { + return this.issuable.state === 'closed'; + }, + issueCreatedToday() { + return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1; + }, + labelIdsString() { + return JSON.stringify(this.issuable.labels.map(l => l.id)); + }, + milestoneDueDate() { + const { due_date: dueDate } = this.issuable.milestone || {}; + + return dueDate ? newDateAsLocaleTime(dueDate) : undefined; + }, + milestoneTooltipText() { + if (this.milestoneDueDate) { + return sprintf(__('%{primary} (%{secondary})'), { + primary: formatDate(this.milestoneDueDate, 'mmm d, yyyy'), + secondary: timeFor(this.milestoneDueDate), + }); + } + return __('Milestone'); + }, + openedAgoByString() { + const { author, created_at } = this.issuable; + + return sprintf( + __('opened %{timeAgoString} by %{user}'), + { + timeAgoString: escape(getTimeago().format(created_at)), + user: `<a href="${escape(author.web_url)}" + data-user-id=${escape(author.id)} + data-username=${escape(author.username)} + data-name=${escape(author.name)} + data-avatar-url="${escape(author.avatar_url)}"> + ${escape(author.name)} + </a>`, + }, + false, + ); + }, + referencePath() { + // TODO: The API should return the reference path (it doesn't now) https://gitlab.com/gitlab-org/gitlab/issues/31301 + return `${ISSUE_TOKEN}${this.issuable.iid}`; + }, + updatedDateString() { + return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt'); + }, + updatedDateAgo() { + // snake_case because it's the same i18n string as the HAML view + return sprintf(__('updated %{time_ago}'), { + time_ago: escape(getTimeago().format(this.issuable.updated_at)), + }); + }, + userNotesCount() { + return this.issuable.user_notes_count; + }, + issuableMeta() { + return [ + { + key: 'merge-requests', + value: this.issuable.merge_requests_count, + title: __('Related merge requests'), + class: 'js-merge-requests', + icon: 'merge-request', + }, + { + key: 'upvotes', + value: this.issuable.upvotes, + title: __('Upvotes'), + class: 'js-upvotes', + faicon: 'fa-thumbs-up', + }, + { + key: 'downvotes', + value: this.issuable.downvotes, + title: __('Downvotes'), + class: 'js-downvotes', + faicon: 'fa-thumbs-down', + }, + ]; + }, + }, + mounted() { + // TODO: Refactor user popover to use its own component instead of + // spawning event listeners on Vue-rendered elements. + initUserPopovers([this.$refs.openedAgoByContainer.querySelector('a')]); + }, + methods: { + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.text_color, + }; + }, + issuableLink(params) { + return mergeUrlParams(params, this.baseUrl); + }, + labelHref({ name }) { + return this.issuableLink({ 'label_name[]': name }); + }, + onSelect(ev) { + this.$emit('select', { + issuable: this.issuable, + selected: ev.target.checked, + }); + }, + }, + + confidentialTooltipText: __('Confidential'), +}; +</script> +<template> + <li + :id="`issue_${issuable.id}`" + class="issue" + :class="{ today: issueCreatedToday, closed: isClosed }" + :data-id="issuable.id" + :data-labels="labelIdsString" + :data-url="issuable.web_url" + > + <div class="d-flex"> + <!-- Bulk edit checkbox --> + <div v-if="isBulkEditing" class="mr-2"> + <input + :checked="selected" + class="selected-issuable" + type="checkbox" + :data-id="issuable.id" + @input="onSelect" + /> + </div> + + <!-- Issuable info container --> + <!-- Issuable main info --> + <div class="flex-grow-1"> + <div class="title"> + <span class="issue-title-text"> + <i + v-if="issuable.confidential" + v-gl-tooltip + class="fa fa-eye-slash" + :title="$options.confidentialTooltipText" + :aria-label="$options.confidentialTooltipText" + ></i> + <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link> + </span> + <span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block">{{ + issuable.task_status + }}</span> + </div> + + <div class="issuable-info"> + <span>{{ referencePath }}</span> + + <span class="d-none d-sm-inline-block mr-1"> + · + <span ref="openedAgoByContainer" v-html="openedAgoByString"></span> + </span> + + <gl-link + v-if="issuable.milestone" + v-gl-tooltip + class="d-none d-sm-inline-block mr-1 js-milestone" + :href="milestoneLink" + :title="milestoneTooltipText" + > + <i class="fa fa-clock-o"></i> + {{ issuable.milestone.title }} + </gl-link> + + <span + v-if="dueDate" + v-gl-tooltip + class="d-none d-sm-inline-block mr-1 js-due-date" + :class="{ cred: isOverdue }" + :title="__('Due date')" + > + <i class="fa fa-calendar"></i> + {{ dueDateWords }} + </span> + + <span v-if="hasLabels" class="js-labels"> + <gl-link + v-for="label in issuable.labels" + :key="label.id" + class="label-link mr-1" + :href="labelHref(label)" + > + <span + v-gl-tooltip + class="badge color-label" + :style="labelStyle(label)" + :title="label.description" + >{{ label.name }}</span + > + </gl-link> + </span> + + <span + v-if="hasWeight" + v-gl-tooltip + :title="__('Weight')" + class="d-none d-sm-inline-block js-weight" + > + <icon name="weight" class="align-text-bottom" /> + {{ issuable.weight }} + </span> + </div> + </div> + + <!-- Issuable meta --> + <div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center"> + <div class="controls d-flex"> + <span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> + + <issue-assignees + :assignees="issuable.assignees" + class="align-items-center d-flex ml-2" + :icon-size="16" + img-css-classes="mr-1" + :max-visible="4" + /> + + <template v-for="meta in issuableMeta"> + <span + v-if="meta.value" + :key="meta.key" + v-gl-tooltip + :class="['d-none d-sm-inline-block ml-2', meta.class]" + :title="meta.title" + > + <icon v-if="meta.icon" :name="meta.icon" /> + <i v-else :class="['fa', meta.faicon]"></i> + {{ meta.value }} + </span> + </template> + + <gl-link + v-gl-tooltip + class="ml-2 js-notes" + :href="`${issuable.web_url}#notes`" + :title="__('Comments')" + :class="{ 'no-comments': hasNoComments }" + > + <i class="fa fa-comments"></i> + {{ userNotesCount }} + </gl-link> + </div> + <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString"> + {{ updatedDateAgo }} + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue new file mode 100644 index 00000000000..6b6a8bd4068 --- /dev/null +++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue @@ -0,0 +1,277 @@ +<script> +import { omit } from 'underscore'; +import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; +import flash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { scrollToElement, urlParamsToObject } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import initManualOrdering from '~/manual_ordering'; +import Issuable from './issuable.vue'; +import { + sortOrderMap, + RELATIVE_POSITION, + PAGE_SIZE, + PAGE_SIZE_MANUAL, + LOADING_LIST_ITEMS_LENGTH, +} from '../constants'; +import issueableEventHub from '../eventhub'; + +export default { + LOADING_LIST_ITEMS_LENGTH, + components: { + GlEmptyState, + GlPagination, + GlSkeletonLoading, + Issuable, + }, + props: { + canBulkEdit: { + type: Boolean, + required: false, + default: false, + }, + createIssuePath: { + type: String, + required: false, + default: '', + }, + emptySvgPath: { + type: String, + required: false, + default: '', + }, + endpoint: { + type: String, + required: true, + }, + sortKey: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + filters: {}, + isBulkEditing: false, + issuables: [], + loading: false, + page: 1, + selection: {}, + totalItems: 0, + }; + }, + computed: { + allIssuablesSelected() { + // WARNING: Because we are only keeping track of selected values + // this works, we will need to rethink this if we start tracking + // [id]: false for not selected values. + return this.issuables.length === Object.keys(this.selection).length; + }, + emptyState() { + if (this.issuables.length) { + return {}; // Empty state shouldn't be shown here + } else if (this.hasFilters) { + return { + title: __('Sorry, your filter produced no results'), + description: __('To widen your search, change or remove filters above'), + }; + } else if (this.filters.state === 'opened') { + return { + title: __('There are no open issues'), + description: __('To keep this project going, create a new issue'), + primaryLink: this.createIssuePath, + primaryText: __('New issue'), + }; + } else if (this.filters.state === 'closed') { + return { + title: __('There are no closed issues'), + }; + } + + return { + title: __('There are no issues to show'), + description: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', + ), + }; + }, + hasFilters() { + const ignored = ['utf8', 'state', 'scope', 'order_by', 'sort']; + return Object.keys(omit(this.filters, ignored)).length > 0; + }, + isManualOrdering() { + return this.sortKey === RELATIVE_POSITION; + }, + itemsPerPage() { + return this.isManualOrdering ? PAGE_SIZE_MANUAL : PAGE_SIZE; + }, + baseUrl() { + return window.location.href.replace(/(\?.*)?(#.*)?$/, ''); + }, + }, + watch: { + selection() { + // We need to call nextTick here to wait for all of the boxes to be checked and rendered + // before we query the dom in issuable_bulk_update_actions.js. + this.$nextTick(() => { + issueableEventHub.$emit('issuables:updateBulkEdit'); + }); + }, + issuables() { + this.$nextTick(() => { + initManualOrdering(); + }); + }, + }, + mounted() { + if (this.canBulkEdit) { + this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', val => { + this.isBulkEditing = val; + }); + } + this.fetchIssuables(); + }, + beforeDestroy() { + issueableEventHub.$off('issuables:toggleBulkEdit'); + }, + methods: { + isSelected(issuableId) { + return Boolean(this.selection[issuableId]); + }, + setSelection(ids) { + ids.forEach(id => { + this.select(id, true); + }); + }, + clearSelection() { + this.selection = {}; + }, + select(id, isSelect = true) { + if (isSelect) { + this.$set(this.selection, id, true); + } else { + this.$delete(this.selection, id); + } + }, + fetchIssuables(pageToFetch) { + this.loading = true; + + this.clearSelection(); + + this.setFilters(); + + return axios + .get(this.endpoint, { + params: { + ...this.filters, + + with_labels_details: true, + page: pageToFetch || this.page, + per_page: this.itemsPerPage, + }, + }) + .then(response => { + this.loading = false; + this.issuables = response.data; + this.totalItems = Number(response.headers['x-total']); + this.page = Number(response.headers['x-page']); + }) + .catch(() => { + this.loading = false; + return flash(__('An error occurred while loading issues')); + }); + }, + getQueryObject() { + return urlParamsToObject(window.location.search); + }, + onPaginate(newPage) { + if (newPage === this.page) return; + + scrollToElement('#content-body'); + this.fetchIssuables(newPage); + }, + onSelectAll() { + if (this.allIssuablesSelected) { + this.selection = {}; + } else { + this.setSelection(this.issuables.map(({ id }) => id)); + } + }, + onSelectIssuable({ issuable, selected }) { + if (!this.canBulkEdit) return; + + this.select(issuable.id, selected); + }, + setFilters() { + const { + label_name: labels, + milestone_title: milestoneTitle, + ...filters + } = this.getQueryObject(); + + if (milestoneTitle) { + filters.milestone = milestoneTitle; + } + if (Array.isArray(labels)) { + filters.labels = labels.join(','); + } + if (!filters.state) { + filters.state = 'opened'; + } + + Object.assign(filters, sortOrderMap[this.sortKey]); + + this.filters = filters; + }, + }, +}; +</script> + +<template> + <ul v-if="loading" class="content-list"> + <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue"> + <gl-skeleton-loading /> + </li> + </ul> + <div v-else-if="issuables.length"> + <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> + <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" /> + <strong>{{ __('Select all') }}</strong> + </div> + <ul + class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + > + <issuable + v-for="issuable in issuables" + :key="issuable.id" + class="pr-3" + :class="{ 'user-can-drag': isManualOrdering }" + :issuable="issuable" + :is-bulk-editing="isBulkEditing" + :selected="isSelected(issuable.id)" + :base-url="baseUrl" + @select="onSelectIssuable" + /> + </ul> + <div class="mt-3"> + <gl-pagination + v-if="totalItems" + :value="page" + :per-page="itemsPerPage" + :total-items="totalItems" + class="justify-content-center" + @input="onPaginate" + /> + </div> + </div> + <gl-empty-state + v-else + :title="emptyState.title" + :description="emptyState.description" + :svg-path="emptySvgPath" + :primary-button-link="emptyState.primaryLink" + :primary-button-text="emptyState.primaryText" + /> +</template> diff --git a/app/assets/javascripts/issuables_list/constants.js b/app/assets/javascripts/issuables_list/constants.js new file mode 100644 index 00000000000..71b9c52c703 --- /dev/null +++ b/app/assets/javascripts/issuables_list/constants.js @@ -0,0 +1,33 @@ +// Maps sort order as it appears in the URL query to API `order_by` and `sort` params. +const PRIORITY = 'priority'; +const ASC = 'asc'; +const DESC = 'desc'; +const CREATED_AT = 'created_at'; +const UPDATED_AT = 'updated_at'; +const DUE_DATE = 'due_date'; +const MILESTONE_DUE = 'milestone_due'; +const POPULARITY = 'popularity'; +const WEIGHT = 'weight'; +const LABEL_PRIORITY = 'label_priority'; +export const RELATIVE_POSITION = 'relative_position'; +export const LOADING_LIST_ITEMS_LENGTH = 8; +export const PAGE_SIZE = 20; +export const PAGE_SIZE_MANUAL = 100; + +export const sortOrderMap = { + priority: { order_by: PRIORITY, sort: ASC }, // asc and desc are flipped for some reason + created_date: { order_by: CREATED_AT, sort: DESC }, + created_asc: { order_by: CREATED_AT, sort: ASC }, + updated_desc: { order_by: UPDATED_AT, sort: DESC }, + updated_asc: { order_by: UPDATED_AT, sort: ASC }, + milestone_due_desc: { order_by: MILESTONE_DUE, sort: DESC }, + milestone: { order_by: MILESTONE_DUE, sort: ASC }, + due_date_desc: { order_by: DUE_DATE, sort: DESC }, + due_date: { order_by: DUE_DATE, sort: ASC }, + popularity: { order_by: POPULARITY, sort: DESC }, + popularity_asc: { order_by: POPULARITY, sort: ASC }, + label_priority: { order_by: LABEL_PRIORITY, sort: ASC }, // asc and desc are flipped + relative_position: { order_by: RELATIVE_POSITION, sort: ASC }, + weight_desc: { order_by: WEIGHT, sort: DESC }, + weight: { order_by: WEIGHT, sort: ASC }, +}; diff --git a/app/assets/javascripts/issuables_list/eventhub.js b/app/assets/javascripts/issuables_list/eventhub.js new file mode 100644 index 00000000000..d1601a7d8f3 --- /dev/null +++ b/app/assets/javascripts/issuables_list/eventhub.js @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +const issueablesEventBus = new Vue(); + +export default issueablesEventBus; diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js new file mode 100644 index 00000000000..9fc7fa837ff --- /dev/null +++ b/app/assets/javascripts/issuables_list/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import IssuablesListApp from './components/issuables_list_app.vue'; + +export default function initIssuablesList() { + if (!gon.features || !gon.features.vueIssuablesList) { + return; + } + + document.querySelectorAll('.js-issuables-list').forEach(el => { + const { canBulkEdit, ...data } = el.dataset; + + const props = { + ...data, + canBulkEdit: Boolean(canBulkEdit), + }; + + return new Vue({ + el, + render(createElement) { + return createElement(IssuablesListApp, { props }); + }, + }); + }); +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index a9e086fade8..9136a47d542 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,4 +1,4 @@ -/* eslint-disable no-var, one-var, consistent-return */ +/* eslint-disable consistent-return */ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; @@ -91,18 +91,17 @@ export default class Issue { 'click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', e => { - var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); - $button = $(e.currentTarget); - shouldSubmit = $button.hasClass('btn-comment'); + const $button = $(e.currentTarget); + const shouldSubmit = $button.hasClass('btn-comment'); if (shouldSubmit) { Issue.submitNoteForm($button.closest('form')); } this.disableCloseReopenButton($button); - url = $button.attr('href'); + const url = $button.attr('href'); return axios .put(url) .then(({ data }) => { @@ -139,16 +138,14 @@ export default class Issue { } static submitNoteForm(form) { - var noteText; - noteText = form.find('textarea.js-note-text').val(); + const noteText = form.find('textarea.js-note-text').val(); if (noteText && noteText.trim().length > 0) { return form.submit(); } } static initRelatedBranches() { - var $container; - $container = $('#related-branches'); + const $container = $('#related-branches'); return axios .get($container.data('url')) .then(({ data }) => { diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index ef126166e8b..03a697d11ed 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -11,11 +11,35 @@ export default { computed: { ...mapState(['traceEndpoint', 'trace', 'isTraceComplete']), }, + updated() { + this.$nextTick(() => { + this.handleScrollDown(); + }); + }, + mounted() { + this.$nextTick(() => { + this.handleScrollDown(); + }); + }, methods: { - ...mapActions(['toggleCollapsibleLine']), + ...mapActions(['toggleCollapsibleLine', 'scrollBottom']), handleOnClickCollapsibleLine(section) { this.toggleCollapsibleLine(section); }, + /** + * The job log is sent in HTML, which means we need to use `v-html` to render it + * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated + * in this case because it runs before `v-html` has finished running, since there's no + * Vue binding. + * In order to scroll the page down after `v-html` has finished, we need to use setTimeout + */ + handleScrollDown() { + if (this.isScrolledToBottomBeforeReceivingTrace) { + setTimeout(() => { + this.scrollBottom(); + }, 0); + } + }, }, }; </script> diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 58e49f54d96..179d0bc4e0f 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -17,7 +17,7 @@ export const parseLine = (line = {}, lineNumber) => ({ * @param Number lineNumber */ export const parseHeaderLine = (line = {}, lineNumber) => ({ - isClosed: true, + isClosed: false, isHeader: true, line: parseLine(line, lineNumber), lines: [], diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 72de3b5d726..6abf723be9a 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, one-var, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */ +/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-else-return, dot-notation, no-empty */ /* global Issuable */ /* global ListLabel */ @@ -15,63 +15,39 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; export default class LabelsSelect { constructor(els, options = {}) { - var _this, $els; - _this = this; + const _this = this; - $els = $(els); + let $els = $(els); if (!els) { $els = $('.js-label-select'); } $els.each((i, dropdown) => { - var $block, - $dropdown, - $form, - $loading, - $selectbox, - $sidebarCollapsedValue, - $value, - $dropdownMenu, - abilityName, - defaultLabel, - issueUpdateURL, - labelUrl, - namespacePath, - projectPath, - saveLabelData, - selectedLabel, - showAny, - showNo, - $sidebarLabelTooltip, - initialSelected, - fieldName, - showMenuAbove, - $dropdownContainer; - $dropdown = $(dropdown); - $dropdownContainer = $dropdown.closest('.labels-filter'); - namespacePath = $dropdown.data('namespacePath'); - projectPath = $dropdown.data('projectPath'); - issueUpdateURL = $dropdown.data('issueUpdate'); - selectedLabel = $dropdown.data('selected'); + const $dropdown = $(dropdown); + const $dropdownContainer = $dropdown.closest('.labels-filter'); + const namespacePath = $dropdown.data('namespacePath'); + const projectPath = $dropdown.data('projectPath'); + const issueUpdateURL = $dropdown.data('issueUpdate'); + let selectedLabel = $dropdown.data('selected'); if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) { selectedLabel = selectedLabel.split(','); } - showNo = $dropdown.data('showNo'); - showAny = $dropdown.data('showAny'); - showMenuAbove = $dropdown.data('showMenuAbove'); - defaultLabel = $dropdown.data('defaultLabel') || __('Label'); - abilityName = $dropdown.data('abilityName'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - $form = $dropdown.closest('form, .js-issuable-update'); - $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); - $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); - $value = $block.find('.value'); - $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); - $loading = $block.find('.block-loading').fadeOut(); - fieldName = $dropdown.data('fieldName'); - initialSelected = $selectbox + const showNo = $dropdown.data('showNo'); + const showAny = $dropdown.data('showAny'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const defaultLabel = $dropdown.data('defaultLabel') || __('Label'); + const abilityName = $dropdown.data('abilityName'); + const $selectbox = $dropdown.closest('.selectbox'); + const $block = $selectbox.closest('.block'); + const $form = $dropdown.closest('form, .js-issuable-update'); + const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); + const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); + const $value = $block.find('.value'); + const $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); + const $loading = $block.find('.block-loading').fadeOut(); + const fieldName = $dropdown.data('fieldName'); + let initialSelected = $selectbox .find(`input[name="${$dropdown.data('fieldName')}"]`) .map(function() { return this.value; @@ -90,9 +66,8 @@ export default class LabelsSelect { ); } - saveLabelData = function() { - var data, selected; - selected = $dropdown + const saveLabelData = function() { + const selected = $dropdown .closest('.selectbox') .find(`input[name='${fieldName}']`) .map(function() { @@ -103,7 +78,7 @@ export default class LabelsSelect { if (_.isEqual(initialSelected, selected)) return; initialSelected = selected; - data = {}; + const data = {}; data[abilityName] = {}; data[abilityName].label_ids = selected; if (!selected.length) { @@ -114,12 +89,13 @@ export default class LabelsSelect { axios .put(issueUpdateURL, data) .then(({ data }) => { - var labelCount, template, labelTooltipTitle, labelTitles; + let labelTooltipTitle; + let template; $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); data.issueUpdateURL = issueUpdateURL; - labelCount = 0; + let labelCount = 0; if (data.labels.length && issueUpdateURL) { template = LabelsSelect.getLabelTemplate({ labels: _.sortBy(data.labels, 'title'), @@ -174,7 +150,7 @@ export default class LabelsSelect { $sidebarCollapsedValue.text(labelCount); if (data.labels.length) { - labelTitles = data.labels.map(label => label.title); + let labelTitles = data.labels.map(label => label.title); if (labelTitles.length > 5) { labelTitles = labelTitles.slice(0, 5); @@ -199,13 +175,13 @@ export default class LabelsSelect { $dropdown.glDropdown({ showMenuAbove, data(term, callback) { - labelUrl = $dropdown.attr('data-labels'); + const labelUrl = $dropdown.attr('data-labels'); axios .get(labelUrl) .then(res => { let { data } = res; if ($dropdown.hasClass('js-extra-options')) { - var extraData = []; + const extraData = []; if (showNo) { extraData.unshift({ id: 0, @@ -232,22 +208,14 @@ export default class LabelsSelect { .catch(() => flash(__('Error fetching labels.'))); }, renderRow(label) { - var linkEl, - listItemEl, - colorEl, - indeterminate, - removesAll, - selectedClass, - i, - marked, - dropdownValue; - - selectedClass = []; - removesAll = label.id <= 0 || label.id == null; + let colorEl; + + const selectedClass = []; + const removesAll = label.id <= 0 || label.id == null; if ($dropdown.hasClass('js-filter-bulk-update')) { - indeterminate = $dropdown.data('indeterminate') || []; - marked = $dropdown.data('marked') || []; + const indeterminate = $dropdown.data('indeterminate') || []; + const marked = $dropdown.data('marked') || []; if (indeterminate.indexOf(label.id) !== -1) { selectedClass.push('is-indeterminate'); @@ -255,7 +223,7 @@ export default class LabelsSelect { if (marked.indexOf(label.id) !== -1) { // Remove is-indeterminate class if the item will be marked as active - i = selectedClass.indexOf('is-indeterminate'); + const i = selectedClass.indexOf('is-indeterminate'); if (i !== -1) { selectedClass.splice(i, 1); } @@ -263,7 +231,7 @@ export default class LabelsSelect { } } else { if (this.id(label)) { - dropdownValue = this.id(label) + const dropdownValue = this.id(label) .toString() .replace(/'/g, "\\'"); @@ -287,7 +255,7 @@ export default class LabelsSelect { colorEl = ''; } - linkEl = document.createElement('a'); + const linkEl = document.createElement('a'); linkEl.href = '#'; // We need to identify which items are actually labels @@ -300,7 +268,7 @@ export default class LabelsSelect { linkEl.className = selectedClass.join(' '); linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; - listItemEl = document.createElement('li'); + const listItemEl = document.createElement('li'); listItemEl.appendChild(linkEl); return listItemEl; @@ -312,12 +280,12 @@ export default class LabelsSelect { filterable: true, selected: $dropdown.data('selected') || [], toggleLabel(selected, el) { - var $dropdownParent = $dropdown.parent(); - var $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); - var isSelected = el !== null ? el.hasClass('is-active') : false; + const $dropdownParent = $dropdown.parent(); + const $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); + const isSelected = el !== null ? el.hasClass('is-active') : false; - var title = selected ? selected.title : null; - var selectedLabels = this.selected; + const title = selected ? selected.title : null; + const selectedLabels = this.selected; if ($dropdownInputField.length && $dropdownInputField.val().length) { $dropdownParent.find('.dropdown-input-clear').trigger('click'); @@ -329,7 +297,7 @@ export default class LabelsSelect { } else if (isSelected) { this.selected.push(title); } else if (!isSelected && title) { - var index = this.selected.indexOf(title); + const index = this.selected.indexOf(title); this.selected.splice(index, 1); } @@ -359,10 +327,9 @@ export default class LabelsSelect { } }, hidden() { - var isIssueIndex, isMRIndex, page; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === 'projects:merge_requests:index'; $selectbox.hide(); // display:block overrides the hide-collapse rule $value.removeAttr('style'); @@ -393,14 +360,13 @@ export default class LabelsSelect { const { $el, e, isMarking } = clickEvent; const label = clickEvent.selectedObj; - var isIssueIndex, isMRIndex, page, boardsModel; - var fadeOutLoader = () => { + const fadeOutLoader = () => { $loading.fadeOut(); }; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === 'projects:merge_requests:index'; if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { $dropdown @@ -419,6 +385,7 @@ export default class LabelsSelect { return; } + let boardsModel; if ($dropdown.closest('.add-issues-modal').length) { boardsModel = ModalStore.store.filter; } @@ -450,7 +417,7 @@ export default class LabelsSelect { }), ); } else { - var { labels } = boardsStore.detail.issue; + let { labels } = boardsStore.detail.issue; labels = labels.filter(selectedLabel => selectedLabel.id !== label.id); boardsStore.detail.issue.labels = labels; } @@ -578,16 +545,14 @@ export default class LabelsSelect { } // eslint-disable-next-line class-methods-use-this setDropdownData($dropdown, isMarking, value) { - var i, markedIds, unmarkedIds, indeterminateIds; - - markedIds = $dropdown.data('marked') || []; - unmarkedIds = $dropdown.data('unmarked') || []; - indeterminateIds = $dropdown.data('indeterminate') || []; + const markedIds = $dropdown.data('marked') || []; + const unmarkedIds = $dropdown.data('unmarked') || []; + const indeterminateIds = $dropdown.data('indeterminate') || []; if (isMarking) { markedIds.push(value); - i = indeterminateIds.indexOf(value); + let i = indeterminateIds.indexOf(value); if (i > -1) { indeterminateIds.splice(i, 1); } @@ -598,7 +563,7 @@ export default class LabelsSelect { } } else { // If marked item (not common) is unmarked - i = markedIds.indexOf(value); + const i = markedIds.indexOf(value); if (i > -1) { markedIds.splice(i, 1); } diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index c05db4a5c71..2c5278d16ae 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -26,7 +26,11 @@ export default (resolvers = {}, config = {}) => { createUploadLink(httpOptions), new BatchHttpLink(httpOptions), ), - cache: new InMemoryCache(config.cacheConfig), + cache: new InMemoryCache({ + ...config.cacheConfig, + freezeResults: config.assumeImmutableResults, + }), resolvers, + assumeImmutableResults: config.assumeImmutableResults, }); }; diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js index 0f78756aac8..4a1e6c5d68c 100644 --- a/app/assets/javascripts/lib/utils/chart_utils.js +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -81,3 +81,20 @@ export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize } }, }, }); + +/** + * Takes a dataset and returns an array containing the y-values of it's first and last entry. + * (e.g., [['xValue1', 'yValue1'], ['xValue2', 'yValue2'], ['xValue3', 'yValue3']] will yield ['yValue1', 'yValue3']) + * + * @param {Array} data + * @returns {[*, *]} + */ +export const firstAndLastY = data => { + const [firstEntry] = data; + const [lastEntry] = data.slice(-1); + + const firstY = firstEntry[1]; + const lastY = lastEntry[1]; + + return [firstY, lastY]; +}; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 37b0215f6f9..28143859e4c 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -78,11 +78,11 @@ export const getDayName = date => * @param {date} datetime * @returns {String} */ -export const formatDate = datetime => { +export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => { if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { throw new Error(__('Invalid date')); } - return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); + return dateFormat(datetime, format); }; /** @@ -541,7 +541,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => { * The result cannot become negative. * * @param endDate date string that the time difference is calculated for - * @return {number} number of milliseconds remaining until the given date + * @return {Number} number of milliseconds remaining until the given date */ export const calculateRemainingMilliseconds = endDate => { const remainingMilliseconds = new Date(endDate).getTime() - Date.now(); @@ -552,15 +552,53 @@ export const calculateRemainingMilliseconds = endDate => { * Subtracts a given number of days from a given date and returns the new date. * * @param {Date} date the date that we will substract days from - * @param {number} daysInPast number of days that are subtracted from a given date - * @returns {String} Date string in ISO format + * @param {Number} daysInPast number of days that are subtracted from a given date + * @returns {Date} Date in past as Date object */ -export const getDateInPast = (date, daysInPast) => { - const dateClone = newDate(date); - return new Date( - dateClone.setTime(dateClone.getTime() - daysInPast * 24 * 60 * 60 * 1000), - ).toISOString(); +export const getDateInPast = (date, daysInPast) => + new Date(newDate(date).setDate(date.getDate() - daysInPast)); + +/* + * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date + * to match the user's time zone. We want to display the date in server time for now, to + * be consistent with the "edit issue -> due date" UI. + */ + +export const newDateAsLocaleTime = date => { + const suffix = 'T00:00:00'; + return new Date(`${date}${suffix}`); }; export const beginOfDayTime = 'T00:00:00Z'; export const endOfDayTime = 'T23:59:59Z'; + +/** + * @param {Date} d1 + * @param {Date} d2 + * @param {Function} formatter + * @return {Any[]} an array of formatted dates between 2 given dates (including start&end date) + */ +export const getDatesInRange = (d1, d2, formatter = x => x) => { + if (!(d1 instanceof Date) || !(d2 instanceof Date)) { + return []; + } + let startDate = d1.getTime(); + const endDate = d2.getTime(); + const oneDay = 24 * 3600 * 1000; + const range = [d1]; + + while (startDate < endDate) { + startDate += oneDay; + range.push(new Date(startDate)); + } + + return range.map(formatter); +}; + +/** + * Converts the supplied number of seconds to milliseconds. + * + * @param {Number} seconds + * @return {Number} number of milliseconds + */ +export const secondsToMilliseconds = seconds => seconds * 1000; diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index cd509a13193..8db08099b3f 100644 --- a/app/assets/javascripts/lib/utils/notify.js +++ b/app/assets/javascripts/lib/utils/notify.js @@ -1,8 +1,7 @@ -/* eslint-disable no-var, consistent-return, no-return-assign */ +/* eslint-disable consistent-return, no-return-assign */ function notificationGranted(message, opts, onclick) { - var notification; - notification = new Notification(message, opts); + const notification = new Notification(message, opts); setTimeout( () => // Hide the notification after X amount of seconds @@ -21,8 +20,7 @@ function notifyPermissions() { } function notifyMe(message, body, icon, onclick) { - var opts; - opts = { + const opts = { body, icon, }; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 0f2cc57b1f9..bc87232f40b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -117,3 +117,36 @@ export const median = arr => { const sorted = arr.sort((a, b) => a - b); return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; }; + +/** + * Computes the change from one value to the other as a percentage. + * @param {Number} firstY + * @param {Number} lastY + * @returns {Number} + */ +export const changeInPercent = (firstY, lastY) => { + if (firstY === lastY) { + return 0; + } + + return Math.round(((lastY - firstY) / Math.abs(firstY)) * 100); +}; + +/** + * Computes and formats the change from one value to the other as a percentage. + * Prepends the computed percentage with either "+" or "-" to indicate an in- or decrease and + * returns a given string if the result is not finite (for example, if the first value is "0"). + * @param firstY + * @param lastY + * @param nonFiniteResult + * @returns {String} + */ +export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-' } = {}) => { + const change = changeInPercent(firstY, lastY); + + if (!Number.isFinite(change)) { + return nonFiniteResult; + } + + return `${change >= 0 ? '+' : ''}${change}%`; +}; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index d13fbeb5fc7..0c194d67bce 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -36,25 +36,26 @@ export const humanize = string => export const dasherize = str => str.replace(/[_\s]+/g, '-'); /** - * Replaces whitespaces with hyphens, convert to lower case and remove non-allowed special characters - * @param {String} str + * Replaces whitespace and non-sluggish characters with a given separator + * @param {String} str - The string to slugify + * @param {String=} separator - The separator used to separate words (defaults to "-") * @returns {String} */ -export const slugify = str => { +export const slugify = (str, separator = '-') => { const slug = str .trim() .toLowerCase() - .replace(/[^a-zA-Z0-9_.-]+/g, '-'); + .replace(/[^a-zA-Z0-9_.-]+/g, separator); - return slug === '-' ? '' : slug; + return slug === separator ? '' : slug; }; /** - * Replaces whitespaces with underscore and converts to lower case + * Replaces whitespace and non-sluggish characters with underscores * @param {String} str * @returns {String} */ -export const slugifyWithUnderscore = str => str.toLowerCase().replace(/\s+/g, '_'); +export const slugifyWithUnderscore = str => slugify(str, '_'); /** * Truncates given text @@ -139,6 +140,14 @@ export const stripHtml = (string, replace = '') => { export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); /** + * Converts camelCase string to snake_case + * + * @param {*} string + */ +export const convertToSnakeCase = string => + slugifyWithUnderscore(string.match(/([a-zA-Z][^A-Z]*)/g).join(' ')); + +/** * Converts a sentence to lower case from the second word onwards * e.g. Hello World => Hello world * diff --git a/app/assets/javascripts/lib/utils/tick_formats.js b/app/assets/javascripts/lib/utils/tick_formats.js deleted file mode 100644 index af3ca714400..00000000000 --- a/app/assets/javascripts/lib/utils/tick_formats.js +++ /dev/null @@ -1,39 +0,0 @@ -import { createDateTimeFormat } from '../../locale'; - -let dateTimeFormats; - -export const initDateFormats = () => { - const dayFormat = createDateTimeFormat({ month: 'short', day: 'numeric' }); - const monthFormat = createDateTimeFormat({ month: 'long' }); - const yearFormat = createDateTimeFormat({ year: 'numeric' }); - - dateTimeFormats = { - dayFormat, - monthFormat, - yearFormat, - }; -}; - -initDateFormats(); - -/** - Formats a localized date in way that it can be used for d3.js axis.tickFormat(). - - That is, it displays - - 4-digit for first of January - - full month name for first of every month - - day and abbreviated month otherwise - - see also https://github.com/d3/d3-3.x-api-reference/blob/master/SVG-Axes.md#tickFormat - */ -export const dateTickFormat = date => { - if (date.getDate() !== 1) { - return dateTimeFormats.dayFormat.format(date); - } - - if (date.getMonth() > 0) { - return dateTimeFormats.monthFormat.format(date); - } - - return dateTimeFormats.yearFormat.format(date); -}; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index b6b96fe7bd5..dd868bb9f4c 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, consistent-return, one-var, no-else-return */ +/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return, no-else-return */ import $ from 'jquery'; @@ -82,13 +82,13 @@ LineHighlighter.prototype.highlightHash = function(newHash) { }; LineHighlighter.prototype.clickHandler = function(event) { - var current, lineNumber, range; + let range; event.preventDefault(); this.clearHighlight(); - lineNumber = $(event.target) + const lineNumber = $(event.target) .closest('a') .data('lineNumber'); - current = this.hashToRange(this._hash); + const current = this.hashToRange(this._hash); if (!(current[0] && event.shiftKey)) { // If there's no current selection, or there is but Shift wasn't held, // treat this like a single-line selection. @@ -121,12 +121,11 @@ LineHighlighter.prototype.clearHighlight = function() { // // Returns an Array LineHighlighter.prototype.hashToRange = function(hash) { - var first, last, matches; // ?L(\d+)(?:-(\d+))?$/) - matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/); + const matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/); if (matches && matches.length) { - first = parseInt(matches[1], 10); - last = matches[2] ? parseInt(matches[2], 10) : null; + const first = parseInt(matches[1], 10); + const last = matches[2] ? parseInt(matches[2], 10) : null; return [first, last]; } else { return [null, null]; @@ -160,7 +159,7 @@ LineHighlighter.prototype.highlightRange = function(range) { // Set the URL hash string LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) { - var hash; + let hash; if (lastLineNumber) { hash = `#L${firstLineNumber}-${lastLineNumber}`; } else { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c19a845eb69..465c9a362ba 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -37,7 +37,6 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import { initUserTracking } from './tracking'; import { __ } from './locale'; -import initPrivacyPolicyUpdateCallout from './privacy_policy_update_callout'; import 'ee_else_ce/main_ee'; @@ -97,7 +96,6 @@ function deferredInitialisation() { initUsagePingConsent(); initUserPopovers(); initUserTracking(); - initPrivacyPolicyUpdateCallout(); if (document.querySelector('.search')) initSearchAutocomplete(); @@ -162,24 +160,6 @@ function deferredInitialisation() { }); loadAwardsHandler(); - - /** - * Toggle Canary Badge - * - * For GitLab.com only, when the user is using canary - * we render a Next badge and hide the option to switch - * to canay - */ - if (Cookies.get('gitlab_canary') && Cookies.get('gitlab_canary') === 'true') { - const canaryBadge = document.querySelector('.js-canary-badge'); - const canaryLink = document.querySelector('.js-canary-link'); - if (canaryBadge) { - canaryBadge.classList.remove('hidden'); - } - if (canaryLink) { - canaryLink.classList.add('hidden'); - } - } } document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js index 29a0e5a904a..f93dbcd4c47 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/manual_ordering.js @@ -18,7 +18,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) => createFlash(s__("ManualOrdering|Couldn't save the order of the issues")); }); -const initManualOrdering = () => { +const initManualOrdering = (draggableSelector = 'li.issue') => { const issueList = document.querySelector('.manual-ordering'); if (!issueList || !(gon.current_user_id > 0)) { @@ -34,14 +34,14 @@ const initManualOrdering = () => { group: { name: 'issues', }, - draggable: 'li.issue', + draggable: draggableSelector, onStart: () => { sortableStart(); }, onUpdate: event => { const el = event.item; - const url = el.getAttribute('url'); + const url = el.getAttribute('url') || el.dataset.url; const prev = el.previousElementSibling; const next = el.nextElementSibling; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 7223b5c0d43..3a7ade5ad94 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return */ +/* eslint-disable func-names, no-underscore-dangle, consistent-return */ import $ from 'jquery'; import { __ } from '~/locale'; @@ -17,14 +17,7 @@ function MergeRequest(opts) { this.opts = opts != null ? opts : {}; this.submitNoteForm = this.submitNoteForm.bind(this); this.$el = $('.merge-request'); - this.$('.show-all-commits').on( - 'click', - (function(_this) { - return function() { - return _this.showAllCommits(); - }; - })(this), - ); + this.$('.show-all-commits').on('click', () => this.showAllCommits()); this.initTabs(); this.initMRBtnListeners(); @@ -71,12 +64,10 @@ MergeRequest.prototype.showAllCommits = function() { }; MergeRequest.prototype.initMRBtnListeners = function() { - var _this; - _this = this; + const _this = this; return $('a.btn-close, a.btn-reopen').on('click', function(e) { - var $this, shouldSubmit; - $this = $(this); - shouldSubmit = $this.hasClass('btn-comment'); + const $this = $(this); + const shouldSubmit = $this.hasClass('btn-comment'); if (shouldSubmit && $this.data('submitted')) { return; } @@ -95,8 +86,7 @@ MergeRequest.prototype.initMRBtnListeners = function() { }; MergeRequest.prototype.submitNoteForm = function(form, $button) { - var noteText; - noteText = form.find('textarea.js-note-text').val(); + const noteText = form.find('textarea.js-note-text').val(); if (noteText.trim().length > 0) { form.submit(); $button.data('submitted', true); @@ -106,7 +96,7 @@ MergeRequest.prototype.submitNoteForm = function(form, $button) { MergeRequest.prototype.initCommitMessageListeners = function() { $(document).on('click', 'a.js-with-description-link', e => { - var textarea = $('textarea.js-commit-message'); + const textarea = $('textarea.js-commit-message'); e.preventDefault(); textarea.val(textarea.data('messageWithDescription')); @@ -115,7 +105,7 @@ MergeRequest.prototype.initCommitMessageListeners = function() { }); $(document).on('click', 'a.js-without-description-link', e => { - var textarea = $('textarea.js-commit-message'); + const textarea = $('textarea.js-commit-message'); e.preventDefault(); textarea.val(textarea.data('messageWithoutDescription')); diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue new file mode 100644 index 00000000000..8eeac737a11 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -0,0 +1,227 @@ +<script> +import { flatten, isNumber } from 'underscore'; +import { GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; +import { roundOffFloat } from '~/lib/utils/common_utils'; +import { hexToRgb } from '~/lib/utils/color_utils'; +import { areaOpacityValues, symbolSizes, colorValues } from '../../constants'; +import { graphDataValidatorForAnomalyValues } from '../../utils'; +import MonitorTimeSeriesChart from './time_series.vue'; + +/** + * Series indexes + */ +const METRIC = 0; +const UPPER = 1; +const LOWER = 2; + +/** + * Boundary area appearance + */ +const AREA_COLOR = colorValues.anomalyAreaColor; +const AREA_OPACITY = areaOpacityValues.default; +const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})`; + +/** + * The anomaly component highlights when a metric shows + * some anomalous behavior. + * + * It shows both a metric line and a boundary band in a + * time series chart, the boundary band shows the normal + * range of values the metric should take. + * + * This component accepts 3 queries, which contain the + * "metric", "upper" limit and "lower" limit. + * + * The upper and lower series are "stacked areas" visually + * to create the boundary band, and if any "metric" value + * is outside this band, it is highlighted to warn users. + * + * The boundary band stack must be painted above the 0 line + * so the area is shown correctly. If any of the values of + * the data are negative, the chart data is shifted to be + * above 0 line. + * + * The data passed to the time series is will always be + * positive, but reformatted to show the original values of + * data. + * + */ +export default { + components: { + GlLineChart, + GlChartSeriesLabel, + MonitorTimeSeriesChart, + }, + inheritAttrs: false, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForAnomalyValues, + }, + }, + computed: { + series() { + return this.graphData.queries.map(query => { + const values = query.result[0] ? query.result[0].values : []; + return { + label: query.label, + data: values.filter(([, value]) => !Number.isNaN(value)), + }; + }); + }, + /** + * If any of the values of the data is negative, the + * chart data is shifted to the lowest value + * + * This offset is the lowest value. + */ + yOffset() { + const values = flatten(this.series.map(ser => ser.data.map(([, y]) => y))); + const min = values.length ? Math.floor(Math.min(...values)) : 0; + return min < 0 ? -min : 0; + }, + metricData() { + const originalMetricQuery = this.graphData.queries[0]; + + const metricQuery = { ...originalMetricQuery }; + metricQuery.result[0].values = metricQuery.result[0].values.map(([x, y]) => [ + x, + y + this.yOffset, + ]); + return { + ...this.graphData, + type: 'line-chart', + queries: [metricQuery], + }; + }, + metricSeriesConfig() { + return { + type: 'line', + symbol: 'circle', + symbolSize: (val, params) => { + if (this.isDatapointAnomaly(params.dataIndex)) { + return symbolSizes.anomaly; + } + // 0 causes echarts to throw an error, use small number instead + // see https://gitlab.com/gitlab-org/gitlab-ui/issues/423 + return 0.001; + }, + showSymbol: true, + itemStyle: { + color: params => { + if (this.isDatapointAnomaly(params.dataIndex)) { + return colorValues.anomalySymbol; + } + return colorValues.primaryColor; + }, + }, + }; + }, + chartOptions() { + const [, upperSeries, lowerSeries] = this.series; + const calcOffsetY = (data, offsetCallback) => + data.map((value, dataIndex) => { + const [x, y] = value; + return [x, y + offsetCallback(dataIndex)]; + }); + + const yAxisWithOffset = { + name: this.yAxisLabel, + axisLabel: { + formatter: num => roundOffFloat(num - this.yOffset, 3).toString(), + }, + }; + + /** + * Boundary is rendered by 2 series: An invisible + * series (opacity: 0) stacked on a visible one. + * + * Order is important, lower boundary is stacked + * *below* the upper boundary. + */ + const boundarySeries = []; + + if (upperSeries.data.length && lowerSeries.data.length) { + // Lower boundary, plus the offset if negative values + boundarySeries.push( + this.makeBoundarySeries({ + name: this.formatLegendLabel(lowerSeries), + data: calcOffsetY(lowerSeries.data, () => this.yOffset), + }), + ); + // Upper boundary, minus the lower boundary + boundarySeries.push( + this.makeBoundarySeries({ + name: this.formatLegendLabel(upperSeries), + data: calcOffsetY(upperSeries.data, i => -this.yValue(LOWER, i)), + areaStyle: { + color: AREA_COLOR, + opacity: AREA_OPACITY, + }, + }), + ); + } + return { yAxis: yAxisWithOffset, series: boundarySeries }; + }, + }, + methods: { + formatLegendLabel(query) { + return query.label; + }, + yValue(seriesIndex, dataIndex) { + const d = this.series[seriesIndex].data[dataIndex]; + return d && d[1]; + }, + yValueFormatted(seriesIndex, dataIndex) { + const y = this.yValue(seriesIndex, dataIndex); + return isNumber(y) ? y.toFixed(3) : ''; + }, + isDatapointAnomaly(dataIndex) { + const yVal = this.yValue(METRIC, dataIndex); + const yUpper = this.yValue(UPPER, dataIndex); + const yLower = this.yValue(LOWER, dataIndex); + return (isNumber(yUpper) && yVal > yUpper) || (isNumber(yLower) && yVal < yLower); + }, + makeBoundarySeries(series) { + const stackKey = 'anomaly-boundary-series-stack'; + return { + type: 'line', + stack: stackKey, + lineStyle: { + width: 0, + color: AREA_COLOR_RGBA, // legend color + }, + color: AREA_COLOR_RGBA, // tooltip color + symbol: 'none', + ...series, + }; + }, + }, +}; +</script> + +<template> + <monitor-time-series-chart + v-bind="$attrs" + :graph-data="metricData" + :option="chartOptions" + :series-config="metricSeriesConfig" + > + <slot></slot> + <template v-slot:tooltipContent="slotProps"> + <div + v-for="(content, seriesIndex) in slotProps.tooltip.content" + :key="seriesIndex" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="content.color"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ yValueFormatted(seriesIndex, content.dataIndex) }} + </div> + </div> + </template> + </monitor-time-series-chart> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue new file mode 100644 index 00000000000..b8158247e49 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -0,0 +1,73 @@ +<script> +import { GlHeatmap } from '@gitlab/ui/dist/charts'; +import dateformat from 'dateformat'; +import PrometheusHeader from '../shared/prometheus_header.vue'; +import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; +import { graphDataValidatorForValues } from '../../utils'; + +export default { + components: { + GlHeatmap, + ResizableChartContainer, + PrometheusHeader, + }, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForValues.bind(null, false), + }, + containerWidth: { + type: Number, + required: true, + }, + }, + computed: { + chartData() { + return this.queries.result.reduce( + (acc, result, i) => [...acc, ...result.values.map((value, j) => [i, j, value[1]])], + [], + ); + }, + xAxisName() { + return this.graphData.x_label || ''; + }, + yAxisName() { + return this.graphData.y_label || ''; + }, + xAxisLabels() { + return this.queries.result.map(res => Object.values(res.metric)[0]); + }, + yAxisLabels() { + return this.result.values.map(val => { + const [yLabel] = val; + + return dateformat(new Date(yLabel), 'HH:MM:ss'); + }); + }, + result() { + return this.queries.result[0]; + }, + queries() { + return this.graphData.queries[0]; + }, + }, +}; +</script> +<template> + <div class="prometheus-graph col-12 col-lg-6"> + <prometheus-header :graph-title="graphData.title" /> + <resizable-chart-container> + <gl-heatmap + ref="heatmapChart" + v-bind="$attrs" + :data-series="chartData" + :x-axis-name="xAxisName" + :y-axis-name="yAxisName" + :x-axis-labels="xAxisLabels" + :y-axis-labels="yAxisLabels" + :width="containerWidth" + /> + </resizable-chart-container> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 78fe575717a..6a88c8a5ee3 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,17 +1,23 @@ <script> import { s__, __ } from '~/locale'; -import { GlLink, GlButton, GlTooltip } from '@gitlab/ui'; +import _ from 'underscore'; +import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; -import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils'; +import { roundOffFloat } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants'; +import { + chartHeight, + graphTypes, + lineTypes, + lineWidths, + symbolSizes, + dateFormats, +} from '../../constants'; import { makeDataSeries } from '~/helpers/monitor_helper'; import { graphDataValidatorForValues } from '../../utils'; -let debouncedResize; - export default { components: { GlAreaChart, @@ -22,6 +28,9 @@ export default { GlLink, Icon, }, + directives: { + GlResizeObserverDirective, + }, inheritAttrs: false, props: { graphData: { @@ -29,9 +38,15 @@ export default { required: true, validator: graphDataValidatorForValues.bind(null, false), }, - containerWidth: { - type: Number, - required: true, + option: { + type: Object, + required: false, + default: () => ({}), + }, + seriesConfig: { + type: Object, + required: false, + default: () => ({}), }, deploymentData: { type: Array, @@ -99,29 +114,35 @@ export default { const lineWidth = appearance && appearance.line && appearance.line.width ? appearance.line.width - : undefined; + : lineWidths.default; const areaStyle = { opacity: appearance && appearance.area && typeof appearance.area.opacity === 'number' ? appearance.area.opacity : undefined, }; - const series = makeDataSeries(query.result, { name: this.formatLegendLabel(query), lineStyle: { type: lineType, width: lineWidth, + color: this.primaryColor, }, showSymbol: false, areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined, + ...this.seriesConfig, }); return acc.concat(series); }, []); }, + chartOptionSeries() { + return (this.option.series || []).concat(this.scatterSeries ? [this.scatterSeries] : []); + }, chartOptions() { + const option = _.omit(this.option, 'series'); return { + series: this.chartOptionSeries, xAxis: { name: __('Time'), type: 'time', @@ -138,8 +159,8 @@ export default { formatter: num => roundOffFloat(num, 3).toString(), }, }, - series: this.scatterSeries, dataZoom: [this.dataZoomConfig], + ...option, }; }, dataZoomConfig() { @@ -147,6 +168,14 @@ export default { return handleIcon ? { handleIcon } : {}; }, + /** + * This method returns the earliest time value in all series of a chart. + * Takes a chart data with data to populate a timeseries. + * data should be an array of data points [t, y] where t is a ISO formatted date, + * and is sorted by t (time). + * @returns {(String|null)} earliest x value from all series, or null when the + * chart series data is empty. + */ earliestDatapoint() { return this.chartData.reduce((acc, series) => { const { data } = series; @@ -206,21 +235,13 @@ export default { return `${this.graphData.y_label}`; }, }, - watch: { - containerWidth: 'onResize', - }, mounted() { const graphTitleEl = this.$refs.graphTitle; if (graphTitleEl && graphTitleEl.scrollWidth > graphTitleEl.offsetWidth) { this.showTitleTooltip = true; } }, - beforeDestroy() { - window.removeEventListener('resize', debouncedResize); - }, created() { - debouncedResize = debounceByAnimationFrame(this.onResize); - window.addEventListener('resize', debouncedResize); this.setSvg('rocket'); this.setSvg('scroll-handle'); }, @@ -241,10 +262,11 @@ export default { this.tooltip.sha = deploy.sha.substring(0, 8); this.tooltip.commitUrl = deploy.commitUrl; } else { - const { seriesName, color } = dataPoint; + const { seriesName, color, dataIndex } = dataPoint; const value = yVal.toFixed(3); this.tooltip.content.push({ name: seriesName, + dataIndex, value, color, }); @@ -276,7 +298,7 @@ export default { </script> <template> - <div class="prometheus-graph"> + <div v-gl-resize-observer-directive="onResize" class="prometheus-graph"> <div class="prometheus-graph-header"> <h5 ref="graphTitle" @@ -317,23 +339,27 @@ export default { </template> <template v-else> <template slot="tooltipTitle"> - <div class="text-nowrap"> - {{ tooltip.title }} - </div> + <slot name="tooltipTitle"> + <div class="text-nowrap"> + {{ tooltip.title }} + </div> + </slot> </template> <template slot="tooltipContent"> - <div - v-for="(content, key) in tooltip.content" - :key="key" - class="d-flex justify-content-between" - > - <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> - {{ content.name }} - </gl-chart-series-label> - <div class="prepend-left-32"> - {{ content.value }} + <slot name="tooltipContent" :tooltip="tooltip"> + <div + v-for="(content, key) in tooltip.content" + :key="key" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ content.value }} + </div> </div> - </div> + </slot> </template> </template> </component> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index b4ea415bb51..26e2c2568c1 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -11,7 +11,7 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; @@ -22,12 +22,9 @@ import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; -import { sidebarAnimationDuration } from '../constants'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; -let sidebarMutationObserver; - export default { components: { VueDraggable, @@ -167,10 +164,10 @@ export default { data() { return { state: 'gettingStarted', - elWidth: 0, formIsValid: null, selectedTimeWindow: {}, isRearrangingPanels: false, + hasValidDates: true, }; }, computed: { @@ -178,7 +175,7 @@ export default { return this.customMetricsAvailable && this.customMetricsPath.length; }, ...mapState('monitoringDashboard', [ - 'groups', + 'dashboard', 'emptyState', 'showEmptyState', 'environments', @@ -189,10 +186,15 @@ export default { 'additionalPanelTypesEnabled', ]), firstDashboard() { - return this.allDashboards[0] || {}; + return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0 + ? this.allDashboards[0] + : {}; + }, + selectedDashboard() { + return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; }, selectedDashboardText() { - return this.currentDashboard || this.firstDashboard.display_name; + return this.selectedDashboard.display_name; }, showRearrangePanelsBtn() { return !this.showEmptyState && this.rearrangePanelsAvailable; @@ -200,8 +202,13 @@ export default { addingMetricsAvailable() { return IS_EE && this.canAddMetrics && !this.showEmptyState; }, - alertWidgetAvailable() { - return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint; + hasHeaderButtons() { + return ( + this.addingMetricsAvailable || + this.showRearrangePanelsBtn || + this.selectedDashboard.can_edit || + this.externalDashboardUrl.length + ); }, }, created() { @@ -214,11 +221,6 @@ export default { projectPath: this.projectPath, }); }, - beforeDestroy() { - if (sidebarMutationObserver) { - sidebarMutationObserver.disconnect(); - } - }, mounted() { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); @@ -235,17 +237,12 @@ export default { this.selectedTimeWindow = range; if (!isValidDate(start) || !isValidDate(end)) { + this.hasValidDates = false; this.showInvalidDateError(); } else { + this.hasValidDates = true; this.fetchData(range); } - - sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); - sidebarMutationObserver.observe(document.querySelector('.layout-page'), { - attributes: true, - childList: false, - subtree: false, - }); } }, methods: { @@ -253,43 +250,25 @@ export default { 'fetchData', 'setGettingStartedEmptyState', 'setEndpoints', - 'setDashboardEnabled', + 'setPanelGroupMetrics', ]), chartsWithData(charts) { - if (!this.useDashboardEndpoint) { - return charts; - } return charts.filter(chart => chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)), ); }, - csvText(graphData) { - const chartData = graphData.queries[0].result[0].values; - const yLabel = graphData.y_label; - const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings - return chartData.reduce((csv, data) => { - const row = data.join(','); - return `${csv}${row}\r\n`; - }, header); - }, - downloadCsv(graphData) { - const data = new Blob([this.csvText(graphData)], { type: 'text/plain' }); - return window.URL.createObjectURL(data); - }, - // TODO: BEGIN, Duplicated code with panel_type until feature flag is removed - // Issue number: https://gitlab.com/gitlab-org/gitlab-foss/issues/63845 - 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)); - }, - showToast() { - this.$toast.show(__('Link copied')); - }, - // TODO: END + updateMetrics(key, metrics) { + this.setPanelGroupMetrics({ + metrics, + key, + }); + }, + removeMetric(key, metrics, graphIndex) { + this.setPanelGroupMetrics({ + metrics: metrics.filter((v, i) => i !== graphIndex), + key, + }); + }, removeGraph(metrics, graphIndex) { // At present graphs will not be removed, they should removed using the vuex store // See https://gitlab.com/gitlab-org/gitlab/issues/27835 @@ -306,11 +285,6 @@ export default { hideAddMetricModal() { this.$refs.addMetricModal.hide(); }, - onSidebarMutation() { - setTimeout(() => { - this.elWidth = this.$el.clientWidth; - }, sidebarAnimationDuration); - }, toggleRearrangingPanels() { this.isRearrangingPanels = !this.isRearrangingPanels; }, @@ -389,7 +363,7 @@ export default { </gl-form-group> <gl-form-group - v-if="!showEmptyState" + v-if="hasValidDates" :label="s__('Metrics|Show last')" label-size="sm" label-for="monitor-time-window-dropdown" @@ -403,7 +377,7 @@ export default { </template> <gl-form-group - v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length" + v-if="hasHeaderButtons" label-for="prometheus-graphs-dropdown-buttons" class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end" > @@ -451,6 +425,14 @@ export default { </gl-modal> <gl-button + v-if="selectedDashboard.can_edit" + class="mt-1 js-edit-link" + :href="selectedDashboard.project_blob_path" + > + {{ __('Edit dashboard') }} + </gl-button> + + <gl-button v-if="externalDashboardUrl.length" class="mt-1 js-external-dashboard-link" variant="primary" @@ -468,116 +450,46 @@ export default { <div v-if="!showEmptyState"> <graph-group - v-for="(groupData, index) in groups" + v-for="(groupData, index) in dashboard.panel_groups" :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" :collapse-group="groupHasData(groupData)" > - <template v-if="additionalPanelTypesEnabled"> - <vue-draggable - :list="groupData.metrics" - group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" - :disabled="!isRearrangingPanels" + <vue-draggable + :value="groupData.metrics" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + @input="updateMetrics(groupData.key, $event)" + > + <div + v-for="(graphData, graphIndex) in groupData.metrics" + :key="`panel-type-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" > - <div - v-for="(graphData, graphIndex) in groupData.metrics" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" - > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removeGraph(groupData.metrics, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" - ><icon name="close" - /></a> - </div> - - <panel-type - :clipboard-text=" - generateLink(groupData.group, graphData.title, graphData.y_label) - " - :graph-data="graphData" - :dashboard-width="elWidth" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - /> + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removeGraph(groupData.metrics, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" + ><icon name="close" + /></a> </div> - </div> - </vue-draggable> - </template> - <template v-else> - <monitor-time-series-chart - v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" - :key="graphIndex" - class="col-12 col-lg-6 pb-3" - :graph-data="graphData" - :deployment-data="deploymentData" - :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="elWidth" - :project-path="projectPath" - group-id="monitor-time-series-chart" - > - <div - class="d-flex align-items-center" - :class="alertWidgetAvailable ? 'justify-content-between' : 'justify-content-end'" - > - <alert-widget - v-if="alertWidgetAvailable && graphData" - :modal-id="`alert-modal-${index}-${graphIndex}`" + + <panel-type + :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" + :graph-data="graphData" :alerts-endpoint="alertsEndpoint" - :relevant-queries="graphData.queries" - :alerts-to-manage="getGraphAlerts(graphData.queries)" - @setAlerts="setAlerts" + :prometheus-alerts-available="prometheusAlertsAvailable" + :index="`${index}-${graphIndex}`" /> - <gl-dropdown - v-gl-tooltip - class="ml-2 mr-3" - toggle-class="btn btn-transparent border-0" - :right="true" - :no-caret="true" - :title="__('More actions')" - > - <template slot="button-content"> - <icon name="ellipsis_v" class="text-secondary" /> - </template> - <gl-dropdown-item - v-track-event="downloadCSVOptions(graphData.title)" - :href="downloadCsv(graphData)" - download="chart_metrics.csv" - > - {{ __('Download CSV') }} - </gl-dropdown-item> - <gl-dropdown-item - v-track-event=" - generateLinkToChartOptions( - generateLink(groupData.group, graphData.title, graphData.y_label), - ) - " - class="js-chart-link" - :data-clipboard-text=" - generateLink(groupData.group, graphData.title, graphData.y_label) - " - @click="showToast" - > - {{ __('Generate link to chart') }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="alertWidgetAvailable" - v-gl-modal="`alert-modal-${index}-${graphIndex}`" - > - {{ __('Alerts') }} - </gl-dropdown-item> - </gl-dropdown> </div> - </monitor-time-series-chart> - </template> + </div> + </vue-draggable> </graph-group> </div> <empty-state diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue index 4616a767295..8749019c5cd 100644 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue @@ -55,17 +55,13 @@ export default { }; }, }, + watch: { + selectedTimeWindow() { + this.verifyTimeRange(); + }, + }, mounted() { - const range = getTimeWindow(this.selectedTimeWindow); - if (range) { - this.selectedTimeWindowText = this.timeWindows[range]; - } else { - this.customTime = { - from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), - to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), - }; - this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); - } + this.verifyTimeRange(); }, methods: { activeTimeWindow(key) { @@ -87,6 +83,18 @@ export default { closeDropdown() { this.$refs.dropdown.hide(); }, + verifyTimeRange() { + const range = getTimeWindow(this.selectedTimeWindow); + if (range) { + this.selectedTimeWindowText = this.timeWindows[range]; + } else { + this.customTime = { + from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), + to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), + }; + this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); + } + }, }, }; </script> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index 7857aaa6ecc..f75839c7c6b 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -35,9 +35,9 @@ export default { }; }, computed: { - ...mapState('monitoringDashboard', ['groups', 'metricsWithData']), + ...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']), charts() { - const groupWithMetrics = this.groups.find(group => + const groupWithMetrics = this.dashboard.panel_groups.find(group => group.metrics.find(chart => this.chartHasData(chart)), ) || { metrics: [] }; @@ -78,9 +78,6 @@ export default { }, sidebarAnimationDuration); }, setInitialState() { - this.setFeatureFlags({ - prometheusEndpointEnabled: true, - }); this.setEndpoints({ dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl), }); diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index ee3a2bae79b..3cb6ccb64b1 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -45,7 +45,7 @@ export default { <div v-if="showPanels" class="card prometheus-panel"> <div class="card-header d-flex align-items-center"> <h4 class="flex-grow-1">{{ name }}</h4> - <a role="button" @click="collapse"> + <a role="button" class="js-graph-group-toggle" @click="collapse"> <icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" /> </a> </div> diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 1a14d06f4c8..cafb4b0b479 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -11,7 +11,9 @@ import { } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; +import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; +import MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorEmptyChart from './charts/empty_chart.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; @@ -19,7 +21,7 @@ import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; export default { components: { MonitorSingleStatChart, - MonitorTimeSeriesChart, + MonitorHeatmapChart, MonitorEmptyChart, Icon, GlDropdown, @@ -40,10 +42,6 @@ export default { type: Object, required: true, }, - dashboardWidth: { - type: Number, - required: true, - }, index: { type: String, required: false, @@ -71,6 +69,12 @@ export default { const data = new Blob([this.csvText], { type: 'text/plain' }); return window.URL.createObjectURL(data); }, + monitorChartComponent() { + if (this.isPanelType('anomaly-chart')) { + return MonitorAnomalyChart; + } + return MonitorTimeSeriesChart; + }, }, methods: { getGraphAlerts(queries) { @@ -97,14 +101,19 @@ export default { v-if="isPanelType('single-stat') && graphDataHasMetrics" :graph-data="graphData" /> - <monitor-time-series-chart + <monitor-heatmap-chart + v-else-if="isPanelType('heatmap') && graphDataHasMetrics" + :graph-data="graphData" + :container-width="dashboardWidth" + /> + <component + :is="monitorChartComponent" v-else-if="graphDataHasMetrics" :graph-data="graphData" :deployment-data="deploymentData" :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="dashboardWidth" - group-id="monitor-area-chart" + group-id="panel-type-chart" > <div class="d-flex align-items-center"> <alert-widget @@ -146,6 +155,6 @@ export default { </gl-dropdown-item> </gl-dropdown> </div> - </monitor-time-series-chart> + </component> <monitor-empty-chart v-else :graph-title="graphData.title" /> </template> diff --git a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue new file mode 100644 index 00000000000..153c8f389db --- /dev/null +++ b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue @@ -0,0 +1,15 @@ +<script> +export default { + props: { + graphTitle: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="prometheus-graph-header"> + <h5 class="prometheus-graph-title js-graph-title">{{ graphTitle }}</h5> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 2836fe4fc26..1a1fcdd0e66 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -14,13 +14,28 @@ export const graphTypes = { }; export const symbolSizes = { + anomaly: 8, default: 14, }; +export const areaOpacityValues = { + default: 0.2, +}; + +export const colorValues = { + primaryColor: '#1f78d1', // $blue-500 (see variables.scss) + anomalySymbol: '#db3b21', + anomalyAreaColor: '#1f78d1', +}; + export const lineTypes = { default: 'solid', }; +export const lineWidths = { + default: 2, +}; + export const timeWindows = { thirtyMinutes: __('30 minutes'), threeHours: __('3 hours'), diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 6aa1fb5e9c6..a14145d480b 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -11,13 +11,6 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { - if (gon.features) { - store.dispatch('monitoringDashboard/setFeatureFlags', { - prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, - additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes, - }); - } - const [currentDashboard] = getParameterValues('dashboard'); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 2cf34ddb45b..6a8e3cc82f5 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -7,7 +7,7 @@ import { s__, __ } from '../../locale'; const MAX_REQUESTS = 3; -function backOffRequest(makeRequestCallback) { +export function backOffRequest(makeRequestCallback) { let requestCounter = 0; return backOff((next, stop) => { makeRequestCallback() @@ -35,14 +35,6 @@ export const setEndpoints = ({ commit }, endpoints) => { commit(types.SET_ENDPOINTS, endpoints); }; -export const setFeatureFlags = ( - { commit }, - { prometheusEndpointEnabled, additionalPanelTypesEnabled }, -) => { - commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled); - commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled); -}; - export const setShowErrorBanner = ({ commit }, enabled) => { commit(types.SET_SHOW_ERROR_BANNER, enabled); }; @@ -79,29 +71,7 @@ export const fetchData = ({ dispatch }, params) => { dispatch('fetchEnvironmentsData'); }; -export const fetchMetricsData = ({ state, dispatch }, params) => { - if (state.useDashboardEndpoint) { - return dispatch('fetchDashboard', params); - } - - dispatch('requestMetricsData'); - - return backOffRequest(() => axios.get(state.metricsEndpoint, { params })) - .then(resp => resp.data) - .then(response => { - if (!response || !response.data || !response.success) { - dispatch('receiveMetricsDataFailure', null); - createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); - } - dispatch('receiveMetricsDataSuccess', response.data); - }) - .catch(error => { - dispatch('receiveMetricsDataFailure', error); - if (state.setShowErrorBanner) { - createFlash(s__('Metrics|There was an error while retrieving metrics')); - } - }); -}; +export const fetchMetricsData = ({ dispatch }, params) => dispatch('fetchDashboard', params); export const fetchDashboard = ({ state, dispatch }, params) => { dispatch('requestMetricsDashboard'); @@ -111,11 +81,13 @@ export const fetchDashboard = ({ state, dispatch }, params) => { params.dashboard = state.currentDashboard; } - return axios - .get(state.dashboardEndpoint, { params }) + return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) .then(response => { - dispatch('receiveMetricsDashboardSuccess', { response, params }); + dispatch('receiveMetricsDashboardSuccess', { + response, + params, + }); }) .catch(error => { dispatch('receiveMetricsDashboardFailure', error); @@ -166,7 +138,7 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { commit(types.REQUEST_METRICS_DATA); const promises = []; - state.groups.forEach(group => { + state.dashboard.panel_groups.forEach(group => { group.panels.forEach(panel => { panel.metrics.forEach(metric => { promises.push(dispatch('fetchPrometheusMetric', { metric, params })); @@ -221,5 +193,15 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { }); }; +/** + * Set a new array of metrics to a panel group + * @param {*} data An object containing + * - `key` with a unique panel key + * - `metrics` with the metrics array + */ +export const setPanelGroupMetrics = ({ commit }, data) => { + commit(types.SET_PANEL_GROUP_METRICS, data); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 9c546427c6e..fa15a2ba800 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -9,10 +9,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCC export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; export const SET_QUERY_RESULT = 'SET_QUERY_RESULT'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; -export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED'; -export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; +export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 320b33d3d69..696af5aed75 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,6 +1,7 @@ import Vue from 'vue'; +import { slugify } from '~/lib/utils/text_utility'; import * as types from './mutation_types'; -import { normalizeMetrics, sortMetrics, normalizeMetric, normalizeQueryResult } from './utils'; +import { normalizeMetrics, normalizeMetric, normalizeQueryResult } from './utils'; const normalizePanel = panel => panel.metrics.map(normalizeMetric); @@ -10,10 +11,12 @@ export default { state.showEmptyState = true; }, [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { - state.groups = groupData.map(group => { + state.dashboard.panel_groups = groupData.map((group, i) => { + const key = `${slugify(group.group || 'default')}-${i}`; let { metrics = [], panels = [] } = group; // each panel has metric information that needs to be normalized + panels = panels.map(panel => ({ ...panel, metrics: normalizePanel(panel), @@ -22,24 +25,21 @@ export default { // for backwards compatibility, and to limit Vue template changes: // for each group alias panels to metrics // for each panel alias metrics to queries - if (state.useDashboardEndpoint) { - metrics = panels.map(panel => ({ - ...panel, - queries: panel.metrics, - })); - } + metrics = panels.map(panel => ({ + ...panel, + queries: panel.metrics, + })); return { ...group, panels, - metrics: normalizeMetrics(sortMetrics(metrics)), + key, + metrics: normalizeMetrics(metrics), }; }); - if (!state.groups.length) { + if (!state.dashboard.panel_groups.length) { state.emptyState = 'noData'; - } else { - state.showEmptyState = false; } }, [types.RECEIVE_METRICS_DATA_FAILURE](state, error) { @@ -65,7 +65,7 @@ export default { state.showEmptyState = false; - state.groups.forEach(group => { + state.dashboard.panel_groups.forEach(group => { group.metrics.forEach(metric => { metric.queries.forEach(query => { if (query.metric_id === metricId) { @@ -86,9 +86,6 @@ export default { state.currentDashboard = endpoints.currentDashboard; state.projectPath = endpoints.projectPath; }, - [types.SET_DASHBOARD_ENABLED](state, enabled) { - state.useDashboardEndpoint = enabled; - }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { state.emptyState = 'gettingStarted'; }, @@ -97,12 +94,13 @@ export default { state.emptyState = 'noData'; }, [types.SET_ALL_DASHBOARDS](state, dashboards) { - state.allDashboards = dashboards; - }, - [types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) { - state.additionalPanelTypesEnabled = enabled; + state.allDashboards = dashboards || []; }, [types.SET_SHOW_ERROR_BANNER](state, enabled) { state.showErrorBanner = enabled; }, + [types.SET_PANEL_GROUP_METRICS](state, payload) { + const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key); + panelGroup.metrics = payload.metrics; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index e894e988f6a..87e94311176 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -7,12 +7,12 @@ export default () => ({ environmentsEndpoint: null, deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, - useDashboardEndpoint: false, - additionalPanelTypesEnabled: false, emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, - groups: [], + dashboard: { + panel_groups: [], + }, deploymentData: [], environments: [], metricsWithData: [], diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index a19829f0c65..8a396b15a31 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -82,12 +82,6 @@ export const normalizeMetric = (metric = {}) => 'id', ); -export const sortMetrics = metrics => - _.chain(metrics) - .sortBy('title') - .sortBy('weight') - .value(); - export const normalizeQueryResult = timeSeries => { let normalizedResult = {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 00f188c1d5a..2ae1647011d 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,7 +1,6 @@ import dateformat from 'dateformat'; import { secondsIn, dateTimePickerRegex, dateFormats } from './constants'; - -const secondsToMilliseconds = seconds => seconds * 1000; +import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; export const getTimeDiff = timeWindow => { const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds @@ -131,4 +130,20 @@ export const downloadCSVOptions = title => { return { category, action, label: 'Chart title', property: title }; }; +/** + * This function validates the graph data contains exactly 3 queries plus + * value validations from graphDataValidatorForValues. + * @param {Object} isValues + * @param {Object} graphData the graph data response from a prometheus request + * @returns {boolean} true if the data is valid + */ +export const graphDataValidatorForAnomalyValues = graphData => { + const anomalySeriesCount = 3; // metric, upper, lower + return ( + graphData.queries && + graphData.queries.length === anomalySeriesCount && + graphDataValidatorForValues(false, graphData) + ); +}; + export default {}; diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index a0ba2193d90..c301c304409 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -1,12 +1,12 @@ -/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, camelcase */ +/* eslint-disable func-names, consistent-return, camelcase */ import $ from 'jquery'; import { __ } from '../locale'; import axios from '../lib/utils/axios_utils'; import Raphael from './raphael'; -export default (function() { - function BranchGraph(element1, options1) { +export default class BranchGraph { + constructor(element1, options1) { this.element = element1; this.options = options1; this.scrollTop = this.scrollTop.bind(this); @@ -28,7 +28,7 @@ export default (function() { this.load(); } - BranchGraph.prototype.load = function() { + load() { axios .get(this.options.url) .then(({ data }) => { @@ -37,21 +37,23 @@ export default (function() { this.buildGraph(); }) .catch(() => __('Error fetching network graph.')); - }; + } - BranchGraph.prototype.prepareData = function(days, commits) { - var c, ch, cw, j, len, ref; + prepareData(days, commits) { + let c = 0; + let j = 0; + let len = 0; this.days = days; this.commits = commits; this.collectParents(); this.graphHeight = $(this.element).height(); this.graphWidth = $(this.element).width(); - ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150); - cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300); + const ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150); + const cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300); this.r = Raphael(this.element.get(0), cw, ch); this.top = this.r.set(); this.barHeight = Math.max(this.graphHeight, this.unitTime * this.days.length + 320); - ref = this.commits; + const ref = this.commits; for (j = 0, len = ref.length; j < len; j += 1) { c = ref[j]; if (c.id in this.parents) { @@ -61,37 +63,34 @@ export default (function() { this.markCommit(c); } return this.collectColors(); - }; + } - BranchGraph.prototype.collectParents = function() { - var c, j, len, p, ref, results; - ref = this.commits; - results = []; + collectParents() { + let j = 0; + let l = 0; + let len = 0; + let len1 = 0; + const ref = this.commits; + const results = []; for (j = 0, len = ref.length; j < len; j += 1) { - c = ref[j]; + const c = ref[j]; this.mtime = Math.max(this.mtime, c.time); this.mspace = Math.max(this.mspace, c.space); - results.push( - function() { - var l, len1, ref1, results1; - ref1 = c.parents; - results1 = []; - for (l = 0, len1 = ref1.length; l < len1; l += 1) { - p = ref1[l]; - this.parents[p[0]] = true; - results1.push((this.mspace = Math.max(this.mspace, p[1]))); - } - return results1; - }.call(this), - ); + const ref1 = c.parents; + const results1 = []; + for (l = 0, len1 = ref1.length; l < len1; l += 1) { + const p = ref1[l]; + this.parents[p[0]] = true; + results1.push((this.mspace = Math.max(this.mspace, p[1]))); + } + results.push(results1); } return results; - }; + } - BranchGraph.prototype.collectColors = function() { - var k, results; - k = 0; - results = []; + collectColors() { + let k = 0; + const results = []; while (k < this.mspace) { this.colors.push(Raphael.getColor(0.8)); // Skipping a few colors in the spectrum to get more contrast between colors @@ -100,23 +99,24 @@ export default (function() { results.push((k += 1)); } return results; - }; + } - BranchGraph.prototype.buildGraph = function() { - var cuday, cumonth, day, len, mm, ref; + buildGraph() { + let mm = 0; + let len = 0; + let cuday = 0; + let cumonth = ''; const { r } = this; - cuday = 0; - cumonth = ''; r.rect(0, 0, 40, this.barHeight).attr({ fill: '#222', }); r.rect(40, 0, 30, this.barHeight).attr({ fill: '#444', }); - ref = this.days; + const ref = this.days; for (mm = 0, len = ref.length; mm < len; mm += 1) { - day = ref[mm]; + const day = ref[mm]; if (cuday !== day[0] || cumonth !== day[1]) { // Dates r.text(55, this.offsetY + this.unitTime * mm, day[0]).attr({ @@ -138,29 +138,28 @@ export default (function() { } this.renderPartialGraph(); return this.bindEvents(); - }; + } - BranchGraph.prototype.renderPartialGraph = function() { - var commit, end, i, isGraphEdge, start, x, y; - start = Math.floor((this.element.scrollTop() - this.offsetY) / this.unitTime) - 10; + renderPartialGraph() { + const isGraphEdge = true; + let i = 0; + let start = Math.floor((this.element.scrollTop() - this.offsetY) / this.unitTime) - 10; if (start < 0) { - isGraphEdge = true; start = 0; } - end = start + 40; + let end = start + 40; if (this.commits.length < end) { - isGraphEdge = true; end = this.commits.length; } if (this.prev_start === -1 || Math.abs(this.prev_start - start) > 10 || isGraphEdge) { i = start; this.prev_start = start; while (i < end) { - commit = this.commits[i]; + const commit = this.commits[i]; i += 1; if (commit.hasDrawn !== true) { - x = this.offsetX + this.unitSpace * (this.mspace - commit.space); - y = this.offsetY + this.unitTime * commit.time; + const x = this.offsetX + this.unitSpace * (this.mspace - commit.space); + const y = this.offsetY + this.unitTime * commit.time; this.drawDot(x, y, commit); this.drawLines(x, y, commit); this.appendLabel(x, y, commit); @@ -170,70 +169,62 @@ export default (function() { } return this.top.toFront(); } - }; + } - BranchGraph.prototype.bindEvents = function() { + bindEvents() { const { element } = this; - return $(element).scroll( - (function(_this) { - return function() { - return _this.renderPartialGraph(); - }; - })(this), - ); - }; + return $(element).scroll(() => this.renderPartialGraph()); + } - BranchGraph.prototype.scrollDown = function() { + scrollDown() { this.element.scrollTop(this.element.scrollTop() + 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollUp = function() { + scrollUp() { this.element.scrollTop(this.element.scrollTop() - 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollLeft = function() { + scrollLeft() { this.element.scrollLeft(this.element.scrollLeft() - 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollRight = function() { + scrollRight() { this.element.scrollLeft(this.element.scrollLeft() + 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollBottom = function() { + scrollBottom() { return this.element.scrollTop(this.element.find('svg').height()); - }; + } - BranchGraph.prototype.scrollTop = function() { + scrollTop() { return this.element.scrollTop(0); - }; - - BranchGraph.prototype.appendLabel = function(x, y, commit) { - var label, rect, shortrefs, text, textbox; + } + appendLabel(x, y, commit) { if (!commit.refs) { return; } const { r } = this; - shortrefs = commit.refs; + let shortrefs = commit.refs; // Truncate if longer than 15 chars if (shortrefs.length > 17) { shortrefs = `${shortrefs.substr(0, 15)}…`; } - text = r.text(x + 4, y, shortrefs).attr({ + const text = r.text(x + 4, y, shortrefs).attr({ 'text-anchor': 'start', font: '10px Monaco, monospace', fill: '#FFF', title: commit.refs, }); - textbox = text.getBBox(); + const textbox = text.getBBox(); // Create rectangle based on the size of the textbox - rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({ + const rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({ fill: '#000', 'fill-opacity': 0.5, stroke: 'none', @@ -244,13 +235,13 @@ export default (function() { 'fill-opacity': 0.5, stroke: 'none', }); - label = r.set(rect, text); + const label = r.set(rect, text); label.transform(['t', -rect.getBBox().width - 15, 0]); // Set text to front return text.toFront(); - }; + } - BranchGraph.prototype.appendAnchor = function(x, y, commit) { + appendAnchor(x, y, commit) { const { r, top, options } = this; const anchor = r .circle(x, y, 10) @@ -270,9 +261,9 @@ export default (function() { }, ); return top.push(anchor); - }; + } - BranchGraph.prototype.drawDot = function(x, y, commit) { + drawDot(x, y, commit) { const { r } = this; r.circle(x, y, 3).attr({ fill: this.colors[commit.space], @@ -293,20 +284,24 @@ export default (function() { 'text-anchor': 'start', font: '14px Monaco, monospace', }); - }; + } - BranchGraph.prototype.drawLines = function(x, y, commit) { - var arrow, color, i, len, offset, parent, parentCommit, parentX1, parentX2, parentY, route; + drawLines(x, y, commit) { + let i = 0; + let len = 0; + let arrow = ''; + let offset = []; + let color = []; const { r } = this; const ref = commit.parents; const results = []; for (i = 0, len = ref.length; i < len; i += 1) { - parent = ref[i]; - parentCommit = this.preparedCommits[parent[0]]; - parentY = this.offsetY + this.unitTime * parentCommit.time; - parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space); - parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]); + const parent = ref[i]; + const parentCommit = this.preparedCommits[parent[0]]; + const parentY = this.offsetY + this.unitTime * parentCommit.time; + const parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space); + const parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]); // Set line color if (parentCommit.space <= commit.space) { color = this.colors[commit.space]; @@ -325,7 +320,7 @@ export default (function() { arrow = 'l-5,0,2,4,3,-4,-4,2'; } // Start point - route = ['M', x + offset[0], y + offset[1]]; + const route = ['M', x + offset[0], y + offset[1]]; // Add arrow if not first parent if (i > 0) { route.push(arrow); @@ -344,9 +339,9 @@ export default (function() { ); } return results; - }; + } - BranchGraph.prototype.markCommit = function(commit) { + markCommit(commit) { if (commit.id === this.options.commit_id) { const { r } = this; const x = this.offsetX + this.unitSpace * (this.mspace - commit.space); @@ -359,7 +354,5 @@ export default (function() { // Displayed in the center return this.element.scrollTop(y - this.graphHeight / 2); } - }; - - return BranchGraph; -})(); + } +} diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 9f9db21d65b..918c6e408a2 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */ +/* eslint-disable func-names, consistent-return, no-return-assign, no-else-return, @gitlab/i18n/no-non-i18n-strings */ import $ from 'jquery'; import RefSelectDropdown from './ref_select_dropdown'; @@ -26,23 +26,22 @@ export default class NewBranchForm { } setupRestrictions() { - var endsWith, invalid, single, startsWith; - startsWith = { + const startsWith = { pattern: /^(\/|\.)/g, prefix: "can't start with", conjunction: 'or', }; - endsWith = { + const endsWith = { pattern: /(\/|\.|\.lock)$/g, prefix: "can't end in", conjunction: 'or', }; - invalid = { + const invalid = { pattern: /(\s|~|\^|:|\?|\*|\[|\\|\.\.|@\{|\/{2,}){1}/g, prefix: "can't contain", conjunction: ', ', }; - single = { + const single = { pattern: /^@+$/g, prefix: "can't be", conjunction: 'or', @@ -51,19 +50,17 @@ export default class NewBranchForm { } validate() { - var errorMessage, errors, formatter, unique, validator; const { indexOf } = []; this.branchNameError.empty(); - unique = function(values, value) { + const unique = function(values, value) { if (indexOf.call(values, value) === -1) { values.push(value); } return values; }; - formatter = function(values, restriction) { - var formatted; - formatted = values.map(value => { + const formatter = function(values, restriction) { + const formatted = values.map(value => { switch (false) { case !/\s/.test(value): return 'spaces'; @@ -75,20 +72,17 @@ export default class NewBranchForm { }); return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`; }; - validator = (function(_this) { - return function(errors, restriction) { - var matched; - matched = _this.name.val().match(restriction.pattern); - if (matched) { - return errors.concat(formatter(matched.reduce(unique, []), restriction)); - } else { - return errors; - } - }; - })(this); - errors = this.restrictions.reduce(validator, []); + const validator = (errors, restriction) => { + const matched = this.name.val().match(restriction.pattern); + if (matched) { + return errors.concat(formatter(matched.reduce(unique, []), restriction)); + } else { + return errors; + } + }; + const errors = this.restrictions.reduce(validator, []); if (errors.length > 0) { - errorMessage = $('<span/>').text(errors.join(', ')); + const errorMessage = $('<span/>').text(errors.join(', ')); return this.branchNameError.append(errorMessage); } } diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index b142f212eb0..037be8467cb 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,4 +1,4 @@ -/* eslint-disable no-var, no-return-assign */ +/* eslint-disable no-return-assign */ export default class NewCommitForm { constructor(form) { this.form = form; @@ -11,8 +11,7 @@ export default class NewCommitForm { this.renderDestination(); } renderDestination() { - var different; - different = this.branchName.val() !== this.originalBranch.val(); + const different = this.branchName.val() !== this.originalBranch.val(); if (different) { this.createMergeRequestContainer.show(); if (!this.wasDifferent) { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 3715a91d599..defa278c089 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,8 +1,8 @@ -/* eslint-disable no-restricted-properties, func-names, no-var, camelcase, +/* eslint-disable no-restricted-properties, no-var, camelcase, no-unused-expressions, one-var, default-case, -consistent-return, no-alert, no-return-assign, -no-param-reassign, no-else-return, vars-on-top, -no-shadow, no-useless-escape, class-methods-use-this */ +consistent-return, no-alert, no-param-reassign, no-else-return, +vars-on-top, no-shadow, no-useless-escape, +class-methods-use-this */ /* global ResolveService */ @@ -281,14 +281,7 @@ export default class Notes { if (Notes.interval) { clearInterval(Notes.interval); } - return (Notes.interval = setInterval( - (function(_this) { - return function() { - return _this.refresh(); - }; - })(this), - this.pollingInterval, - )); + Notes.interval = setInterval(() => this.refresh(), this.pollingInterval); } refresh() { @@ -847,57 +840,52 @@ export default class Notes { var noteElId, $note; $note = $(e.currentTarget).closest('.note'); noteElId = $note.attr('id'); - $(`.note[id="${noteElId}"]`).each( - (function() { - // A same note appears in the "Discussion" and in the "Changes" tab, we have - // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, - // where $('#noteId') would return only one. - return function(i, el) { - var $note, $notes; - $note = $(el); - $notes = $note.closest('.discussion-notes'); - const discussionId = $('.notes', $notes).data('discussionId'); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - if (gl.diffNoteApps[noteElId]) { - gl.diffNoteApps[noteElId].$destroy(); - } - } - - $note.remove(); + $(`.note[id="${noteElId}"]`).each((i, el) => { + // A same note appears in the "Discussion" and in the "Changes" tab, we have + // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, + // where $('#noteId') would return only one. + const $note = $(el); + const $notes = $note.closest('.discussion-notes'); + const discussionId = $('.notes', $notes).data('discussionId'); + + if (typeof gl.diffNotesCompileComponents !== 'undefined') { + if (gl.diffNoteApps[noteElId]) { + gl.diffNoteApps[noteElId].$destroy(); + } + } - // check if this is the last note for this line - if ($notes.find('.note').length === 0) { - var notesTr = $notes.closest('tr'); + $note.remove(); - // "Discussions" tab - $notes.closest('.timeline-entry').remove(); + // check if this is the last note for this line + if ($notes.find('.note').length === 0) { + const notesTr = $notes.closest('tr'); - $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); + // "Discussions" tab + $notes.closest('.timeline-entry').remove(); - // The notes tr can contain multiple lists of notes, like on the parallel diff - // notesTr does not exist for image diffs - if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { - const $diffFile = $notes.closest('.diff-file'); - if ($diffFile.length > 0) { - const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { - detail: { - // badgeNumber's start with 1 and index starts with 0 - badgeNumber: $notes.index() + 1, - }, - }); + $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); - $diffFile[0].dispatchEvent(removeBadgeEvent); - } + // The notes tr can contain multiple lists of notes, like on the parallel diff + // notesTr does not exist for image diffs + if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { + const $diffFile = $notes.closest('.diff-file'); + if ($diffFile.length > 0) { + const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { + detail: { + // badgeNumber's start with 1 and index starts with 0 + badgeNumber: $notes.index() + 1, + }, + }); - $notes.remove(); - } else if (notesTr.length > 0) { - notesTr.remove(); - } + $diffFile[0].dispatchEvent(removeBadgeEvent); } - }; - })(this), - ); + + $notes.remove(); + } else if (notesTr.length > 0) { + notesTr.remove(); + } + } + }); Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue new file mode 100644 index 00000000000..4c9075912ee --- /dev/null +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -0,0 +1,133 @@ +<script> +import { mapActions } from 'vuex'; +import _ from 'underscore'; + +import { s__, __, sprintf } from '~/locale'; +import { truncateSha } from '~/lib/utils/text_utility'; + +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import noteEditedText from './note_edited_text.vue'; +import noteHeader from './note_header.vue'; + +export default { + name: 'DiffDiscussionHeader', + components: { + userAvatarLink, + noteEditedText, + noteHeader, + }, + props: { + discussion: { + type: Object, + required: true, + }, + }, + computed: { + notes() { + return this.discussion.notes; + }, + firstNote() { + return this.notes[0]; + }, + lastNote() { + return this.notes[this.notes.length - 1]; + }, + author() { + return this.firstNote.author; + }, + resolvedText() { + return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); + }, + lastUpdatedBy() { + return this.notes.length > 1 ? this.lastNote.author : null; + }, + lastUpdatedAt() { + return this.notes.length > 1 ? this.lastNote.created_at : null; + }, + headerText() { + const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; + const linkEnd = '</a>'; + + const { commit_id: commitId } = this.discussion; + let commitDisplay = commitId; + + if (commitId) { + commitDisplay = `<span class="commit-sha">${truncateSha(commitId)}</span>`; + } + + const { + for_commit: isForCommit, + diff_discussion: isDiffDiscussion, + active: isActive, + } = this.discussion; + + let text = s__('MergeRequests|started a thread'); + if (isForCommit) { + text = s__( + 'MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}', + ); + } else if (isDiffDiscussion && commitId) { + text = isActive + ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}') + : s__( + 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitDisplay}%{linkEnd}', + ); + } else if (isDiffDiscussion) { + text = isActive + ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}') + : s__( + 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}', + ); + } + + return sprintf(text, { commitDisplay, linkStart, linkEnd }, false); + }, + }, + methods: { + ...mapActions(['toggleDiscussion']), + toggleDiscussionHandler() { + this.toggleDiscussion({ discussionId: this.discussion.id }); + }, + }, +}; +</script> + +<template> + <div class="discussion-header note-wrapper"> + <div v-once class="timeline-icon align-self-start flex-shrink-0"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content w-100"> + <note-header + :author="author" + :created-at="firstNote.created_at" + :note-id="firstNote.id" + :include-toggle="true" + :expanded="discussion.expanded" + @toggleHandler="toggleDiscussionHandler" + > + <span v-html="headerText"></span> + </note-header> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline" + /> + <note-edited-text + v-else-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + :action-text="__('Last updated')" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 3158e086f6c..e4f09492d9c 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -101,6 +101,7 @@ export default { <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" /> </a> </template> + <slot name="extra-controls"></slot> <i class="fa fa-spinner fa-spin editing-spinner" :aria-label="__('Comment is being updated')" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index cb1975a8962..47ec740b63a 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,18 +1,15 @@ <script> -import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; -import { truncateSha } from '~/lib/utils/text_utility'; -import { s__, __, sprintf } from '~/locale'; +import { s__, __ } from '~/locale'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import icon from '~/vue_shared/components/icon.vue'; import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import noteHeader from './note_header.vue'; +import diffDiscussionHeader from './diff_discussion_header.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; -import noteEditedText from './note_edited_text.vue'; import noteForm from './note_form.vue'; import diffWithNote from './diff_with_note.vue'; import noteable from '../mixins/noteable'; @@ -27,9 +24,8 @@ export default { components: { icon, userAvatarLink, - noteHeader, + diffDiscussionHeader, noteSignedOutWidget, - noteEditedText, noteForm, DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'), TimelineEntryItem, @@ -92,9 +88,6 @@ export default { currentUser() { return this.getUserData; }, - author() { - return this.firstNote.author; - }, autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); }, @@ -104,27 +97,6 @@ export default { firstNote() { return this.discussion.notes.slice(0, 1)[0]; }, - lastUpdatedBy() { - const { notes } = this.discussion; - - if (notes.length > 1) { - return notes[notes.length - 1].author; - } - - return null; - }, - lastUpdatedAt() { - const { notes } = this.discussion; - - if (notes.length > 1) { - return notes[notes.length - 1].created_at; - } - - return null; - }, - resolvedText() { - return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); - }, shouldShowJumpToNextDiscussion() { return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion'); }, @@ -150,40 +122,6 @@ export default { shouldHideDiscussionBody() { return this.shouldRenderDiffs && !this.isExpanded; }, - actionText() { - const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; - const linkEnd = '</a>'; - - let { commit_id: commitId } = this.discussion; - if (commitId) { - commitId = `<span class="commit-sha">${truncateSha(commitId)}</span>`; - } - - const { - for_commit: isForCommit, - diff_discussion: isDiffDiscussion, - active: isActive, - } = this.discussion; - - let text = s__('MergeRequests|started a thread'); - if (isForCommit) { - text = s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}'); - } else if (isDiffDiscussion && commitId) { - text = isActive - ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}') - : s__( - 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}', - ); - } else if (isDiffDiscussion) { - text = isActive - ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}') - : s__( - 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}', - ); - } - - return sprintf(text, { commitId, linkStart, linkEnd }, false); - }, diffLine() { if (this.line) { return this.line; @@ -208,16 +146,11 @@ export default { methods: { ...mapActions([ 'saveNote', - 'toggleDiscussion', 'removePlaceholderNotes', 'toggleResolveNote', 'expandDiscussion', 'removeConvertedDiscussion', ]), - truncateSha, - toggleDiscussionHandler() { - this.toggleDiscussion({ discussionId: this.discussion.id }); - }, showReplyForm() { this.isReplying = true; }, @@ -311,43 +244,7 @@ export default { class="discussion js-discussion-container" data-qa-selector="discussion_content" > - <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> - <div v-once class="timeline-icon align-self-start flex-shrink-0"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> - <div class="timeline-content w-100"> - <note-header - :author="author" - :created-at="firstNote.created_at" - :note-id="firstNote.id" - :include-toggle="true" - :expanded="discussion.expanded" - @toggleHandler="toggleDiscussionHandler" - > - <span v-html="actionText"></span> - </note-header> - <note-edited-text - v-if="discussion.resolved" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline" - /> - <note-edited-text - v-else-if="lastUpdatedAt" - :edited-at="lastUpdatedAt" - :edited-by="lastUpdatedBy" - action-text="Last updated" - class-name="discussion-headline-light js-discussion-headline" - /> - </div> - </div> + <diff-discussion-header v-if="shouldRenderDiffs" :discussion="discussion" /> <div v-if="!shouldHideDiscussionBody" class="discussion-body"> <component :is="wrapperComponent" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index c6c97489e5e..9d1de4ef8a0 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -122,6 +122,8 @@ export default { this.toggleAward({ awardName, noteId }); }); } + + window.addEventListener('hashchange', this.handleHashChanged); }, updated() { this.$nextTick(() => { @@ -131,6 +133,7 @@ export default { }, beforeDestroy() { this.stopPolling(); + window.removeEventListener('hashchange', this.handleHashChanged); }, methods: { ...mapActions([ @@ -138,7 +141,6 @@ export default { 'fetchDiscussions', 'poll', 'toggleAward', - 'scrollToNoteIfNeeded', 'setNotesData', 'setNoteableData', 'setUserData', @@ -151,6 +153,13 @@ export default { 'convertToDiscussion', 'stopPolling', ]), + handleHashChanged() { + const noteId = this.checkLocationHash(); + + if (noteId) { + this.setTargetNoteHash(getLocationHash()); + } + }, fetchNotes() { if (this.isFetching) return null; @@ -194,6 +203,8 @@ export default { this.expandDiscussion({ discussionId: discussion.id }); } } + + return noteId; }, startReplying(discussionId) { return this.convertToDiscussion(discussionId) diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js new file mode 100644 index 00000000000..12d80f3faa2 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/description_version_history.js @@ -0,0 +1,12 @@ +// Placeholder for GitLab FOSS +// Actual implementation: ee/app/assets/javascripts/notes/mixins/description_version_history.js +export default { + computed: { + canSeeDescriptionVersion() {}, + shouldShowDescriptionVersion() {}, + descriptionVersionToggleIcon() {}, + }, + methods: { + toggleDescriptionVersion() {}, + }, +}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 004035ea1d4..82c291379ec 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -12,6 +12,7 @@ import service from '../services/notes_service'; import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; +import { mergeUrlParams } from '../../lib/utils/url_utility'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; import { __ } from '~/locale'; import Api from '~/api'; @@ -475,5 +476,20 @@ export const convertToDiscussion = ({ commit }, noteId) => export const removeConvertedDiscussion = ({ commit }, noteId) => commit(types.REMOVE_CONVERTED_DISCUSSION, noteId); +export const fetchDescriptionVersion = (_, { endpoint, startingVersion }) => { + let requestUrl = endpoint; + + if (startingVersion) { + requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl); + } + + return axios + .get(requestUrl) + .then(res => res.data) + .catch(() => { + Flash(__('Something went wrong while fetching description changes. Please try again.')); + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js index bee6d4f0329..3cdcc7a05b8 100644 --- a/app/assets/javascripts/notes/stores/collapse_utils.js +++ b/app/assets/javascripts/notes/stores/collapse_utils.js @@ -1,34 +1,9 @@ -import { n__, s__, sprintf } from '~/locale'; import { DESCRIPTION_TYPE } from '../constants'; /** - * Changes the description from a note, returns 'changed the description n number of times' - */ -export const changeDescriptionNote = (note, descriptionChangedTimes, timeDifferenceMinutes) => { - const descriptionNote = Object.assign({}, note); - - descriptionNote.note_html = sprintf( - s__(`MergeRequest| - %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}`), - { - paragraphStart: '<p dir="auto">', - paragraphEnd: '</p>', - descriptionChangedTimes, - timeDifferenceMinutes: n__('within %d minute ', 'within %d minutes ', timeDifferenceMinutes), - }, - false, - ); - - descriptionNote.times_updated = descriptionChangedTimes; - - return descriptionNote; -}; - -/** * Checks the time difference between two notes from their 'created_at' dates * returns an integer */ - export const getTimeDifferenceMinutes = (noteBeggining, noteEnd) => { const descriptionNoteBegin = new Date(noteBeggining.created_at); const descriptionNoteEnd = new Date(noteEnd.created_at); @@ -57,7 +32,6 @@ export const isDescriptionSystemNote = note => note.system && note.note === DESC export const collapseSystemNotes = notes => { let lastDescriptionSystemNote = null; let lastDescriptionSystemNoteIndex = -1; - let descriptionChangedTimes = 1; return notes.slice(0).reduce((acc, currentNote) => { const note = currentNote.notes[0]; @@ -70,32 +44,24 @@ export const collapseSystemNotes = notes => { } else if (lastDescriptionSystemNote) { const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note); - // are they less than 10 minutes apart? - if (timeDifferenceMinutes > 10) { - // reset counter - descriptionChangedTimes = 1; + // are they less than 10 minutes apart from the same user? + if (timeDifferenceMinutes > 10 || note.author.id !== lastDescriptionSystemNote.author.id) { // update the previous system note lastDescriptionSystemNote = note; lastDescriptionSystemNoteIndex = acc.length; } else { - // increase counter - descriptionChangedTimes += 1; + // set the first version to fetch grouped system note versions + note.start_description_version_id = lastDescriptionSystemNote.description_version_id; // delete the previous one acc.splice(lastDescriptionSystemNoteIndex, 1); - // replace the text of the current system note with the collapsed note. - currentNote.notes.splice( - 0, - 1, - changeDescriptionNote(note, descriptionChangedTimes, timeDifferenceMinutes), - ); - // update the previous system note index lastDescriptionSystemNoteIndex = acc.length; } } } + acc.push(currentNote); return acc; }, []); diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js index d76b1f174fc..d97e24d9e0b 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/index.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js @@ -1,3 +1,8 @@ +/* eslint-disable no-new */ import AbuseReports from './abuse_reports'; +import UsersSelect from '~/users_select'; -document.addEventListener('DOMContentLoaded', () => new AbuseReports()); +document.addEventListener('DOMContentLoaded', () => { + new AbuseReports(); + new UsersSelect(); +}); diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js index 43992938d07..4d04c37caa7 100644 --- a/app/assets/javascripts/pages/admin/clusters/index.js +++ b/app/assets/javascripts/pages/admin/clusters/index.js @@ -1,21 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; -import initGkeDropdowns from '~/create_cluster/gke_cluster'; - -function initGcpSignupCallout() { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); -} +import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - const { page } = document.body.dataset; - const newClusterViews = [ - 'admin:clusters:new', - 'admin:clusters:create_gcp', - 'admin:clusters:create_user', - ]; - - if (newClusterViews.indexOf(page) > -1) { - initGcpSignupCallout(); - initGkeDropdowns(); - } + initCreateCluster(document, gon); }); diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index a33d242908b..4d04c37caa7 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -1,21 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; -import initGkeDropdowns from '~/create_cluster/gke_cluster'; - -function initGcpSignupCallout() { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); -} +import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - const { page } = document.body.dataset; - const newClusterViews = [ - 'groups:clusters:new', - 'groups:clusters:create_gcp', - 'groups:clusters:create_user', - ]; - - if (newClusterViews.indexOf(page) > -1) { - initGcpSignupCallout(); - initGkeDropdowns(); - } + initCreateCluster(document, gon); }); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index dcdee77a8ab..090e1a2bc6d 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,3 +1,4 @@ +import initIssuablesList from '~/issuables_list'; import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; @@ -11,6 +12,8 @@ document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); + initIssuablesList(); + initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, isGroupDecendent: true, diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js new file mode 100644 index 00000000000..1d68ccd724d --- /dev/null +++ b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +const rootUrl = gon.relative_url_root; + +export default function fetchGroupPathAvailability(groupPath) { + return axios.get(`${rootUrl}/users/${groupPath}/suggests`); +} diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js new file mode 100644 index 00000000000..2021ad117e8 --- /dev/null +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -0,0 +1,91 @@ +import InputValidator from '~/validators/input_validator'; + +import _ from 'underscore'; +import fetchGroupPathAvailability from './fetch_group_path_availability'; +import flash from '~/flash'; +import { __ } from '~/locale'; + +const debounceTimeoutDuration = 1000; +const invalidInputClass = 'gl-field-error-outline'; +const successInputClass = 'gl-field-success-outline'; +const successMessageSelector = '.validation-success'; +const pendingMessageSelector = '.validation-pending'; +const unavailableMessageSelector = '.validation-error'; +const suggestionsMessageSelector = '.gl-path-suggestions'; + +export default class GroupPathValidator extends InputValidator { + constructor(opts = {}) { + super(); + + const container = opts.container || ''; + const validateElements = document.querySelectorAll(`${container} .js-validate-group-path`); + + this.debounceValidateInput = _.debounce(inputDomElement => { + GroupPathValidator.validateGroupPathInput(inputDomElement); + }, debounceTimeoutDuration); + + validateElements.forEach(element => + element.addEventListener('input', this.eventHandler.bind(this)), + ); + } + + eventHandler(event) { + const inputDomElement = event.target; + + GroupPathValidator.resetInputState(inputDomElement); + this.debounceValidateInput(inputDomElement); + } + + static validateGroupPathInput(inputDomElement) { + const groupPath = inputDomElement.value; + + if (inputDomElement.checkValidity() && groupPath.length > 0) { + GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); + + fetchGroupPathAvailability(groupPath) + .then(({ data }) => data) + .then(data => { + GroupPathValidator.setInputState(inputDomElement, !data.exists); + GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector, false); + GroupPathValidator.setMessageVisibility( + inputDomElement, + data.exists ? unavailableMessageSelector : successMessageSelector, + ); + + if (data.exists) { + GroupPathValidator.showSuggestions(inputDomElement, data.suggests); + } + }) + .catch(() => flash(__('An error occurred while validating group path'))); + } + } + + static showSuggestions(inputDomElement, suggestions) { + const messageElement = inputDomElement.parentElement.parentElement.querySelector( + suggestionsMessageSelector, + ); + const textSuggestions = suggestions && suggestions.length > 0 ? suggestions.join(', ') : 'none'; + messageElement.textContent = textSuggestions; + } + + static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) { + const messageElement = inputDomElement.parentElement.parentElement.querySelector( + messageSelector, + ); + messageElement.classList.toggle('hide', !isVisible); + } + + static setInputState(inputDomElement, success = true) { + inputDomElement.classList.toggle(successInputClass, success); + inputDomElement.classList.toggle(invalidInputClass, !success); + } + + static resetInputState(inputDomElement) { + GroupPathValidator.setMessageVisibility(inputDomElement, successMessageSelector, false); + GroupPathValidator.setMessageVisibility(inputDomElement, unavailableMessageSelector, false); + + if (inputDomElement.checkValidity()) { + inputDomElement.classList.remove(successInputClass, invalidInputClass); + } + } +} diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 57b53eb9e5d..0710fefe70c 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,8 +1,14 @@ +import $ from 'jquery'; import BindInOut from '~/behaviors/bind_in_out'; import Group from '~/group'; import initAvatarPicker from '~/avatar_picker'; +import GroupPathValidator from './group_path_validator'; document.addEventListener('DOMContentLoaded', () => { + const parentId = $('#group_parent_id'); + if (!parentId.val()) { + new GroupPathValidator(); // eslint-disable-line no-new + } BindInOut.initAll(); new Group(); // eslint-disable-line no-new initAvatarPicker(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 84e5bb3c46e..aee67899ca2 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -3,6 +3,7 @@ import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_sta import BlobViewer from '~/blob/viewer/index'; import initBlob from '~/pages/projects/init_blob'; import GpgBadges from '~/gpg_badges'; +import '~/sourcegraph/load'; document.addEventListener('DOMContentLoaded', () => { new BlobViewer(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/new/index.js deleted file mode 100644 index 14d5ab21555..00000000000 --- a/app/assets/javascripts/pages/projects/clusters/new/index.js +++ /dev/null @@ -1,13 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - if (gon.features.createEksClusters) { - import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') - .then(({ default: initCreateEKSCluster }) => { - const el = document.querySelector('.js-create-eks-cluster-form-container'); - - if (el) { - initCreateEKSCluster(el); - } - }) - .catch(() => {}); - } -}); diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index f091c01fc98..397f9faf6fe 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,5 +1,5 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -import initGkeNamespace from '~/projects/gke_cluster_namespace'; +import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 5aa4734244e..0eb6f231839 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -9,6 +9,7 @@ import initNotes from '~/init_notes'; import initChangesDropdown from '~/init_changes_dropdown'; import initDiffNotes from '~/diff_notes/diff_notes_bundle'; import { fetchCommitMergeRequests } from '~/commit_merge_requests'; +import '~/sourcegraph/load'; document.addEventListener('DOMContentLoaded', () => { const hasPerfBar = document.querySelector('.with-performance-bar'); diff --git a/app/assets/javascripts/pages/projects/error_tracking/details/index.js b/app/assets/javascripts/pages/projects/error_tracking/details/index.js new file mode 100644 index 00000000000..25d1c744e1b --- /dev/null +++ b/app/assets/javascripts/pages/projects/error_tracking/details/index.js @@ -0,0 +1,5 @@ +import ErrorTrackingDetails from '~/error_tracking/details'; + +document.addEventListener('DOMContentLoaded', () => { + ErrorTrackingDetails(); +}); diff --git a/app/assets/javascripts/pages/projects/error_tracking/index.js b/app/assets/javascripts/pages/projects/error_tracking/index.js deleted file mode 100644 index 5a8fe137e9a..00000000000 --- a/app/assets/javascripts/pages/projects/error_tracking/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import ErrorTracking from '~/error_tracking'; - -document.addEventListener('DOMContentLoaded', () => { - ErrorTracking(); -}); diff --git a/app/assets/javascripts/pages/projects/error_tracking/index/index.js b/app/assets/javascripts/pages/projects/error_tracking/index/index.js new file mode 100644 index 00000000000..ead81cd5d2d --- /dev/null +++ b/app/assets/javascripts/pages/projects/error_tracking/index/index.js @@ -0,0 +1,5 @@ +import ErrorTrackingList from '~/error_tracking/list'; + +document.addEventListener('DOMContentLoaded', () => { + ErrorTrackingList(); +}); diff --git a/app/assets/javascripts/pages/projects/graphs/show/index.js b/app/assets/javascripts/pages/projects/graphs/show/index.js index f79c386b59e..09d9c78c446 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/index.js +++ b/app/assets/javascripts/pages/projects/graphs/show/index.js @@ -1,25 +1,3 @@ -import $ from 'jquery'; -import flash from '~/flash'; -import { __ } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; -import ContributorsStatGraph from './stat_graph_contributors'; +import initContributorsGraphs from '~/contributors'; -document.addEventListener('DOMContentLoaded', () => { - const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath; - - axios - .get(url) - .then(({ data }) => { - const graph = new ContributorsStatGraph(); - graph.init(data); - - $('#brush_change').change(() => { - graph.change_date_header(); - graph.redraw_authors(); - }); - - $('.stat-graph').fadeIn(); - $('.loading-graph').hide(); - }) - .catch(() => flash(__('Error fetching contributors data.'))); -}); +document.addEventListener('DOMContentLoaded', initContributorsGraphs); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js deleted file mode 100644 index 5b873e6b909..00000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign */ - -import $ from 'jquery'; -import _ from 'underscore'; -import { n__, s__, createDateTimeFormat, sprintf } from '~/locale'; -import { - ContributorsGraph, - ContributorsAuthorGraph, - ContributorsMasterGraph, -} from './stat_graph_contributors_graph'; -import ContributorsStatGraphUtil from './stat_graph_contributors_util'; - -export default (function() { - function ContributorsStatGraph() { - this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' }); - } - - ContributorsStatGraph.prototype.init = function(log) { - var author_commits, total_commits; - this.parsed_log = ContributorsStatGraphUtil.parse_log(log); - this.set_current_field('commits'); - total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); - author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field); - this.add_master_graph(total_commits); - this.add_authors_graph(author_commits); - return this.change_date_header(); - }; - - ContributorsStatGraph.prototype.add_master_graph = function(total_data) { - this.master_graph = new ContributorsMasterGraph(total_data); - return this.master_graph.draw(); - }; - - ContributorsStatGraph.prototype.add_authors_graph = function(author_data) { - var limited_author_data; - this.authors = []; - limited_author_data = author_data.slice(0, 100); - return _.each( - limited_author_data, - (function(_this) { - return function(d) { - var author_graph, author_header; - author_header = _this.create_author_header(d); - $('.contributors-list').append(author_header); - - author_graph = new ContributorsAuthorGraph(d.dates); - _this.authors[d.author_name] = author_graph; - return author_graph.draw(); - }; - })(this), - ); - }; - - ContributorsStatGraph.prototype.format_author_commit_info = function(author) { - var commits; - commits = $('<span/>', { - class: 'graph-author-commits-count', - }); - commits.text(n__('%d commit', '%d commits', author.commits)); - return $('<span/>').append(commits); - }; - - ContributorsStatGraph.prototype.create_author_header = function(author) { - var author_commit_info, author_commit_info_span, author_email, author_name, list_item; - list_item = $('<li/>', { - class: 'person', - style: 'display: block;', - }); - author_name = $(`<h4>${author.author_name}</h4>`); - author_email = $(`<p class="graph-author-email">${author.author_email}</p>`); - author_commit_info_span = $('<span/>', { - class: 'commits', - }); - author_commit_info = this.format_author_commit_info(author); - author_commit_info_span.html(author_commit_info); - list_item.append(author_name); - list_item.append(author_email); - list_item.append(author_commit_info_span); - return list_item; - }; - - ContributorsStatGraph.prototype.redraw_master = function() { - var total_data; - total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); - this.master_graph.set_data(total_data); - return this.master_graph.redraw(); - }; - - ContributorsStatGraph.prototype.redraw_authors = function() { - $('ol').html(''); - - const { x_domain } = ContributorsGraph.prototype; - const author_commits = ContributorsStatGraphUtil.get_author_data( - this.parsed_log, - this.field, - x_domain, - ); - - return _.each( - author_commits, - (function(_this) { - return function(d) { - _this.redraw_author_commit_info(d); - if (_this.authors[d.author_name] != null) { - $(_this.authors[d.author_name].list_item).appendTo('ol'); - _this.authors[d.author_name].set_data(d.dates); - return _this.authors[d.author_name].redraw(); - } - return ''; - }; - })(this), - ); - }; - - ContributorsStatGraph.prototype.set_current_field = function(field) { - return (this.field = field); - }; - - ContributorsStatGraph.prototype.change_date_header = function() { - const { x_domain } = ContributorsGraph.prototype; - const formattedDateRange = sprintf(s__('ContributorsPage|%{startDate} – %{endDate}'), { - startDate: this.dateFormat.format(new Date(x_domain[0])), - endDate: this.dateFormat.format(new Date(x_domain[1])), - }); - return $('#date_header').text(formattedDateRange); - }; - - ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) { - var author_commit_info, author_list_item, $author; - $author = this.authors[author.author_name]; - if ($author != null) { - author_list_item = $(this.authors[author.author_name].list_item); - author_commit_info = this.format_author_commit_info(author); - return author_list_item.find('span').html(author_commit_info); - } - return ''; - }; - - return ContributorsStatGraph; -})(); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js deleted file mode 100644 index 86794800f87..00000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js +++ /dev/null @@ -1,379 +0,0 @@ -/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, no-else-return, no-shadow */ - -import $ from 'jquery'; -import _ from 'underscore'; -import { extent, max } from 'd3-array'; -import { select, event as d3Event } from 'd3-selection'; -import { scaleTime, scaleLinear } from 'd3-scale'; -import { axisLeft, axisBottom } from 'd3-axis'; -import { area } from 'd3-shape'; -import { brushX } from 'd3-brush'; -import { timeParse } from 'd3-time-format'; -import { dateTickFormat } from '~/lib/utils/tick_formats'; - -const d3 = { - extent, - max, - select, - scaleTime, - scaleLinear, - axisLeft, - axisBottom, - area, - brushX, - timeParse, -}; - -const hasProp = {}.hasOwnProperty; -const extend = function(child, parent) { - for (const key in parent) { - if (hasProp.call(parent, key)) child[key] = parent[key]; - } - function ctor() { - this.constructor = child; - } - ctor.prototype = parent.prototype; - child.prototype = new ctor(); - child.__super__ = parent.prototype; - return child; -}; - -export const ContributorsGraph = (function() { - function ContributorsGraph() {} - - ContributorsGraph.prototype.MARGIN = { - top: 20, - right: 10, - bottom: 30, - left: 40, - }; - - ContributorsGraph.prototype.x_domain = null; - - ContributorsGraph.prototype.y_domain = null; - - ContributorsGraph.prototype.dates = []; - - ContributorsGraph.prototype.determine_width = function(baseWidth, $parentElement) { - const parentPaddingWidth = - parseFloat($parentElement.css('padding-left')) + - parseFloat($parentElement.css('padding-right')); - const marginWidth = this.MARGIN.left + this.MARGIN.right; - return baseWidth - parentPaddingWidth - marginWidth; - }; - - ContributorsGraph.set_x_domain = function(data) { - return (ContributorsGraph.prototype.x_domain = data); - }; - - ContributorsGraph.set_y_domain = function(data) { - return (ContributorsGraph.prototype.y_domain = [ - 0, - d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), - ]); - }; - - ContributorsGraph.init_x_domain = function(data) { - return (ContributorsGraph.prototype.x_domain = d3.extent(data, d => d.date)); - }; - - ContributorsGraph.init_y_domain = function(data) { - return (ContributorsGraph.prototype.y_domain = [ - 0, - d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), - ]); - }; - - ContributorsGraph.init_domain = function(data) { - ContributorsGraph.init_x_domain(data); - return ContributorsGraph.init_y_domain(data); - }; - - ContributorsGraph.set_dates = function(data) { - return (ContributorsGraph.prototype.dates = data); - }; - - ContributorsGraph.prototype.set_x_domain = function() { - return this.x.domain(this.x_domain); - }; - - ContributorsGraph.prototype.set_y_domain = function() { - return this.y.domain(this.y_domain); - }; - - ContributorsGraph.prototype.set_domain = function() { - this.set_x_domain(); - return this.set_y_domain(); - }; - - ContributorsGraph.prototype.create_scale = function(width, height) { - this.x = d3 - .scaleTime() - .range([0, width]) - .clamp(true); - return (this.y = d3 - .scaleLinear() - .range([height, 0]) - .nice()); - }; - - ContributorsGraph.prototype.draw_x_axis = function() { - return this.svg - .append('g') - .attr('class', 'x axis') - .attr('transform', `translate(0, ${this.height})`) - .call(this.x_axis); - }; - - ContributorsGraph.prototype.draw_y_axis = function() { - return this.svg - .append('g') - .attr('class', 'y axis') - .call(this.y_axis); - }; - - ContributorsGraph.prototype.set_data = function(data) { - return (this.data = data); - }; - - return ContributorsGraph; -})(); - -export const ContributorsMasterGraph = (function(superClass) { - extend(ContributorsMasterGraph, superClass); - - function ContributorsMasterGraph(data1) { - const $parentElement = $('#contributors-master'); - - this.data = data1; - this.update_content = this.update_content.bind(this); - this.width = this.determine_width($('.js-graphs-show').width(), $parentElement); - this.height = 200; - this.x = null; - this.y = null; - this.x_axis = null; - this.y_axis = null; - this.area = null; - this.svg = null; - this.brush = null; - this.x_max_domain = null; - } - - ContributorsMasterGraph.prototype.process_dates = function(data) { - const dates = this.get_dates(data); - this.parse_dates(data); - return ContributorsGraph.set_dates(dates); - }; - - ContributorsMasterGraph.prototype.get_dates = function(data) { - return _.pluck(data, 'date'); - }; - - ContributorsMasterGraph.prototype.parse_dates = function(data) { - const parseDate = d3.timeParse('%Y-%m-%d'); - return data.forEach(d => (d.date = parseDate(d.date))); - }; - - ContributorsMasterGraph.prototype.create_scale = function() { - return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height); - }; - - ContributorsMasterGraph.prototype.create_axes = function() { - this.x_axis = d3 - .axisBottom() - .scale(this.x) - .tickFormat(dateTickFormat); - return (this.y_axis = d3 - .axisLeft() - .scale(this.y) - .ticks(5)); - }; - - ContributorsMasterGraph.prototype.create_svg = function() { - this.svg = d3 - .select('#contributors-master') - .append('svg') - .attr('width', this.width + this.MARGIN.left + this.MARGIN.right) - .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) - .attr('class', 'tint-box') - .append('g') - .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); - return this.svg; - }; - - ContributorsMasterGraph.prototype.create_area = function(x, y) { - return (this.area = d3 - .area() - .x(d => x(d.date)) - .y0(this.height) - .y1(d => { - d.commits = d.commits || d.additions || d.deletions; - return y(d.commits); - })); - }; - - ContributorsMasterGraph.prototype.create_brush = function() { - return (this.brush = d3 - .brushX(this.x) - .extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]) - .on('end', this.update_content)); - }; - - ContributorsMasterGraph.prototype.draw_path = function(data) { - return this.svg - .append('path') - .datum(data) - .attr('class', 'area') - .attr('d', this.area); - }; - - ContributorsMasterGraph.prototype.add_brush = function() { - return this.svg - .append('g') - .attr('class', 'selection') - .call(this.brush) - .selectAll('rect') - .attr('height', this.height); - }; - - ContributorsMasterGraph.prototype.update_content = function() { - // d3Event.selection replaces the function brush.empty() calls - if (d3Event.selection != null) { - ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert)); - } else { - ContributorsGraph.set_x_domain(this.x_max_domain); - } - return $('#brush_change').trigger('change'); - }; - - ContributorsMasterGraph.prototype.draw = function() { - this.process_dates(this.data); - this.create_scale(); - this.create_axes(); - ContributorsGraph.init_domain(this.data); - this.x_max_domain = this.x_domain; - this.set_domain(); - this.create_area(this.x, this.y); - this.create_svg(); - this.create_brush(); - this.draw_path(this.data); - this.draw_x_axis(); - this.draw_y_axis(); - return this.add_brush(); - }; - - ContributorsMasterGraph.prototype.redraw = function() { - this.process_dates(this.data); - ContributorsGraph.set_y_domain(this.data); - this.set_y_domain(); - this.svg.select('path').datum(this.data); - this.svg.select('path').attr('d', this.area); - return this.svg.select('.y.axis').call(this.y_axis); - }; - - return ContributorsMasterGraph; -})(ContributorsGraph); - -export const ContributorsAuthorGraph = (function(superClass) { - extend(ContributorsAuthorGraph, superClass); - - function ContributorsAuthorGraph(data1) { - const $parentElements = $('.person'); - - this.data = data1; - // Don't split graph size in half for mobile devices. - if ($(window).width() < 790) { - this.width = this.determine_width($('.js-graphs-show').width(), $parentElements); - } else { - this.width = this.determine_width($('.js-graphs-show').width() / 2, $parentElements); - } - this.height = 200; - this.x = null; - this.y = null; - this.x_axis = null; - this.y_axis = null; - this.area = null; - this.svg = null; - this.list_item = null; - } - - ContributorsAuthorGraph.prototype.create_scale = function() { - return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height); - }; - - ContributorsAuthorGraph.prototype.create_axes = function() { - this.x_axis = d3 - .axisBottom() - .scale(this.x) - .ticks(8) - .tickFormat(dateTickFormat); - return (this.y_axis = d3 - .axisLeft() - .scale(this.y) - .ticks(5)); - }; - - ContributorsAuthorGraph.prototype.create_area = function(x, y) { - return (this.area = d3 - .area() - .x(d => { - const parseDate = d3.timeParse('%Y-%m-%d'); - return x(parseDate(d)); - }) - .y0(this.height) - .y1( - (function(_this) { - return function(d) { - if (_this.data[d] != null) { - return y(_this.data[d]); - } else { - return y(0); - } - }; - })(this), - )); - }; - - ContributorsAuthorGraph.prototype.create_svg = function() { - const persons = document.querySelectorAll('.person'); - this.list_item = persons[persons.length - 1]; - this.svg = d3 - .select(this.list_item) - .append('svg') - .attr('width', this.width + this.MARGIN.left + this.MARGIN.right) - .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) - .attr('class', 'spark') - .append('g') - .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); - return this.svg; - }; - - ContributorsAuthorGraph.prototype.draw_path = function(data) { - return this.svg - .append('path') - .datum(data) - .attr('class', 'area-contributor') - .attr('d', this.area); - }; - - ContributorsAuthorGraph.prototype.draw = function() { - this.create_scale(); - this.create_axes(); - this.set_domain(); - this.create_area(this.x, this.y); - this.create_svg(); - this.draw_path(this.dates); - this.draw_x_axis(); - return this.draw_y_axis(); - }; - - ContributorsAuthorGraph.prototype.redraw = function() { - this.set_domain(); - this.svg.select('path').datum(this.dates); - this.svg.select('path').attr('d', this.area); - this.svg.select('.x.axis').call(this.x_axis); - return this.svg.select('.y.axis').call(this.y_axis); - }; - - return ContributorsAuthorGraph; -})(ContributorsGraph); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js deleted file mode 100644 index a89a13fe37a..00000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, consistent-return, no-cond-assign, no-else-return */ -import _ from 'underscore'; - -export default { - parse_log(log) { - var by_author, by_email, data, entry, i, len, total, normalized_email; - total = {}; - by_author = {}; - by_email = {}; - for (i = 0, len = log.length; i < len; i += 1) { - entry = log[i]; - if (total[entry.date] == null) { - this.add_date(entry.date, total); - } - normalized_email = entry.author_email.toLowerCase(); - data = by_author[entry.author_name] || by_email[normalized_email]; - if (data == null) { - data = this.add_author(entry, by_author, by_email); - } - if (!data[entry.date]) { - this.add_date(entry.date, data); - } - this.store_data(entry, total[entry.date], data[entry.date]); - } - total = _.toArray(total); - by_author = _.toArray(by_author); - return { - total, - by_author, - }; - }, - add_date(date, collection) { - collection[date] = {}; - return (collection[date].date = date); - }, - add_author(author, by_author, by_email) { - var data, normalized_email; - data = {}; - data.author_name = author.author_name; - data.author_email = author.author_email; - normalized_email = author.author_email.toLowerCase(); - by_author[author.author_name] = data; - by_email[normalized_email] = data; - return data; - }, - store_data(entry, total, by_author) { - this.store_commits(total, by_author); - this.store_additions(entry, total, by_author); - return this.store_deletions(entry, total, by_author); - }, - store_commits(total, by_author) { - this.add(total, 'commits', 1); - return this.add(by_author, 'commits', 1); - }, - add(collection, field, value) { - if (collection[field] == null) { - collection[field] = 0; - } - return (collection[field] += value); - }, - store_additions(entry, total, by_author) { - if (entry.additions == null) { - entry.additions = 0; - } - this.add(total, 'additions', entry.additions); - return this.add(by_author, 'additions', entry.additions); - }, - store_deletions(entry, total, by_author) { - if (entry.deletions == null) { - entry.deletions = 0; - } - this.add(total, 'deletions', entry.deletions); - return this.add(by_author, 'deletions', entry.deletions); - }, - get_total_data(parsed_log, field) { - var log, total_data; - log = parsed_log.total; - total_data = this.pick_field(log, field); - return _.sortBy(total_data, d => d.date); - }, - pick_field(log, field) { - var total_data; - total_data = []; - _.each(log, d => total_data.push(_.pick(d, [field, 'date']))); - return total_data; - }, - get_author_data(parsed_log, field, date_range) { - var author_data, log; - if (date_range == null) { - date_range = null; - } - log = parsed_log.by_author; - author_data = []; - _.each( - log, - (function(_this) { - return function(log_entry) { - var parsed_log_entry; - parsed_log_entry = _this.parse_log_entry(log_entry, field, date_range); - if (!_.isEmpty(parsed_log_entry.dates)) { - return author_data.push(parsed_log_entry); - } - }; - })(this), - ); - return _.sortBy(author_data, d => d[field]).reverse(); - }, - parse_log_entry(log_entry, field, date_range) { - var parsed_entry; - parsed_entry = {}; - - parsed_entry.author_name = log_entry.author_name; - parsed_entry.author_email = log_entry.author_email; - parsed_entry.dates = {}; - - parsed_entry.commits = 0; - parsed_entry.additions = 0; - parsed_entry.deletions = 0; - - _.each( - _.omit(log_entry, 'author_name', 'author_email'), - (function(_this) { - return function(value) { - if (_this.in_range(value.date, date_range)) { - parsed_entry.dates[value.date] = value[field]; - parsed_entry.commits += value.commits; - parsed_entry.additions += value.additions; - return (parsed_entry.deletions += value.deletions); - } - }; - })(this), - ); - return parsed_entry; - }, - in_range(date, date_range) { - var ref; - if (date_range === null || (date_range[0] <= (ref = new Date(date)) && ref <= date_range[1])) { - return true; - } else { - return false; - } - }, -}; diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 196798a9076..190d0806c28 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,24 +1,9 @@ -import initGkeDropdowns from '~/create_cluster/gke_cluster'; -import initGkeNamespace from '~/projects/gke_cluster_namespace'; -import PersistentUserCallout from '../../persistent_user_callout'; import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; +import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - const { page } = document.body.dataset; - const newClusterViews = [ - 'projects:clusters:new', - 'projects:clusters:create_gcp', - 'projects:clusters:create_user', - ]; - - if (newClusterViews.indexOf(page) > -1) { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); - - initGkeDropdowns(); - initGkeNamespace(); - } + initCreateCluster(document, gon); new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index fa1de1f13cb..16034313af2 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -5,6 +5,7 @@ import { handleLocationHash } from '~/lib/utils/common_utils'; import howToMerge from '~/how_to_merge'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; +import initSourcegraph from '~/sourcegraph'; import initWidget from '../../../vue_merge_request_widget'; export default function() { @@ -19,4 +20,5 @@ export default function() { handleLocationHash(); howToMerge(); initWidget(); + initSourcegraph(); } diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js index 43417fa9702..5f2014f1631 100644 --- a/app/assets/javascripts/pages/projects/network/network.js +++ b/app/assets/javascripts/pages/projects/network/network.js @@ -1,22 +1,19 @@ -/* eslint-disable func-names, no-var */ - import $ from 'jquery'; import BranchGraph from '../../../network/branch_graph'; -export default (function() { - function Network(opts) { - var vph; - $('#filter_ref').click(function() { - return $(this) - .closest('form') - .submit(); - }); - this.branch_graph = new BranchGraph($('.network-graph'), opts); - vph = $(window).height() - 250; - $('.network-graph').css({ - height: `${vph}px`, - }); +const vph = $(window).height() - 250; + +export default class Network { + constructor(opts) { + this.opts = opts; + this.filter_ref = $('#filter_ref'); + this.network_graph = $('.network-graph'); + this.filter_ref.click(() => this.submit()); + this.branch_graph = new BranchGraph(this.network_graph, this.opts); + this.network_graph.css({ height: `${vph}px` }); } - return Network; -})(); + submit() { + return this.filter_ref.closest('form').submit(); + } +} diff --git a/app/assets/javascripts/pages/projects/pages_domains/form.js b/app/assets/javascripts/pages/projects/pages_domains/form.js index cef8e92610c..ae5368179b1 100644 --- a/app/assets/javascripts/pages/projects/pages_domains/form.js +++ b/app/assets/javascripts/pages/projects/pages_domains/form.js @@ -1,17 +1,23 @@ import setupToggleButtons from '~/toggle_buttons'; +function updateVisibility(selector, isVisible) { + Array.from(document.querySelectorAll(selector)).forEach(el => { + if (isVisible) { + el.classList.remove('d-none'); + } else { + el.classList.add('d-none'); + } + }); +} + export default () => { const toggleContainer = document.querySelector('.js-auto-ssl-toggle-container'); if (toggleContainer) { const onToggleButtonClicked = isAutoSslEnabled => { - Array.from(document.querySelectorAll('.js-shown-unless-auto-ssl')).forEach(el => { - if (isAutoSslEnabled) { - el.classList.add('d-none'); - } else { - el.classList.remove('d-none'); - } - }); + updateVisibility('.js-shown-unless-auto-ssl', !isAutoSslEnabled); + + updateVisibility('.js-shown-if-auto-ssl', isAutoSslEnabled); Array.from(document.querySelectorAll('.js-enabled-unless-auto-ssl')).forEach(el => { if (isAutoSslEnabled) { diff --git a/app/assets/javascripts/pages/projects/pipelines/test_report/index.js b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js new file mode 100644 index 00000000000..7e69983c2ed --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js @@ -0,0 +1,2 @@ +// /test_report is an alias for show +import '../show/index'; diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 435e8705803..01acfca158f 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -40,11 +40,6 @@ export default class Project { $label.text(activeText); }); - $('#modal-geo-info').data({ - cloneUrlSecondary: $this.attr('href'), - cloneUrlPrimary: $this.data('primaryUrl') || '', - }); - if (mobileCloneField) { mobileCloneField.dataset.clipboardText = url; } else { diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 98e19705976..a32c188909c 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -1,9 +1,11 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; import mountOperationSettings from '~/operation_settings'; +import mountGrafanaIntegration from '~/grafana_integration'; import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { mountErrorTrackingForm(); mountOperationSettings(); + mountGrafanaIntegration(); initSettingsPanels(); }); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 89cac42abae..4802cc2ad25 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -1,7 +1,6 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; @@ -13,7 +12,7 @@ import { } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; -const PAGE_FEATURE_ACCESS_LEVEL = __('Everyone'); +const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone'); export default { components: { @@ -207,7 +206,10 @@ export default { <template> <div> <div class="project-visibility-setting"> - <project-setting-row :help-path="visibilityHelpPath" label="Project visibility"> + <project-setting-row + :help-path="visibilityHelpPath" + :label="s__('ProjectSettings|Project visibility')" + > <div class="project-feature-controls"> <div class="select-wrapper"> <select @@ -220,17 +222,17 @@ export default { <option :value="visibilityOptions.PRIVATE" :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" - >{{ __('Private') }}</option + >{{ s__('ProjectSettings|Private') }}</option > <option :value="visibilityOptions.INTERNAL" :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)" - >{{ __('Internal') }}</option + >{{ s__('ProjectSettings|Internal') }}</option > <option :value="visibilityOptions.PUBLIC" :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" - >{{ __('Public') }}</option + >{{ s__('ProjectSettings|Public') }}</option > </select> <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> @@ -243,14 +245,15 @@ export default { type="hidden" name="project[request_access_enabled]" /> - <input v-model="requestAccessEnabled" type="checkbox" /> Allow users to request access + <input v-model="requestAccessEnabled" type="checkbox" /> + {{ s__('ProjectSettings|Allow users to request access') }} </label> </project-setting-row> </div> <div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings"> <project-setting-row - label="Issues" - help-text="Lightweight issue tracking system for this project" + :label="s__('ProjectSettings|Issues')" + :help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')" > <project-feature-setting v-model="issuesAccessLevel" @@ -258,7 +261,10 @@ export default { name="project[project_feature_attributes][issues_access_level]" /> </project-setting-row> - <project-setting-row label="Repository" help-text="View and edit files in this project"> + <project-setting-row + :label="s__('ProjectSettings|Repository')" + :help-text="s__('ProjectSettings|View and edit files in this project')" + > <project-feature-setting v-model="repositoryAccessLevel" :options="featureAccessLevelOptions" @@ -267,8 +273,8 @@ export default { </project-setting-row> <div class="project-feature-setting-group"> <project-setting-row - label="Merge requests" - help-text="Submit changes to be merged upstream" + :label="s__('ProjectSettings|Merge requests')" + :help-text="s__('ProjectSettings|Submit changes to be merged upstream')" > <project-feature-setting v-model="mergeRequestsAccessLevel" @@ -277,7 +283,10 @@ export default { name="project[project_feature_attributes][merge_requests_access_level]" /> </project-setting-row> - <project-setting-row label="Pipelines" help-text="Build, test, and deploy your changes"> + <project-setting-row + :label="s__('ProjectSettings|Pipelines')" + :help-text="s__('ProjectSettings|Build, test, and deploy your changes')" + > <project-feature-setting v-model="buildsAccessLevel" :options="repoFeatureAccessLevelOptions" @@ -288,11 +297,17 @@ export default { <project-setting-row v-if="registryAvailable" :help-path="registryHelpPath" - label="Container registry" - help-text="Every project can have its own space to store its Docker images" + :label="s__('ProjectSettings|Container registry')" + :help-text=" + s__('ProjectSettings|Every project can have its own space to store its Docker images') + " > <div v-if="showContainerRegistryPublicNote" class="text-muted"> - {{ __('Note: the container registry is always visible when a project is public') }} + {{ + s__( + 'ProjectSettings|Note: the container registry is always visible when a project is public', + ) + }} </div> <project-feature-toggle v-model="containerRegistryEnabled" @@ -303,8 +318,10 @@ export default { <project-setting-row v-if="lfsAvailable" :help-path="lfsHelpPath" - label="Git Large File Storage" - help-text="Manages large files such as audio, video, and graphics files" + :label="s__('ProjectSettings|Git Large File Storage')" + :help-text=" + s__('ProjectSettings|Manages large files such as audio, video, and graphics files') + " > <project-feature-toggle v-model="lfsEnabled" @@ -315,8 +332,10 @@ export default { <project-setting-row v-if="packagesAvailable" :help-path="packagesHelpPath" - label="Packages" - help-text="Every project can have its own space to store its packages" + :label="s__('ProjectSettings|Packages')" + :help-text=" + s__('ProjectSettings|Every project can have its own space to store its packages') + " > <project-feature-toggle v-model="packagesEnabled" @@ -325,7 +344,10 @@ export default { /> </project-setting-row> </div> - <project-setting-row label="Wiki" help-text="Pages for project documentation"> + <project-setting-row + :label="s__('ProjectSettings|Wiki')" + :help-text="s__('ProjectSettings|Pages for project documentation')" + > <project-feature-setting v-model="wikiAccessLevel" :options="featureAccessLevelOptions" @@ -333,8 +355,8 @@ export default { /> </project-setting-row> <project-setting-row - label="Snippets" - help-text="Share code pastes with others out of Git repository" + :label="s__('ProjectSettings|Snippets')" + :help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')" > <project-feature-setting v-model="snippetsAccessLevel" @@ -345,8 +367,10 @@ export default { <project-setting-row v-if="pagesAvailable && pagesAccessControlEnabled" :help-path="pagesHelpPath" - label="Pages access control" - help-text="Access control for the project's static website" + :label="s__('ProjectSettings|Pages')" + :help-text=" + s__('ProjectSettings|With GitLab Pages you can host your static websites on GitLab') + " > <project-feature-setting v-model="pagesAccessLevel" @@ -358,10 +382,13 @@ export default { <project-setting-row v-if="canDisableEmails" class="mb-3"> <label class="js-emails-disabled"> <input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" /> - <input v-model="emailsDisabled" type="checkbox" /> {{ __('Disable email notifications') }} + <input v-model="emailsDisabled" type="checkbox" /> + {{ s__('ProjectSettings|Disable email notifications') }} </label> <span class="form-text text-muted">{{ - __('This setting will override user notification preferences for all project members.') + s__( + 'ProjectSettings|This setting will override user notification preferences for all project members.', + ) }}</span> </project-setting-row> </div> diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 6aa41d0825b..370f3c6e7a2 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -48,7 +48,7 @@ document.addEventListener('DOMContentLoaded', () => { leaveByUrl('project'); if (document.getElementById('js-tree-list')) { - import('~/repository') + import('ee_else_ce/repository') .then(m => m.default()) .catch(e => { throw e; diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 7b90a3a4f6e..16d71379e31 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', () => { GpgBadges.fetch(); if (document.getElementById('js-tree-list')) { - import('~/repository') + import('ee_else_ce/repository') .then(m => m.default()) .catch(e => { throw e; diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 55bc93a2b13..66ee2d9303f 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -5,6 +5,7 @@ import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import SigninTabsMemoizer from './signin_tabs_memoizer'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; +import Tracking from '~/tracking'; document.addEventListener('DOMContentLoaded', () => { new UsernameValidator(); // eslint-disable-line no-new @@ -20,3 +21,16 @@ document.addEventListener('DOMContentLoaded', () => { // redirected to sign-in after attempting to access a protected URL that included a fragment. preserveUrlFragment(window.location.hash); }); + +export default function trackData() { + if (gon.tracking_data) { + const tab = document.querySelector(".new-session-tabs a[href='#register-pane']"); + const { category, action, ...data } = gon.tracking_data; + + tab.addEventListener('click', () => { + Tracking.event(category, action, data); + }); + } +} + +trackData(); diff --git a/app/assets/javascripts/performance_bar/components/add_request.vue b/app/assets/javascripts/performance_bar/components/add_request.vue new file mode 100644 index 00000000000..54bca8a1b67 --- /dev/null +++ b/app/assets/javascripts/performance_bar/components/add_request.vue @@ -0,0 +1,48 @@ +import { __ } from '~/locale'; + +<script> +export default { + data() { + return { + inputEnabled: false, + urlOrRequestId: '', + }; + }, + methods: { + toggleInput() { + this.inputEnabled = !this.inputEnabled; + }, + addRequest() { + this.$emit('add-request', this.urlOrRequestId); + this.clearForm(); + }, + clearForm() { + this.urlOrRequestId = ''; + this.toggleInput(); + }, + }, +}; +</script> +<template> + <div id="peek-view-add-request" class="view"> + <form class="form-inline" @submit.prevent> + <button + class="btn-blank btn-link bold" + type="button" + :title="__(`Add request manually`)" + @click="toggleInput" + > + + + </button> + <input + v-if="inputEnabled" + v-model="urlOrRequestId" + type="text" + :placeholder="__(`URL or request ID`)" + class="form-control form-control-sm d-inline-block ml-1" + @keyup.enter="addRequest" + @keyup.esc="clearForm" + /> + </form> + </div> +</template> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 3b07eba02b7..8ce653bf1fb 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,12 +1,14 @@ <script> import { glEmojiTag } from '~/emoji'; +import AddRequest from './add_request.vue'; import DetailedMetric from './detailed_metric.vue'; import RequestSelector from './request_selector.vue'; import { s__ } from '~/locale'; export default { components: { + AddRequest, DetailedMetric, RequestSelector, }, @@ -118,6 +120,7 @@ export default { > <a :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|trace') }}</a> </div> + <add-request v-on="$listeners" /> <request-selector v-if="currentRequest" :current-request="currentRequest" diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 1ae9487f391..735c9d804ee 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; + import PerformanceBarService from './services/performance_bar_service'; import PerformanceBarStore from './stores/performance_bar_store'; @@ -32,6 +34,15 @@ export default ({ container }) => PerformanceBarService.removeInterceptor(this.interceptor); }, methods: { + addRequestManually(urlOrRequestId) { + if (urlOrRequestId.startsWith('https://') || urlOrRequestId.startsWith('http://')) { + // We don't need to do anything with the response, we just + // want to trace the request. + axios.get(urlOrRequestId); + } else { + this.loadRequestDetails(urlOrRequestId, urlOrRequestId); + } + }, loadRequestDetails(requestId, requestUrl) { if (!this.store.canTrackRequest(requestUrl)) { return; @@ -58,6 +69,9 @@ export default ({ container }) => peekUrl: this.peekUrl, profileUrl: this.profileUrl, }, + on: { + 'add-request': this.addRequestManually, + }, }); }, }); diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 3c85bb61ce8..fd59a580961 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -88,7 +88,7 @@ export default { :title="tooltipText" :class="cssClass" :disabled="isDisabled" - class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper" + class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper d-flex align-items-center justify-content-center" @click="onClickAction" > <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index 5275de3bc8b..afb8439511f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -265,7 +265,11 @@ export default { <div class="table-section section-10 commit-link"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div> <div class="table-mobile-content"> - <ci-badge :status="pipelineStatus" :show-text="!isChildView" /> + <ci-badge + :status="pipelineStatus" + :show-text="!isChildView" + data-qa-selector="pipeline_commit_status" + /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue new file mode 100644 index 00000000000..388b300b39d --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -0,0 +1,81 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import TestSuiteTable from './test_suite_table.vue'; +import TestSummary from './test_summary.vue'; +import TestSummaryTable from './test_summary_table.vue'; +import store from '~/pipelines/stores/test_reports'; + +export default { + name: 'TestReports', + components: { + GlLoadingIcon, + TestSuiteTable, + TestSummary, + TestSummaryTable, + }, + store, + computed: { + ...mapState(['isLoading', 'selectedSuite', 'testReports']), + showSuite() { + return this.selectedSuite.total_count > 0; + }, + showTests() { + return this.testReports.total_count > 0; + }, + }, + methods: { + ...mapActions(['setSelectedSuite', 'removeSelectedSuite']), + summaryBackClick() { + this.removeSelectedSuite(); + }, + summaryTableRowClick(suite) { + this.setSelectedSuite(suite); + }, + beforeEnterTransition() { + document.documentElement.style.overflowX = 'hidden'; + }, + afterLeaveTransition() { + document.documentElement.style.overflowX = ''; + }, + }, +}; +</script> + +<template> + <div v-if="isLoading"> + <gl-loading-icon size="lg" class="prepend-top-default js-loading-spinner" /> + </div> + + <div + v-else-if="!isLoading && showTests" + ref="container" + class="tests-detail position-relative js-tests-detail" + > + <transition + name="slide" + @before-enter="beforeEnterTransition" + @after-leave="afterLeaveTransition" + > + <div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element"> + <test-summary :report="selectedSuite" show-back @on-back-click="summaryBackClick" /> + + <test-suite-table /> + </div> + + <div v-else key="summary" class="w-100 position-absolute slide-enter-from-element"> + <test-summary :report="testReports" /> + + <test-summary-table @row-click="summaryTableRowClick" /> + </div> + </transition> + </div> + + <div v-else> + <div class="row prepend-top-default"> + <div class="col-12"> + <p class="js-no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue new file mode 100644 index 00000000000..28b2c706320 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -0,0 +1,108 @@ +<script> +import { mapGetters } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import store from '~/pipelines/stores/test_reports'; +import { __ } from '~/locale'; + +export default { + name: 'TestsSuiteTable', + components: { + Icon, + }, + store, + props: { + heading: { + type: String, + required: false, + default: __('Tests'), + }, + }, + computed: { + ...mapGetters(['getSuiteTests']), + hasSuites() { + return this.getSuiteTests.length > 0; + }, + }, +}; +</script> + +<template> + <div> + <div class="row prepend-top-default"> + <div class="col-12"> + <h4>{{ heading }}</h4> + </div> + </div> + + <div v-if="hasSuites" class="test-reports-table js-test-cases-table"> + <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray"> + <div role="rowheader" class="table-section section-20"> + {{ __('Class') }} + </div> + <div role="rowheader" class="table-section section-20"> + {{ __('Name') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Status') }} + </div> + <div role="rowheader" class="table-section flex-grow-1"> + {{ __('Trace'), }} + </div> + <div role="rowheader" class="table-section section-10 text-right"> + {{ __('Duration') }} + </div> + </div> + + <div + v-for="(testCase, index) in getSuiteTests" + :key="index" + class="gl-responsive-table-row rounded align-items-md-start mt-sm-3 js-case-row" + > + <div class="table-section section-20 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div> + <div class="table-mobile-content pr-md-1">{{ testCase.classname }}</div> + </div> + + <div class="table-section section-20 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> + <div class="table-mobile-content">{{ testCase.name }}</div> + </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> + <div class="table-mobile-content text-center"> + <div + class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" + :class="`ci-status-icon-${testCase.status}`" + > + <icon :size="24" :name="testCase.icon" /> + </div> + </div> + </div> + + <div class="table-section flex-grow-1"> + <div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div> + <div class="table-mobile-content"> + <pre + v-if="testCase.system_output" + class="build-trace build-trace-rounded text-left" + ><code class="bash p-0">{{testCase.system_output}}</code></pre> + </div> + </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content text-right"> + {{ testCase.formattedTime }} + </div> + </div> + </div> + </div> + + <div v-else> + <p class="js-no-test-cases">{{ s__('TestReports|There are no test cases to display.') }}</p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue new file mode 100644 index 00000000000..dce8b020d6f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -0,0 +1,116 @@ +<script> +import { GlButton, GlLink, GlProgressBar } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'TestSummary', + components: { + GlButton, + GlLink, + GlProgressBar, + Icon, + }, + props: { + report: { + type: Object, + required: true, + }, + showBack: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + heading() { + return this.report.name || __('Summary'); + }, + successPercentage() { + return Math.round((this.report.success_count / this.report.total_count) * 100) || 0; + }, + formattedDuration() { + return formatTime(secondsToMilliseconds(this.report.total_time)); + }, + progressBarVariant() { + if (this.successPercentage < 33) { + return 'danger'; + } + + if (this.successPercentage >= 33 && this.successPercentage < 66) { + return 'warning'; + } + + if (this.successPercentage >= 66 && this.successPercentage < 90) { + return 'primary'; + } + + return 'success'; + }, + }, + methods: { + onBackClick() { + this.$emit('on-back-click'); + }, + }, +}; +</script> + +<template> + <div> + <div class="row"> + <div class="col-12 d-flex prepend-top-8 align-items-center"> + <gl-button + v-if="showBack" + size="sm" + class="append-right-default js-back-button" + @click="onBackClick" + > + <icon name="angle-left" /> + </gl-button> + + <h4>{{ heading }}</h4> + </div> + </div> + + <div class="row mt-2"> + <div class="col-4 col-md"> + <span class="js-total-tests">{{ + sprintf(s__('TestReports|%{count} jobs'), { count: report.total_count }) + }}</span> + </div> + + <div class="col-4 col-md text-center text-md-center"> + <span class="js-failed-tests">{{ + sprintf(s__('TestReports|%{count} failures'), { count: report.failed_count }) + }}</span> + </div> + + <div class="col-4 col-md text-right text-md-center"> + <span class="js-errored-tests">{{ + sprintf(s__('TestReports|%{count} errors'), { count: report.error_count }) + }}</span> + </div> + + <div class="col-6 mt-3 col-md mt-md-0 text-md-center"> + <span class="js-success-rate">{{ + sprintf(s__('TestReports|%{rate}%{sign} success rate'), { + rate: successPercentage, + sign: '%', + }) + }}</span> + </div> + + <div class="col-6 mt-3 col-md mt-md-0 text-right"> + <span class="js-duration">{{ formattedDuration }}</span> + </div> + </div> + + <div class="row mt-3"> + <div class="col-12"> + <gl-progress-bar :value="successPercentage" :variant="progressBarVariant" height="10px" /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue new file mode 100644 index 00000000000..96177512e35 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -0,0 +1,129 @@ +<script> +import { mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import store from '~/pipelines/stores/test_reports'; + +export default { + name: 'TestsSummaryTable', + store, + props: { + heading: { + type: String, + required: false, + default: s__('TestReports|Test suites'), + }, + }, + computed: { + ...mapGetters(['getTestSuites']), + hasSuites() { + return this.getTestSuites.length > 0; + }, + }, + methods: { + tableRowClick(suite) { + this.$emit('row-click', suite); + }, + }, +}; +</script> + +<template> + <div> + <div class="row prepend-top-default"> + <div class="col-12"> + <h4>{{ heading }}</h4> + </div> + </div> + + <div v-if="hasSuites" class="test-reports-table js-test-suites-table"> + <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold"> + <div role="rowheader" class="table-section section-25 pl-3"> + {{ __('Suite') }} + </div> + <div role="rowheader" class="table-section section-25"> + {{ __('Duration') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Failed') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Errors'), }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Skipped'), }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Passed'), }} + </div> + <div role="rowheader" class="table-section section-10 pr-3 text-right"> + {{ __('Total') }} + </div> + </div> + + <div + v-for="(testSuite, index) in getTestSuites" + :key="index" + role="row" + class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row" + @click="tableRowClick(testSuite)" + > + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Suite') }} + </div> + <div class="table-mobile-content underline cgray pl-3"> + {{ testSuite.name }} + </div> + </div> + + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content text-md-left"> + {{ testSuite.formattedTime }} + </div> + </div> + + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Failed') }} + </div> + <div class="table-mobile-content">{{ testSuite.failed_count }}</div> + </div> + + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Errors') }} + </div> + <div class="table-mobile-content">{{ testSuite.error_count }}</div> + </div> + + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Skipped') }} + </div> + <div class="table-mobile-content">{{ testSuite.skipped_count }}</div> + </div> + + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Passed') }} + </div> + <div class="table-mobile-content">{{ testSuite.success_count }}</div> + </div> + + <div class="table-section section-10 text-right pr-md-3"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Total') }} + </div> + <div class="table-mobile-content">{{ testSuite.total_count }}</div> + </div> + </div> + </div> + + <div v-else> + <p class="js-no-tests-suites">{{ s__('TestReports|There are no test suites to show.') }}</p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index d27829db50c..c9655d18a04 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -1,3 +1,9 @@ export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const PIPELINES_TABLE = 'PIPELINES_TABLE'; export const LAYOUT_CHANGE_DELAY = 300; + +export const TestStatus = { + FAILED: 'failed', + SKIPPED: 'skipped', + SUCCESS: 'success', +}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index b6f8716d37d..d8dbc3c2454 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -7,6 +7,8 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; +import TestReports from './components/test_reports/test_reports.vue'; +import testReportsStore from './stores/test_reports'; Vue.use(Translate); @@ -17,7 +19,7 @@ export default () => { mediator.fetchPipeline(); - // eslint-disable-next-line + // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-graph-vue', components: { @@ -47,7 +49,7 @@ export default () => { }, }); - // eslint-disable-next-line + // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-header-vue', components: { @@ -81,4 +83,23 @@ export default () => { }); }, }); + + const testReportsEnabled = + window.gon && window.gon.features && window.gon.features.junitPipelineView; + + if (testReportsEnabled) { + testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint); + testReportsStore.dispatch('fetchReports'); + + // eslint-disable-next-line no-new + new Vue({ + el: '#js-pipeline-tests-detail', + components: { + TestReports, + }, + render(createElement) { + return createElement('test-reports'); + }, + }); + } }; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js new file mode 100644 index 00000000000..71d875c1a83 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -0,0 +1,30 @@ +import axios from '~/lib/utils/axios_utils'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; + +export const setEndpoint = ({ commit }, data) => commit(types.SET_ENDPOINT, data); + +export const fetchReports = ({ state, commit, dispatch }) => { + dispatch('toggleLoading'); + + return axios + .get(state.endpoint) + .then(response => { + const { data } = response; + commit(types.SET_REPORTS, data); + }) + .catch(() => { + createFlash(s__('TestReports|There was an error fetching the test reports.')); + }) + .finally(() => { + dispatch('toggleLoading'); + }); +}; + +export const setSelectedSuite = ({ commit }, data) => commit(types.SET_SELECTED_SUITE, data); +export const removeSelectedSuite = ({ commit }) => commit(types.SET_SELECTED_SUITE, {}); +export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js new file mode 100644 index 00000000000..788c1d32987 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -0,0 +1,23 @@ +import { addIconStatus, formattedTime, sortTestCases } from './utils'; + +export const getTestSuites = state => { + const { test_suites: testSuites = [] } = state.testReports; + + return testSuites.map(suite => ({ + ...suite, + formattedTime: formattedTime(suite.total_time), + })); +}; + +export const getSuiteTests = state => { + const { selectedSuite } = state; + + if (selectedSuite.test_cases) { + return selectedSuite.test_cases.sort(sortTestCases).map(addIconStatus); + } + + return []; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js new file mode 100644 index 00000000000..318dff5bcb2 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + actions, + getters, + mutations, + state, +}); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js new file mode 100644 index 00000000000..832e45cf7a1 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_ENDPOINT = 'SET_ENDPOINT'; +export const SET_REPORTS = 'SET_REPORTS'; +export const SET_SELECTED_SUITE = 'SET_SELECTED_SUITE'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js new file mode 100644 index 00000000000..349e6ec0469 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -0,0 +1,19 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ENDPOINT](state, endpoint) { + Object.assign(state, { endpoint }); + }, + + [types.SET_REPORTS](state, testReports) { + Object.assign(state, { testReports }); + }, + + [types.SET_SELECTED_SUITE](state, selectedSuite) { + Object.assign(state, { selectedSuite }); + }, + + [types.TOGGLE_LOADING](state) { + Object.assign(state, { isLoading: !state.isLoading }); + }, +}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js new file mode 100644 index 00000000000..80a0c2a46a0 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -0,0 +1,6 @@ +export default () => ({ + endpoint: '', + testReports: {}, + selectedSuite: {}, + isLoading: false, +}); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js new file mode 100644 index 00000000000..95466587d6b --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -0,0 +1,36 @@ +import { TestStatus } from '~/pipelines/constants'; +import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; + +function iconForTestStatus(status) { + switch (status) { + case 'success': + return 'status_success_borderless'; + case 'failed': + return 'status_failed_borderless'; + default: + return 'status_skipped_borderless'; + } +} + +export const formattedTime = timeInSeconds => formatTime(secondsToMilliseconds(timeInSeconds)); + +export const addIconStatus = testCase => ({ + ...testCase, + icon: iconForTestStatus(testCase.status), + formattedTime: formattedTime(testCase.execution_time), +}); + +export const sortTestCases = (a, b) => { + if (a.status === b.status) { + return 0; + } + + switch (b.status) { + case TestStatus.SUCCESS: + return -1; + case TestStatus.FAILED: + return 1; + default: + return 0; + } +}; diff --git a/app/assets/javascripts/privacy_policy_update_callout.js b/app/assets/javascripts/privacy_policy_update_callout.js deleted file mode 100644 index 97f41deb30f..00000000000 --- a/app/assets/javascripts/privacy_policy_update_callout.js +++ /dev/null @@ -1,8 +0,0 @@ -import PersistentUserCallout from '~/persistent_user_callout'; - -function initPrivacyPolicyUpdateCallout() { - const callout = document.querySelector('.js-privacy-policy-update'); - PersistentUserCallout.factory(callout); -} - -export default initPrivacyPolicyUpdateCallout; diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index 44bc2d9f5f8..880e1a88975 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-escape, no-var, no-underscore-dangle, func-names, no-return-assign, one-var, consistent-return, class-methods-use-this */ +/* eslint-disable no-useless-escape, no-underscore-dangle, func-names, no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; import 'cropper'; @@ -59,8 +59,7 @@ import _ from 'underscore'; } bindEvents() { - var _this; - _this = this; + const _this = this; this.fileInput.on('change', function(e) { _this.onFileInputChange(e, this); this.value = null; @@ -70,8 +69,7 @@ import _ from 'underscore'; this.modalCrop.on('hidden.bs.modal', this.onModalHide); this.uploadImageBtn.on('click', this.onUploadImageBtnClick); this.cropActionsBtn.on('click', function() { - var btn; - btn = this; + const btn = this; return _this.onActionBtnClick(btn); }); return (this.croppedImageBlob = null); @@ -82,8 +80,7 @@ import _ from 'underscore'; } onModalShow() { - var _this; - _this = this; + const _this = this; return this.modalCropImg.cropper({ viewMode: 1, center: false, @@ -128,8 +125,7 @@ import _ from 'underscore'; } onActionBtnClick(btn) { - var data; - data = $(btn).data(); + const data = $(btn).data(); if (this.modalCropImg.data('cropper') && data.method) { return this.modalCropImg.cropper(data.method, data.option); } @@ -140,9 +136,8 @@ import _ from 'underscore'; } readFile(input) { - var _this, reader; - _this = this; - reader = new FileReader(); + const _this = this; + const reader = new FileReader(); reader.onload = () => { _this.modalCropImg.attr('src', reader.result); return _this.modalCrop.modal('show'); @@ -151,9 +146,10 @@ import _ from 'underscore'; } dataURLtoBlob(dataURL) { - var array, binary, i, len; - binary = atob(dataURL.split(',')[1]); - array = []; + let i = 0; + let len = 0; + const binary = atob(dataURL.split(',')[1]); + const array = []; for (i = 0, len = binary.length; i < len; i += 1) { array.push(binary.charCodeAt(i)); @@ -164,9 +160,8 @@ import _ from 'underscore'; } setPreview() { - var filename; + const filename = this.fileInput.val().replace(FILENAMEREGEX, ''); this.previewImage.attr('src', this.dataURL); - filename = this.fileInput.val().replace(FILENAMEREGEX, ''); return this.filename.text(filename); } diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 2c375b39c1f..031c54d2336 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -1,16 +1,20 @@ -/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, no-return-assign */ +/* eslint-disable func-names, consistent-return, no-return-assign */ import $ from 'jquery'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import { __ } from '~/locale'; +import sanitize from 'sanitize-html'; // highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> ) const highlighter = function(element, text, matches) { - var j, lastIndex, len, matchIndex, matchedChars, unmatched; - lastIndex = 0; - matchedChars = []; + let j = 0; + let len = 0; + let lastIndex = 0; + let matchedChars = []; + let matchIndex = matches[j]; + let unmatched = text.substring(lastIndex, matchIndex); for (j = 0, len = matches.length; j < len; j += 1) { matchIndex = matches[j]; unmatched = text.substring(lastIndex, matchIndex); @@ -54,10 +58,10 @@ export default class ProjectFindFile { 'keyup', (function(_this) { return function(event) { - var oldValue, ref, target, value; - target = $(event.target); - value = target.val(); - oldValue = (ref = target.data('oldValue')) != null ? ref : ''; + const target = $(event.target); + const value = target.val(); + const ref = target.data('oldValue'); + const oldValue = ref != null ? ref : ''; if (value !== oldValue) { target.data('oldValue', value); _this.findFile(); @@ -73,9 +77,8 @@ export default class ProjectFindFile { } findFile() { - var result, searchText; - searchText = this.inputElement.val(); - result = + const searchText = sanitize(this.inputElement.val()); + const result = searchText.length > 0 ? fuzzaldrinPlus.filter(this.filePaths, searchText) : this.filePaths; return this.renderList(result, searchText); // find file @@ -100,20 +103,21 @@ export default class ProjectFindFile { // render result renderList(filePaths, searchText) { - var blobItemUrl, filePath, html, i, len, matches, results; + let i = 0; + let len = 0; + let matches = []; + const results = []; this.element.find('.tree-table > tbody').empty(); - results = []; - for (i = 0, len = filePaths.length; i < len; i += 1) { - filePath = filePaths[i]; + const filePath = filePaths[i]; if (i === 20) { break; } if (searchText) { matches = fuzzaldrinPlus.match(filePath, searchText); } - blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`; - html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl); + const blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`; + const html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl); results.push(this.element.find('.tree-table > tbody').append(html)); } @@ -124,8 +128,7 @@ export default class ProjectFindFile { // make tbody row html static makeHtml(filePath, matches, blobItemUrl) { - var $tr; - $tr = $( + const $tr = $( "<tr class='tree-item'><td class='tree-item-file-name link-container'><a><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'></span></a></td></tr>", ); if (matches) { @@ -140,9 +143,9 @@ export default class ProjectFindFile { } selectRow(type) { - var next, rows, selectedRow; - rows = this.element.find('.files-slider tr.tree-item'); - selectedRow = this.element.find('.files-slider tr.tree-item.selected'); + const rows = this.element.find('.files-slider tr.tree-item'); + let selectedRow = this.element.find('.files-slider tr.tree-item.selected'); + let next = selectedRow.prev(); if (rows && rows.length > 0) { if (selectedRow && selectedRow.length > 0) { if (type === 'UP') { @@ -174,7 +177,7 @@ export default class ProjectFindFile { } goToBlob() { - var $link = this.element.find('.tree-item.selected .tree-item-file-name a'); + const $link = this.element.find('.tree-item.selected .tree-item-file-name a'); if ($link.length) { $link.get(0).click(); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 0fbb7e5ca42..66ce1ab5659 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, no-else-return */ +/* eslint-disable func-names, no-else-return */ import $ from 'jquery'; import Api from './api'; @@ -7,9 +7,11 @@ import { s__ } from './locale'; const projectSelect = () => { $('.ajax-project-select').each(function(i, select) { - var placeholder; + let placeholder; const simpleFilter = $(select).data('simpleFilter') || false; + const isInstantiated = $(select).data('select2'); this.groupId = $(select).data('groupId'); + this.userId = $(select).data('userId'); this.includeGroups = $(select).data('includeGroups'); this.allProjects = $(select).data('allProjects') || false; this.orderBy = $(select).data('orderBy') || 'id'; @@ -28,55 +30,62 @@ const projectSelect = () => { $(select).select2({ placeholder, minimumInputLength: 0, - query: (function(_this) { - return function(query) { - var finalCallback, projectsCallback; - finalCallback = function(projects) { - var data; - data = { - results: projects, - }; - return query.callback(data); + query: query => { + let projectsCallback; + const finalCallback = function(projects) { + const data = { + results: projects, }; - if (_this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { - var data; - data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(query.term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (_this.groupId) { - return Api.groupProjects( - _this.groupId, - query.term, - { - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - with_shared: _this.withShared, - include_subgroups: _this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } else { - return Api.projects( - query.term, - { - order_by: _this.orderBy, - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - membership: !_this.allProjects, - }, - projectsCallback, - ); - } + return query.callback(data); }; - })(this), + if (this.includeGroups) { + projectsCallback = function(projects) { + const groupsCallback = function(groups) { + const data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, {}, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (this.groupId) { + return Api.groupProjects( + this.groupId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else if (this.userId) { + return Api.userProjects( + this.userId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else { + return Api.projects( + query.term, + { + order_by: this.orderBy, + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + membership: !this.allProjects, + }, + projectsCallback, + ); + } + }, id(project) { if (simpleFilter) return project.id; return JSON.stringify({ @@ -96,7 +105,7 @@ const projectSelect = () => { dropdownCssClass: 'ajax-project-dropdown', }); - if (simpleFilter) return select; + if (isInstantiated || simpleFilter) return select; return new ProjectSelectComboButton(select); }); }; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 9066844f687..2429da9c061 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -182,6 +182,10 @@ const bindEvents = () => { text: s__('ProjectTemplates|Netlify/Hexo'), icon: '.template-option .icon-netlify', }, + serverless_framework: { + text: s__('ProjectTemplates|Serverless Framework/JS'), + icon: '.template-option .icon-serverless_framework', + }, }; const selectedTemplate = templates[value]; diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 95f8270b5d0..5a6f9370564 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -8,12 +8,13 @@ import { GlModalDirective, GlEmptyState, } from '@gitlab/ui'; -import createFlash from '../../flash'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import Icon from '../../vue_shared/components/icon.vue'; +import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import TableRegistry from './table_registry.vue'; -import { errorMessages, errorMessagesTypes } from '../constants'; -import { __ } from '../../locale'; +import { DELETE_REPO_ERROR_MESSAGE } from '../constants'; +import { __ } from '~/locale'; export default { name: 'CollapsibeContainerRegisty', @@ -30,6 +31,7 @@ export default { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, + mixins: [Tracking.mixin({})], props: { repo: { type: Object, @@ -40,6 +42,10 @@ export default { return { isOpen: false, modalId: `confirm-repo-deletion-modal-${this.repo.id}`, + tracking: { + category: document.body.dataset.page, + label: 'registry_repository_delete', + }, }; }, computed: { @@ -61,15 +67,13 @@ export default { } }, handleDeleteRepository() { + this.track('confirm_delete', {}); return this.deleteItem(this.repo) .then(() => { createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); this.fetchRepos(); }) - .catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); - }, - showError(message) { - createFlash(errorMessages[message]); + .catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE)); }, }, }; @@ -97,10 +101,9 @@ export default { v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" - data-track-event="click_button" - data-track-label="registry_repository_delete" class="js-remove-repo btn-inverted" variant="danger" + @click="track('click_button', {})" > <icon name="remove" /> </gl-button> @@ -124,7 +127,13 @@ export default { class="mx-auto my-0" /> </div> - <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository"> + <gl-modal + ref="deleteModal" + :modal-id="modalId" + ok-variant="danger" + @ok="handleDeleteRepository" + @cancel="track('cancel_delete', {})" + > <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> <p v-html=" diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index 8470fbc2b59..caa5fd4ff4e 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,20 +1,15 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import { - GlButton, - GlFormCheckbox, - GlTooltipDirective, - GlModal, - GlModalDirective, -} from '@gitlab/ui'; -import { n__, s__, sprintf } from '../../locale'; -import createFlash from '../../flash'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; -import Icon from '../../vue_shared/components/icon.vue'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; -import { errorMessages, errorMessagesTypes } from '../constants'; -import { numberToHumanSize } from '../../lib/utils/number_utils'; +import { GlButton, GlFormCheckbox, GlTooltipDirective, GlModal } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { n__, s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { FETCH_REGISTRY_ERROR_MESSAGE, DELETE_REGISTRY_ERROR_MESSAGE } from '../constants'; export default { components: { @@ -27,7 +22,6 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - GlModal: GlModalDirective, }, mixins: [timeagoMixin], props: { @@ -65,12 +59,21 @@ export default { this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length, ); }, - }, - mounted() { - this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents); + isMultiDelete() { + return this.itemsToBeDeleted.length > 1; + }, + tracking() { + return { + property: this.repo.name, + label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + }; + }, }, methods: { ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']), + track(action) { + Tracking.event(document.body.dataset.page, action, this.tracking); + }, setModalDescription(itemIndex = -1) { if (itemIndex === -1) { this.modalDescription = sprintf( @@ -92,17 +95,11 @@ export default { formatSize(size) { return numberToHumanSize(size); }, - removeModalEvents() { - this.$refs.deleteModal.$refs.modal.$off('ok'); - }, deleteSingleItem(index) { this.setModalDescription(index); this.itemsToBeDeleted = [index]; - - this.$refs.deleteModal.$refs.modal.$once('ok', () => { - this.removeModalEvents(); - this.handleSingleDelete(this.repo.list[index]); - }); + this.track('click_button'); + this.$refs.deleteModal.show(); }, deleteMultipleItems() { this.itemsToBeDeleted = [...this.selectedItems]; @@ -111,17 +108,14 @@ export default { } else if (this.selectedItems.length > 1) { this.setModalDescription(); } - - this.$refs.deleteModal.$refs.modal.$once('ok', () => { - this.removeModalEvents(); - this.handleMultipleDelete(); - }); + this.track('click_button'); + this.$refs.deleteModal.show(); }, handleSingleDelete(itemToDelete) { this.itemsToBeDeleted = []; this.deleteItem(itemToDelete) .then(() => this.fetchList({ repo: this.repo })) - .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); }, handleMultipleDelete() { const { itemsToBeDeleted } = this; @@ -134,19 +128,16 @@ export default { items: itemsToBeDeleted.map(x => this.repo.list[x].tag), }) .then(() => this.fetchList({ repo: this.repo })) - .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); } else { - this.showError(errorMessagesTypes.DELETE_REGISTRY); + createFlash(DELETE_REGISTRY_ERROR_MESSAGE); } }, onPageChange(pageNumber) { this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => - this.showError(errorMessagesTypes.FETCH_REGISTRY), + createFlash(FETCH_REGISTRY_ERROR_MESSAGE), ); }, - showError(message) { - createFlash(errorMessages[message]); - }, onSelectAllChange() { if (this.selectAllChecked) { this.deselectAll(); @@ -179,6 +170,15 @@ export default { canDeleteRow(item) { return item && item.canDelete && !this.isDeleteDisabled; }, + onDeletionConfirmed() { + this.track('confirm_delete'); + if (this.isMultiDelete) { + this.handleMultipleDelete(); + } else { + const index = this.itemsToBeDeleted[0]; + this.handleSingleDelete(this.repo.list[index]); + } + }, }, }; </script> @@ -202,12 +202,10 @@ export default { <th> <gl-button v-if="canDeleteRepo" + ref="bulkDeleteButton" v-gl-tooltip - v-gl-modal="modalId" :disabled="!selectedItems || selectedItems.length === 0" - class="js-delete-registry float-right" - data-track-event="click_button" - data-track-label="bulk_registry_tag_delete" + class="float-right" variant="danger" :title="s__('ContainerRegistry|Remove selected tags')" :aria-label="s__('ContainerRegistry|Remove selected tags')" @@ -259,11 +257,8 @@ export default { <td class="content action-buttons"> <gl-button v-if="canDeleteRow(item)" - v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove tag')" :aria-label="s__('ContainerRegistry|Remove tag')" - data-track-event="click_button" - data-track-label="registry_tag_delete" variant="danger" class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon" @click="deleteSingleItem(index)" @@ -282,7 +277,13 @@ export default { class="js-registry-pagination" /> - <gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger"> + <gl-modal + ref="deleteModal" + :modal-id="modalId" + ok-variant="danger" + @ok="onDeletionConfirmed" + @cancel="track('cancel_delete')" + > <template v-slot:modal-title>{{ modalAction }}</template> <template v-slot:modal-ok>{{ modalAction }}</template> <p v-html="modalDescription"></p> diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/constants.js index 712b0fade3d..db798fb88ac 100644 --- a/app/assets/javascripts/registry/constants.js +++ b/app/assets/javascripts/registry/constants.js @@ -1,15 +1,8 @@ import { __ } from '../locale'; -export const errorMessagesTypes = { - FETCH_REGISTRY: 'FETCH_REGISTRY', - FETCH_REPOS: 'FETCH_REPOS', - DELETE_REPO: 'DELETE_REPO', - DELETE_REGISTRY: 'DELETE_REGISTRY', -}; - -export const errorMessages = { - [errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'), - [errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'), - [errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'), - [errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'), -}; +export const FETCH_REGISTRY_ERROR_MESSAGE = __( + 'Something went wrong while fetching the registry list.', +); +export const FETCH_REPOS_ERROR_MESSAGE = __('Something went wrong while fetching the projects.'); +export const DELETE_REPO_ERROR_MESSAGE = __('Something went wrong on our end.'); +export const DELETE_REGISTRY_ERROR_MESSAGE = __('Something went wrong on our end.'); diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index 2121f518a7a..6afba618486 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import * as types from './mutation_types'; -import { errorMessages, errorMessagesTypes } from '../constants'; +import { FETCH_REPOS_ERROR_MESSAGE, FETCH_REGISTRY_ERROR_MESSAGE } from '../constants'; export const fetchRepos = ({ commit, state }) => { commit(types.TOGGLE_MAIN_LOADING); @@ -14,7 +14,7 @@ export const fetchRepos = ({ commit, state }) => { }) .catch(() => { commit(types.TOGGLE_MAIN_LOADING); - createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]); + createFlash(FETCH_REPOS_ERROR_MESSAGE); }); }; @@ -30,7 +30,7 @@ export const fetchList = ({ commit }, { repo, page }) => { }) .catch(() => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]); + createFlash(FETCH_REGISTRY_ERROR_MESSAGE); }); }; diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index ea5925247d1..419de848883 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -1,33 +1,31 @@ import * as types from './mutation_types'; -import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; export default { [types.SET_MAIN_ENDPOINT](state, endpoint) { - Object.assign(state, { endpoint }); + state.endpoint = endpoint; }, [types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) { - Object.assign(state, { isDeleteDisabled }); + state.isDeleteDisabled = isDeleteDisabled; }, [types.SET_REPOS_LIST](state, list) { - Object.assign(state, { - repos: list.map(el => ({ - canDelete: Boolean(el.destroy_path), - destroyPath: el.destroy_path, - id: el.id, - isLoading: false, - list: [], - location: el.location, - name: el.path, - tagsPath: el.tags_path, - projectId: el.project_id, - })), - }); + state.repos = list.map(el => ({ + canDelete: Boolean(el.destroy_path), + destroyPath: el.destroy_path, + id: el.id, + isLoading: false, + list: [], + location: el.location, + name: el.path, + tagsPath: el.tags_path, + projectId: el.project_id, + })); }, [types.TOGGLE_MAIN_LOADING](state) { - Object.assign(state, { isLoading: !state.isLoading }); + state.isLoading = !state.isLoading; }, [types.SET_REGISTRY_LIST](state, { repo, resp, headers }) { diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/detail/components/app.vue index 54a441de886..073cfcd7694 100644 --- a/app/assets/javascripts/releases/detail/components/app.vue +++ b/app/assets/javascripts/releases/detail/components/app.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; +import _ from 'underscore'; import { __, sprintf } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; @@ -23,6 +24,7 @@ export default { 'markdownDocsPath', 'markdownPreviewPath', 'releasesPagePath', + 'updateReleaseApiDocsPath', ]), showForm() { return !this.isFetchingRelease && !this.fetchError; @@ -42,6 +44,20 @@ export default { tagName() { return this.$store.state.release.tagName; }, + tagNameHintText() { + return sprintf( + __( + 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}', + ), + { + linkStart: `<a href="${_.escape( + this.updateReleaseApiDocsPath, + )}" target="_blank" rel="noopener noreferrer">`, + linkEnd: '</a>', + }, + false, + ); + }, releaseTitle: { get() { return this.$store.state.release.name; @@ -77,22 +93,22 @@ export default { <div class="d-flex flex-column"> <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> <form v-if="showForm" @submit.prevent="updateRelease()"> - <div class="row"> - <gl-form-group class="col-md-6 col-lg-5 col-xl-4"> - <label for="git-ref">{{ __('Tag name') }}</label> - <gl-form-input - id="git-ref" - v-model="tagName" - type="text" - class="form-control" - aria-describedby="tag-name-help" - disabled - /> - <div id="tag-name-help" class="form-text text-muted"> - {{ __('Choose an existing tag, or create a new one') }} + <gl-form-group> + <div class="row"> + <div class="col-md-6 col-lg-5 col-xl-4"> + <label for="git-ref">{{ __('Tag name') }}</label> + <gl-form-input + id="git-ref" + v-model="tagName" + type="text" + class="form-control" + aria-describedby="tag-name-help" + disabled + /> </div> - </gl-form-group> - </div> + </div> + <div id="tag-name-help" class="form-text text-muted" v-html="tagNameHintText"></div> + </gl-form-group> <gl-form-group> <label for="release-title">{{ __('Release title') }}</label> <gl-form-input diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js index 3da971e6d90..0dab90a1ede 100644 --- a/app/assets/javascripts/releases/detail/index.js +++ b/app/assets/javascripts/releases/detail/index.js @@ -5,7 +5,7 @@ import createStore from './store'; export default () => { const el = document.getElementById('js-edit-release-page'); - const store = createStore(el.dataset); + const store = createStore(); store.dispatch('setInitialState', el.dataset); return new Vue({ diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/detail/store/state.js index ff98e2bed78..7e3d975f1ae 100644 --- a/app/assets/javascripts/releases/detail/store/state.js +++ b/app/assets/javascripts/releases/detail/store/state.js @@ -4,6 +4,7 @@ export default () => ({ releasesPagePath: null, markdownDocsPath: null, markdownPreviewPath: null, + updateReleaseApiDocsPath: null, release: null, diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue index 8d4b32e9dc0..2b6aa6aeff9 100644 --- a/app/assets/javascripts/releases/list/components/release_block.vue +++ b/app/assets/javascripts/releases/list/components/release_block.vue @@ -10,6 +10,7 @@ import { slugify } from '~/lib/utils/text_utility'; import { getLocationHash } from '~/lib/utils/url_utility'; import { scrollToElement } from '~/lib/utils/common_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ReleaseBlockFooter from './release_block_footer.vue'; export default { name: 'ReleaseBlock', @@ -19,6 +20,7 @@ export default { GlButton, Icon, UserAvatarLink, + ReleaseBlockFooter, }, directives: { GlTooltip: GlTooltipDirective, @@ -76,9 +78,12 @@ export default { }, shouldShowEditButton() { return Boolean( - this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit, + this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit_url, ); }, + shouldShowFooter() { + return this.glFeatures.releaseIssueSummary; + }, }, mounted() { const hash = getLocationHash(); @@ -108,7 +113,7 @@ export default { v-gl-tooltip class="btn btn-default js-edit-button ml-2" :title="__('Edit this release')" - :href="release._links.edit" + :href="release._links.edit_url" > <icon name="pencil" /> </gl-link> @@ -164,7 +169,7 @@ export default { by <user-avatar-link class="prepend-left-4" - :link-href="author.path" + :link-href="author.web_url" :img-src="author.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="author.username" @@ -216,5 +221,16 @@ export default { <div v-html="release.description_html"></div> </div> </div> + + <release-block-footer + v-if="shouldShowFooter" + class="card-footer" + :commit="release.commit" + :commit-path="release.commit_path" + :tag-name="release.tag_name" + :tag-path="release.tag_path" + :author="release.author" + :released-at="release.released_at" + /> </div> </template> diff --git a/app/assets/javascripts/releases/list/components/release_block_footer.vue b/app/assets/javascripts/releases/list/components/release_block_footer.vue new file mode 100644 index 00000000000..5659f0e530b --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_footer.vue @@ -0,0 +1,112 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { __, sprintf } from '~/locale'; + +export default { + name: 'ReleaseBlockFooter', + components: { + Icon, + GlLink, + UserAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + commit: { + type: Object, + required: false, + default: null, + }, + commitPath: { + type: String, + required: false, + default: '', + }, + tagName: { + type: String, + required: false, + default: '', + }, + tagPath: { + type: String, + required: false, + default: '', + }, + author: { + type: Object, + required: false, + default: null, + }, + releasedAt: { + type: String, + required: false, + default: '', + }, + }, + computed: { + releasedAtTimeAgo() { + return this.timeFormated(this.releasedAt); + }, + userImageAltDescription() { + return this.author && this.author.username + ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) + : null; + }, + }, +}; +</script> +<template> + <div> + <div v-if="commit" class="float-left mr-3 d-flex align-items-center js-commit-info"> + <icon ref="commitIcon" name="commit" class="mr-1" /> + <div v-gl-tooltip.bottom :title="commit.title"> + <gl-link v-if="commitPath" :href="commitPath"> + {{ commit.short_id }} + </gl-link> + <span v-else>{{ commit.short_id }}</span> + </div> + </div> + + <div v-if="tagName" class="float-left mr-3 d-flex align-items-center js-tag-info"> + <icon name="tag" class="mr-1" /> + <div v-gl-tooltip.bottom :title="__('Tag')"> + <gl-link v-if="tagPath" :href="tagPath"> + {{ tagName }} + </gl-link> + <span v-else>{{ tagName }}</span> + </div> + </div> + + <div + v-if="releasedAt || author" + class="float-left d-flex align-items-center js-author-date-info" + > + <span class="text-secondary">{{ __('Created') }} </span> + <template v-if="releasedAt"> + <span + v-gl-tooltip.bottom + :title="tooltipTitle(releasedAt)" + class="text-secondary flex-shrink-0" + > + {{ releasedAtTimeAgo }} + </span> + </template> + + <div v-if="author" class="d-flex"> + <span class="text-secondary">{{ __('by') }} </span> + <user-avatar-link + :link-href="author.web_url" + :img-src="author.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="author.username" + tooltip-placement="bottom" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue index 386653b9444..62a9338b864 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/reports/components/issue_status_icon.vue @@ -50,6 +50,6 @@ export default { }" class="report-block-list-icon" > - <icon :name="iconName" :size="statusIconSize" /> + <icon :name="iconName" :size="statusIconSize" :data-qa-selector="`status_${status}_icon`" /> </div> </template> diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index f3f7d2648a8..3c8a9e6ebef 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -46,6 +46,7 @@ export default { <li :class="{ 'is-dismissed': issue.isDismissed }" class="report-block-list-issue align-items-center" + data-qa-selector="report_item_row" > <issue-status-icon v-if="showReportSectionStatusIcon" diff --git a/app/assets/javascripts/repository/components/directory_download_links.vue b/app/assets/javascripts/repository/components/directory_download_links.vue new file mode 100644 index 00000000000..dffadade082 --- /dev/null +++ b/app/assets/javascripts/repository/components/directory_download_links.vue @@ -0,0 +1,47 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + props: { + currentPath: { + type: String, + required: false, + default: null, + }, + links: { + type: Array, + required: true, + }, + }, + computed: { + normalizedLinks() { + return this.links.map(link => ({ + text: link.text, + path: `${link.path}?path=${this.currentPath}`, + })); + }, + }, +}; +</script> + +<template> + <section class="border-top pt-1 mt-1"> + <h5 class="m-0 dropdown-bold-header">{{ __('Download this directory') }}</h5> + <div class="dropdown-menu-content"> + <div class="btn-group ml-0 w-100"> + <gl-link + v-for="(link, index) in normalizedLinks" + :key="index" + :href="link.path" + :class="{ 'btn-primary': index === 0 }" + class="btn btn-xs" + > + {{ link.text }} + </gl-link> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 19a2db2db25..70678b0db37 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import defaultAvatarUrl from 'images/no_avatar.png'; import { sprintf, s__ } from '~/locale'; import Icon from '../../vue_shared/components/icon.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -38,7 +39,14 @@ export default { path: this.currentPath.replace(/^\//, ''), }; }, - update: data => data.project.repository.tree.lastCommit, + update: data => { + const pipelines = data.project.repository.tree.lastCommit.pipelines.edges; + + return { + ...data.project.repository.tree.lastCommit, + pipeline: pipelines.length && pipelines[0].node, + }; + }, context: { isSingleRequest: true, }, @@ -61,7 +69,7 @@ export default { computed: { statusTitle() { return sprintf(s__('Commits|Commit: %{commitText}'), { - commitText: this.commit.latestPipeline.detailedStatus.text, + commitText: this.commit.pipeline.detailedStatus.text, }); }, isLoading() { @@ -76,12 +84,13 @@ export default { this.showDescription = !this.showDescription; }, }, + defaultAvatarUrl, }; </script> <template> <div class="info-well d-none d-sm-flex project-last-commit commit p-3"> - <gl-loading-icon v-if="isLoading" size="md" class="mx-auto" /> + <gl-loading-icon v-if="isLoading" size="md" class="m-auto" /> <template v-else> <user-avatar-link v-if="commit.author" @@ -90,6 +99,9 @@ export default { :img-size="40" class="avatar-cell" /> + <span v-else class="avatar-cell user-avatar-link"> + <img :src="$options.defaultAvatarUrl" width="40" height="40" class="avatar s40" /> + </span> <div class="commit-detail flex-list"> <div class="commit-content qa-commit-content"> <gl-link :href="commit.webUrl" class="commit-row-message item-title"> @@ -102,7 +114,7 @@ export default { class="text-expander" @click="toggleShowDescription" > - <icon name="ellipsis_h" /> + <icon name="ellipsis_h" :size="10" /> </gl-button> <div class="committer"> <gl-link @@ -112,12 +124,15 @@ export default { > {{ commit.author.name }} </gl-link> + <template v-else> + {{ commit.authorName }} + </template> {{ s__('LastCommit|authored') }} <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" /> </div> <pre v-if="commit.description" - v-show="showDescription" + :class="{ 'd-block': showDescription }" class="commit-row-description append-bottom-8" > {{ commit.description }} @@ -125,19 +140,20 @@ export default { </div> <div class="commit-actions flex-row"> <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div> - <gl-link - v-if="commit.latestPipeline" - v-gl-tooltip - :href="commit.latestPipeline.detailedStatus.detailsPath" - :title="statusTitle" - class="js-commit-pipeline" - > - <ci-icon - :status="commit.latestPipeline.detailedStatus" - :size="24" - :aria-label="statusTitle" - /> - </gl-link> + <div v-if="commit.pipeline" class="ci-status-link"> + <gl-link + v-gl-tooltip.left + :href="commit.pipeline.detailedStatus.detailsPath" + :title="statusTitle" + class="js-commit-pipeline" + > + <ci-icon + :status="commit.pipeline.detailedStatus" + :size="24" + :aria-label="statusTitle" + /> + </gl-link> + </div> <div class="commit-sha-group d-flex"> <div class="label label-monospace monospace"> {{ showCommitId }} @@ -153,3 +169,9 @@ export default { </template> </div> </template> + +<style scoped> +.commit { + min-height: 4.75rem; +} +</style> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue new file mode 100644 index 00000000000..7f974838359 --- /dev/null +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -0,0 +1,49 @@ +<script> +import { GlLink, GlLoadingIcon } from '@gitlab/ui'; +import getReadmeQuery from '../../queries/getReadme.query.graphql'; + +export default { + apollo: { + readme: { + query: getReadmeQuery, + variables() { + return { + url: this.blob.webUrl, + }; + }, + loadingKey: 'loading', + }, + }, + components: { + GlLink, + GlLoadingIcon, + }, + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + readme: null, + loading: 0, + }; + }, +}; +</script> + +<template> + <article class="file-holder limited-width-container readme-holder"> + <div class="file-title"> + <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i> + <gl-link :href="blob.webUrl"> + <strong>{{ blob.name }}</strong> + </gl-link> + </div> + <div class="blob-viewer"> + <gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" /> + <div v-else-if="readme" v-html="readme.html"></div> + </div> + </article> +</template> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 610c7e8d99e..8f2e9264bca 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,19 +1,15 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlSkeletonLoading } from '@gitlab/ui'; import { sprintf, __ } from '../../../locale'; import getRefMixin from '../../mixins/get_ref'; -import getFiles from '../../queries/getFiles.query.graphql'; import getProjectPath from '../../queries/getProjectPath.query.graphql'; import TableHeader from './header.vue'; import TableRow from './row.vue'; import ParentRow from './parent_row.vue'; -const PAGE_SIZE = 100; - export default { components: { - GlLoadingIcon, + GlSkeletonLoading, TableHeader, TableRow, ParentRow, @@ -29,86 +25,39 @@ export default { type: String, required: true, }, + entries: { + type: Object, + required: false, + default: () => ({}), + }, + isLoading: { + type: Boolean, + required: true, + }, }, data() { return { projectPath: '', - nextPageCursor: '', - entries: { - trees: [], - submodules: [], - blobs: [], - }, - isLoadingFiles: false, }; }, computed: { tableCaption() { + if (this.isLoading) { + return sprintf( + __( + 'Loading files, directories, and submodules in the path %{path} for commit reference %{ref}', + ), + { path: this.path, ref: this.ref }, + ); + } + return sprintf( __('Files, directories, and submodules in the path %{path} for commit reference %{ref}'), { path: this.path, ref: this.ref }, ); }, showParentRow() { - return !this.isLoadingFiles && ['', '/'].indexOf(this.path) === -1; - }, - }, - watch: { - $route: function routeChange() { - this.entries.trees = []; - this.entries.submodules = []; - this.entries.blobs = []; - this.nextPageCursor = ''; - this.fetchFiles(); - }, - }, - mounted() { - // We need to wait for `ref` and `projectPath` to be set - this.$nextTick(() => this.fetchFiles()); - }, - methods: { - fetchFiles() { - this.isLoadingFiles = true; - - return this.$apollo - .query({ - query: getFiles, - variables: { - projectPath: this.projectPath, - ref: this.ref, - path: this.path || '/', - nextPageCursor: this.nextPageCursor, - pageSize: PAGE_SIZE, - }, - }) - .then(({ data }) => { - if (!data) return; - - const pageInfo = this.hasNextPage(data.project.repository.tree); - - this.isLoadingFiles = false; - this.entries = Object.keys(this.entries).reduce( - (acc, key) => ({ - ...acc, - [key]: this.normalizeData(key, data.project.repository.tree[key].edges), - }), - {}, - ); - - if (pageInfo && pageInfo.hasNextPage) { - this.nextPageCursor = pageInfo.endCursor; - this.fetchFiles(); - } - }) - .catch(() => createFlash(__('An error occurred while fetching folder content.'))); - }, - normalizeData(key, data) { - return this.entries[key].concat(data.map(({ node }) => node)); - }, - hasNextPage(data) { - return [] - .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) - .find(({ hasNextPage }) => hasNextPage); + return !this.isLoading && ['', '/'].indexOf(this.path) === -1; }, }, }; @@ -117,12 +66,7 @@ export default { <template> <div class="tree-content-holder"> <div class="table-holder bordered-box"> - <table class="table tree-table qa-file-tree" aria-live="polite"> - <caption class="sr-only"> - {{ - tableCaption - }} - </caption> + <table :aria-label="tableCaption" class="table tree-table qa-file-tree" aria-live="polite"> <table-header v-once /> <tbody> <parent-row v-show="showParentRow" :commit-ref="ref" :path="path" /> @@ -131,6 +75,7 @@ export default { v-for="entry in val" :id="entry.id" :key="`${entry.flatPath}-${entry.id}`" + :sha="entry.sha" :project-path="projectPath" :current-path="path" :name="entry.name" @@ -141,9 +86,15 @@ export default { :lfs-oid="entry.lfsOid" /> </template> + <template v-if="isLoading"> + <tr v-for="i in 5" :key="i" aria-hidden="true"> + <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> + <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> + <td><gl-skeleton-loading :lines="1" class="ml-auto h-auto w-50" /></td> + </tr> + </template> </tbody> </table> - <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" /> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 171841178a3..cf0457a2abf 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -1,7 +1,8 @@ <script> -import { GlBadge, GlLink, GlSkeletonLoading } from '@gitlab/ui'; +import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import { getIconName } from '../../utils/icon'; import getRefMixin from '../../mixins/get_ref'; import getCommit from '../../queries/getCommit.query.graphql'; @@ -12,6 +13,10 @@ export default { GlLink, GlSkeletonLoading, TimeagoTooltip, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, }, apollo: { commit: { @@ -32,6 +37,10 @@ export default { type: String, required: true, }, + sha: { + type: String, + required: true, + }, projectPath: { type: String, required: true, @@ -93,15 +102,20 @@ export default { return this.path.replace(new RegExp(`^${this.currentPath}/`), ''); }, shortSha() { - return this.id.slice(0, 8); + return this.sha.slice(0, 8); + }, + hasLockLabel() { + return this.commit && this.commit.lockLabel; }, }, methods: { - openRow() { - if (this.isFolder) { + openRow(e) { + if (e.target.tagName === 'A') return; + + if (this.isFolder && !e.metaKey) { this.$router.push(this.routerLinkTo); } else { - visitUrl(this.url); + visitUrl(this.url, e.metaKey); } }, }, @@ -120,15 +134,28 @@ export default { <template v-if="isSubmodule"> @ <gl-link :href="submoduleTreeUrl" class="commit-sha">{{ shortSha }}</gl-link> </template> + <icon + v-if="hasLockLabel" + v-gl-tooltip + :title="commit.lockLabel" + name="lock" + :size="12" + class="ml-2 vertical-align-middle" + /> </td> <td class="d-none d-sm-table-cell tree-commit"> - <gl-link v-if="commit" :href="commit.commitPath" class="str-truncated-100 tree-commit-link"> + <gl-link + v-if="commit" + :href="commit.commitPath" + :title="commit.message" + class="str-truncated-100 tree-commit-link" + > {{ commit.message }} </gl-link> <gl-skeleton-loading v-else :lines="1" class="h-auto" /> </td> <td class="tree-time-ago text-right"> - <timeago-tooltip v-if="commit" :time="commit.committedDate" tooltip-placement="bottom" /> + <timeago-tooltip v-if="commit" :time="commit.committedDate" /> <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" /> </td> </tr> diff --git a/app/assets/javascripts/repository/components/tree_action_link.vue b/app/assets/javascripts/repository/components/tree_action_link.vue new file mode 100644 index 00000000000..72764f3ccc9 --- /dev/null +++ b/app/assets/javascripts/repository/components/tree_action_link.vue @@ -0,0 +1,28 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + props: { + path: { + type: String, + required: true, + }, + text: { + type: String, + required: true, + }, + cssClass: { + type: String, + required: false, + default: null, + }, + }, +}; +</script> + +<template> + <gl-link :href="path" :class="cssClass" class="btn">{{ text }}</gl-link> +</template> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue new file mode 100644 index 00000000000..949e653fc8f --- /dev/null +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -0,0 +1,115 @@ +<script> +import createFlash from '~/flash'; +import { __ } from '../../locale'; +import FileTable from './table/index.vue'; +import getRefMixin from '../mixins/get_ref'; +import getFiles from '../queries/getFiles.query.graphql'; +import getProjectPath from '../queries/getProjectPath.query.graphql'; +import FilePreview from './preview/index.vue'; +import { readmeFile } from '../utils/readme'; + +const PAGE_SIZE = 100; + +export default { + components: { + FileTable, + FilePreview, + }, + mixins: [getRefMixin], + apollo: { + projectPath: { + query: getProjectPath, + }, + }, + props: { + path: { + type: String, + required: false, + default: '/', + }, + }, + data() { + return { + projectPath: '', + nextPageCursor: '', + entries: { + trees: [], + submodules: [], + blobs: [], + }, + isLoadingFiles: false, + }; + }, + computed: { + readme() { + return readmeFile(this.entries.blobs); + }, + }, + + watch: { + $route: function routeChange() { + this.entries.trees = []; + this.entries.submodules = []; + this.entries.blobs = []; + this.nextPageCursor = ''; + this.fetchFiles(); + }, + }, + mounted() { + // We need to wait for `ref` and `projectPath` to be set + this.$nextTick(() => this.fetchFiles()); + }, + methods: { + fetchFiles() { + this.isLoadingFiles = true; + + return this.$apollo + .query({ + query: getFiles, + variables: { + projectPath: this.projectPath, + ref: this.ref, + path: this.path || '/', + nextPageCursor: this.nextPageCursor, + pageSize: PAGE_SIZE, + }, + }) + .then(({ data }) => { + if (!data) return; + + const pageInfo = this.hasNextPage(data.project.repository.tree); + + this.isLoadingFiles = false; + this.entries = Object.keys(this.entries).reduce( + (acc, key) => ({ + ...acc, + [key]: this.normalizeData(key, data.project.repository.tree[key].edges), + }), + {}, + ); + + if (pageInfo && pageInfo.hasNextPage) { + this.nextPageCursor = pageInfo.endCursor; + this.fetchFiles(); + } + }) + .catch(() => createFlash(__('An error occurred while fetching folder content.'))); + }, + normalizeData(key, data) { + return this.entries[key].concat(data.map(({ node }) => node)); + }, + hasNextPage(data) { + return [] + .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) + .find(({ hasNextPage }) => hasNextPage); + }, + }, +}; +</script> + +<template> + <div> + <file-table :path="path" :entries="entries" :is-loading="isLoadingFiles" /> + <file-preview v-if="readme" :blob="readme" /> + </div> +</template> diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 6cb253c8169..6936c08d852 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import axios from '~/lib/utils/axios_utils'; import createDefaultClient from '~/lib/graphql'; import introspectionQueryResultData from './fragmentTypes.json'; import { fetchLogsTree } from './log_tree'; @@ -27,6 +28,11 @@ const defaultClient = createDefaultClient( }); }); }, + readme(_, { url }) { + return axios + .get(url, { params: { viewer: 'rich', format: 'json' } }) + .then(({ data }) => ({ ...data, __typename: 'ReadmeFile' })); + }, }, }, { diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index f9727960040..d826f209815 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -3,13 +3,18 @@ import createRouter from './router'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import LastCommit from './components/last_commit.vue'; +import TreeActionLink from './components/tree_action_link.vue'; +import DirectoryDownloadLinks from './components/directory_download_links.vue'; import apolloProvider from './graphql'; import { setTitle } from './utils/title'; import { parseBoolean } from '../lib/utils/common_utils'; +import { webIDEUrl } from '../lib/utils/url_utility'; +import { __ } from '../locale'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); - const { projectPath, projectShortPath, ref, fullName } = el.dataset; + const { dataset } = el; + const { projectPath, projectShortPath, ref, fullName } = dataset; const router = createRouter(projectPath, ref); apolloProvider.clients.defaultClient.cache.writeData({ @@ -22,19 +27,7 @@ export default function setupVueRepositoryList() { }); router.afterEach(({ params: { pathMatch } }) => { - const isRoot = pathMatch === undefined || pathMatch === '/'; - setTitle(pathMatch, ref, fullName); - - if (!isRoot) { - document - .querySelectorAll('.js-keep-hidden-on-navigation') - .forEach(elem => elem.classList.add('hidden')); - } - - document - .querySelectorAll('.js-hide-on-navigation') - .forEach(elem => elem.classList.toggle('hidden', !isRoot)); }); const breadcrumbEl = document.getElementById('js-repo-breadcrumb'); @@ -88,7 +81,68 @@ export default function setupVueRepositoryList() { }, }); - return new Vue({ + const treeHistoryLinkEl = document.getElementById('js-tree-history-link'); + const { historyLink } = treeHistoryLinkEl.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: treeHistoryLinkEl, + router, + render(h) { + return h(TreeActionLink, { + props: { + path: historyLink + (this.$route.params.pathMatch || '/'), + text: __('History'), + }, + }); + }, + }); + + const webIdeLinkEl = document.getElementById('js-tree-web-ide-link'); + + if (webIdeLinkEl) { + // eslint-disable-next-line no-new + new Vue({ + el: webIdeLinkEl, + router, + render(h) { + return h(TreeActionLink, { + props: { + path: webIDEUrl(`/${projectPath}/edit/${ref}/-${this.$route.params.pathMatch || '/'}`), + text: __('Web IDE'), + cssClass: 'qa-web-ide-button', + }, + }); + }, + }); + } + + const directoryDownloadLinks = document.getElementById('js-directory-downloads'); + + if (directoryDownloadLinks) { + // eslint-disable-next-line no-new + new Vue({ + el: directoryDownloadLinks, + router, + render(h) { + const currentPath = this.$route.params.pathMatch || '/'; + + if (currentPath !== '/') { + return h(DirectoryDownloadLinks, { + props: { + currentPath: currentPath.replace(/^\//, ''), + links: JSON.parse(directoryDownloadLinks.dataset.links), + }, + }); + } + + return null; + }, + }); + } + + // eslint-disable-next-line no-new + new Vue({ el, router, apolloProvider, @@ -96,4 +150,6 @@ export default function setupVueRepositoryList() { return h(App); }, }); + + return { router, data: dataset }; } diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 2c19aca2397..5bf30e625a0 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -1,4 +1,5 @@ import axios from '~/lib/utils/axios_utils'; +import { normalizeData } from 'ee_else_ce/repository/utils/commit'; import getCommits from './queries/getCommits.query.graphql'; import getProjectPath from './queries/getProjectPath.query.graphql'; import getRef from './queries/getRef.query.graphql'; @@ -6,18 +7,6 @@ import getRef from './queries/getRef.query.graphql'; let fetchpromise; let resolvers = []; -export function normalizeData(data) { - return data.map(d => ({ - sha: d.commit.id, - message: d.commit.message, - committedDate: d.commit.committed_date, - commitPath: d.commit_path, - fileName: d.file_name, - type: d.type, - __typename: 'LogTreeCommit', - })); -} - export function resolveCommit(commits, { resolve, entry }) { const commit = commits.find(c => c.fileName === entry.name && c.type === entry.type); @@ -37,9 +26,12 @@ export function fetchLogsTree(client, path, offset, resolver = null) { const { ref } = client.readQuery({ query: getRef }); fetchpromise = axios - .get(`${gon.gitlab_url}/${projectPath}/refs/${ref}/logs_tree${path ? `/${path}` : ''}`, { - params: { format: 'json', offset }, - }) + .get( + `${gon.relative_url_root}/${projectPath}/refs/${ref}/logs_tree/${path.replace(/^\//, '')}`, + { + params: { format: 'json', offset }, + }, + ) .then(({ data, headers }) => { const headerLogsOffset = headers['more-logs-offset']; const { commits } = client.readQuery({ query: getCommits }); diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue index 2d92e9174ca..29786bf4ec8 100644 --- a/app/assets/javascripts/repository/pages/index.vue +++ b/app/assets/javascripts/repository/pages/index.vue @@ -1,18 +1,25 @@ <script> -import FileTable from '../components/table/index.vue'; +import TreePage from './tree.vue'; +import { updateElementsVisibility } from '../utils/dom'; export default { components: { - FileTable, + TreePage, }, - data() { - return { - ref: '', - }; + mounted() { + this.updateProjectElements(true); + }, + beforeDestroy() { + this.updateProjectElements(false); + }, + methods: { + updateProjectElements(isShow) { + updateElementsVisibility('.js-show-on-project-root', isShow); + }, }, }; </script> <template> - <file-table path="/" /> + <tree-page path="/" /> </template> diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue index 3b898d1aa91..dd4d437f4dd 100644 --- a/app/assets/javascripts/repository/pages/tree.vue +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -1,9 +1,10 @@ <script> -import FileTable from '../components/table/index.vue'; +import TreeContent from '../components/tree_content.vue'; +import { updateElementsVisibility } from '../utils/dom'; export default { components: { - FileTable, + TreeContent, }, props: { path: { @@ -12,9 +13,26 @@ export default { default: '/', }, }, + computed: { + isRoot() { + return this.path === '/'; + }, + }, + watch: { + isRoot: { + immediate: true, + handler: 'updateElements', + }, + }, + methods: { + updateElements(isRoot) { + updateElementsVisibility('.js-show-on-root', isRoot); + updateElementsVisibility('.js-hide-on-root', !isRoot); + }, + }, }; </script> <template> - <file-table :path="path" /> + <tree-content :path="path" /> </template> diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql new file mode 100644 index 00000000000..9bb13c475c7 --- /dev/null +++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql @@ -0,0 +1,8 @@ +fragment TreeEntryCommit on LogTreeCommit { + sha + message + committedDate + commitPath + fileName + type +} diff --git a/app/assets/javascripts/repository/queries/getCommit.query.graphql b/app/assets/javascripts/repository/queries/getCommit.query.graphql index e2a2d831e47..e4aeaaff8fe 100644 --- a/app/assets/javascripts/repository/queries/getCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/getCommit.query.graphql @@ -1,10 +1,7 @@ +#import "ee_else_ce/repository/queries/commit.fragment.graphql" + query getCommit($fileName: String!, $type: String!, $path: String!) { commit(path: $path, fileName: $fileName, type: $type) @client { - sha - message - committedDate - commitPath - fileName - type + ...TreeEntryCommit } } diff --git a/app/assets/javascripts/repository/queries/getCommits.query.graphql b/app/assets/javascripts/repository/queries/getCommits.query.graphql index df9e67cc440..0976b8f32d7 100644 --- a/app/assets/javascripts/repository/queries/getCommits.query.graphql +++ b/app/assets/javascripts/repository/queries/getCommits.query.graphql @@ -1,10 +1,7 @@ +#import "ee_else_ce/repository/queries/commit.fragment.graphql" + query getCommits { commits @client { - sha - message - committedDate - commitPath - fileName - type + ...TreeEntryCommit } } diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql index c4814f8e63a..2aaf5066b4a 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql @@ -2,6 +2,7 @@ fragment TreeEntry on Entry { id + sha name flatPath type diff --git a/app/assets/javascripts/repository/queries/getReadme.query.graphql b/app/assets/javascripts/repository/queries/getReadme.query.graphql new file mode 100644 index 00000000000..cf056330133 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getReadme.query.graphql @@ -0,0 +1,5 @@ +query getReadme($url: String!) { + readme(url: $url) @client { + html + } +} diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index 71c1bf12749..9be025afe39 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -5,22 +5,27 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { lastCommit { sha title - message + description webUrl authoredDate + authorName author { name avatarUrl webUrl } signatureHtml - latestPipeline { - detailedStatus { - detailsPath - icon - tooltip - text - group + pipelines(ref: $ref, first: 1) { + edges { + node { + detailedStatus { + detailsPath + icon + tooltip + text + group + } + } } } } diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index 9322c81ab97..ebf0a7091ea 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -16,7 +16,7 @@ export default function createRouter(base, baseRef) { name: 'treePath', component: TreePage, props: route => ({ - path: route.params.pathMatch && route.params.pathMatch.replace(/^\//, ''), + path: route.params.pathMatch && (route.params.pathMatch.replace(/^\//, '') || '/'), }), }, { diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js new file mode 100644 index 00000000000..6c204b57b37 --- /dev/null +++ b/app/assets/javascripts/repository/utils/commit.js @@ -0,0 +1,13 @@ +// eslint-disable-next-line import/prefer-default-export +export function normalizeData(data, extra = () => {}) { + return data.map(d => ({ + sha: d.commit.id, + message: d.commit.message, + committedDate: d.commit.committed_date, + commitPath: d.commit_path, + fileName: d.file_name, + type: d.type, + __typename: 'LogTreeCommit', + ...extra(d), + })); +} diff --git a/app/assets/javascripts/repository/utils/dom.js b/app/assets/javascripts/repository/utils/dom.js new file mode 100644 index 00000000000..963e6fc0bc4 --- /dev/null +++ b/app/assets/javascripts/repository/utils/dom.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export +export const updateElementsVisibility = (selector, isVisible) => { + document.querySelectorAll(selector).forEach(elem => elem.classList.toggle('hidden', !isVisible)); +}; diff --git a/app/assets/javascripts/repository/utils/readme.js b/app/assets/javascripts/repository/utils/readme.js new file mode 100644 index 00000000000..e43b2bdc33a --- /dev/null +++ b/app/assets/javascripts/repository/utils/readme.js @@ -0,0 +1,21 @@ +const MARKDOWN_EXTENSIONS = ['mdown', 'mkd', 'mkdn', 'md', 'markdown']; +const ASCIIDOC_EXTENSIONS = ['adoc', 'ad', 'asciidoc']; +const OTHER_EXTENSIONS = ['textile', 'rdoc', 'org', 'creole', 'wiki', 'mediawiki', 'rst']; +const EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...ASCIIDOC_EXTENSIONS, ...OTHER_EXTENSIONS]; +const PLAIN_FILENAMES = ['readme', 'index']; +const FILE_REGEXP = new RegExp( + `^(${PLAIN_FILENAMES.join('|')})(.(${EXTENSIONS.join('|')}))?$`, + 'i', +); +const PLAIN_FILE_REGEXP = new RegExp(`^(${PLAIN_FILENAMES.join('|')})`, 'i'); +const EXTENSIONS_REGEXP = new RegExp(`.(${EXTENSIONS.join('|')})$`, 'i'); + +// eslint-disable-next-line import/prefer-default-export +export const readmeFile = blobs => { + const readMeFiles = blobs.filter(f => f.name.search(FILE_REGEXP) !== -1); + + const previewableReadme = readMeFiles.find(f => f.name.search(EXTENSIONS_REGEXP) !== -1); + const plainReadme = readMeFiles.find(f => f.name.search(PLAIN_FILE_REGEXP) !== -1); + + return previewableReadme || plainReadme; +}; diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js index 87d54c01200..ff16fbdd420 100644 --- a/app/assets/javascripts/repository/utils/title.js +++ b/app/assets/javascripts/repository/utils/title.js @@ -1,10 +1,14 @@ +const DEFAULT_TITLE = '· GitLab'; // eslint-disable-next-line import/prefer-default-export export const setTitle = (pathMatch, ref, project) => { - if (!pathMatch) return; + if (!pathMatch) { + document.title = `${project} ${DEFAULT_TITLE}`; + return; + } const path = pathMatch.replace(/^\//, ''); const isEmpty = path === ''; /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`; + document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`; }; diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 87454ee056f..fa5649679d7 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, consistent-return, one-var, no-else-return, no-param-reassign */ +/* eslint-disable func-names, consistent-return, no-else-return, no-param-reassign */ import $ from 'jquery'; import _ from 'underscore'; @@ -44,12 +44,11 @@ Sidebar.prototype.addEventListeners = function() { }; Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { - var $allGutterToggleIcons, $this, isExpanded, tooltipLabel; + const $this = $(this); + const isExpanded = $this.find('i').hasClass('fa-angle-double-right'); + const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); + const $allGutterToggleIcons = $('.js-sidebar-toggle i'); e.preventDefault(); - $this = $(this); - isExpanded = $this.find('i').hasClass('fa-angle-double-right'); - tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); - $allGutterToggleIcons = $('.js-sidebar-toggle i'); if (isExpanded) { $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); @@ -77,15 +76,9 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { }; Sidebar.prototype.toggleTodo = function(e) { - var $this, ajaxType, url; - $this = $(e.currentTarget); - ajaxType = $this.data('deletePath') ? 'delete' : 'post'; - - if ($this.data('deletePath')) { - url = String($this.data('deletePath')); - } else { - url = String($this.data('createPath')); - } + const $this = $(e.currentTarget); + const ajaxType = $this.data('deletePath') ? 'delete' : 'post'; + const url = String($this.data('deletePath') || $this.data('createPath')); $this.tooltip('hide'); @@ -141,13 +134,12 @@ Sidebar.prototype.todoUpdateDone = function(data) { }; Sidebar.prototype.sidebarDropdownLoading = function() { - var $loading, $sidebarCollapsedIcon, i, img; - $sidebarCollapsedIcon = $(this) + const $sidebarCollapsedIcon = $(this) .closest('.block') .find('.sidebar-collapsed-icon'); - img = $sidebarCollapsedIcon.find('img'); - i = $sidebarCollapsedIcon.find('i'); - $loading = $('<i class="fa fa-spinner fa-spin"></i>'); + const img = $sidebarCollapsedIcon.find('img'); + const i = $sidebarCollapsedIcon.find('i'); + const $loading = $('<i class="fa fa-spinner fa-spin"></i>'); if (img.length) { img.before($loading); return img.hide(); @@ -158,13 +150,12 @@ Sidebar.prototype.sidebarDropdownLoading = function() { }; Sidebar.prototype.sidebarDropdownLoaded = function() { - var $sidebarCollapsedIcon, i, img; - $sidebarCollapsedIcon = $(this) + const $sidebarCollapsedIcon = $(this) .closest('.block') .find('.sidebar-collapsed-icon'); - img = $sidebarCollapsedIcon.find('img'); + const img = $sidebarCollapsedIcon.find('img'); $sidebarCollapsedIcon.find('i.fa-spin').remove(); - i = $sidebarCollapsedIcon.find('i'); + const i = $sidebarCollapsedIcon.find('i'); if (img.length) { return img.show(); } else { @@ -173,19 +164,17 @@ Sidebar.prototype.sidebarDropdownLoaded = function() { }; Sidebar.prototype.sidebarCollapseClicked = function(e) { - var $block, sidebar; if ($(e.currentTarget).hasClass('dont-change-state')) { return; } - sidebar = e.data; + const sidebar = e.data; e.preventDefault(); - $block = $(this).closest('.block'); + const $block = $(this).closest('.block'); return sidebar.openDropdown($block); }; Sidebar.prototype.openDropdown = function(blockOrName) { - var $block; - $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; + const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; if (!this.isOpen()) { this.setCollapseAfterUpdate($block); this.toggleSidebar('open'); @@ -204,10 +193,9 @@ Sidebar.prototype.setCollapseAfterUpdate = function($block) { }; Sidebar.prototype.onSidebarDropdownHidden = function(e) { - var $block, sidebar; - sidebar = e.data; + const sidebar = e.data; e.preventDefault(); - $block = $(e.target).closest('.block'); + const $block = $(e.target).closest('.block'); return sidebar.sidebarDropdownHidden($block); }; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index f6722ff7bca..8d888a574d8 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable no-return-assign, one-var, no-var, consistent-return, class-methods-use-this, no-lonely-if, vars-on-top */ +/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; import { escape, throttle } from 'underscore'; @@ -29,14 +29,14 @@ const KEYCODE = { }; function setSearchOptions() { - var $projectOptionsDataEl = $('.js-search-project-options'); - var $groupOptionsDataEl = $('.js-search-group-options'); - var $dashboardOptionsDataEl = $('.js-search-dashboard-options'); + const $projectOptionsDataEl = $('.js-search-project-options'); + const $groupOptionsDataEl = $('.js-search-group-options'); + const $dashboardOptionsDataEl = $('.js-search-dashboard-options'); if ($projectOptionsDataEl.length) { gl.projectOptions = gl.projectOptions || {}; - var projectPath = $projectOptionsDataEl.data('projectPath'); + const projectPath = $projectOptionsDataEl.data('projectPath'); gl.projectOptions[projectPath] = { name: $projectOptionsDataEl.data('name'), @@ -49,7 +49,7 @@ function setSearchOptions() { if ($groupOptionsDataEl.length) { gl.groupOptions = gl.groupOptions || {}; - var groupPath = $groupOptionsDataEl.data('groupPath'); + const groupPath = $groupOptionsDataEl.data('groupPath'); gl.groupOptions[groupPath] = { name: $groupOptionsDataEl.data('name'), @@ -95,10 +95,9 @@ export class SearchAutocomplete { this.createAutocomplete(); } - this.searchInput.addClass('disabled'); - this.saveTextLength(); this.bindEvents(); this.dropdownToggle.dropdown(); + this.searchInput.addClass('js-autocomplete-disabled'); } // Finds an element inside wrapper element @@ -107,7 +106,7 @@ export class SearchAutocomplete { this.onClearInputClick = this.onClearInputClick.bind(this); this.onSearchInputFocus = this.onSearchInputFocus.bind(this); this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this); - this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this); + this.onSearchInputChange = this.onSearchInputChange.bind(this); this.setScrollFade = this.setScrollFade.bind(this); } getElement(selector) { @@ -118,10 +117,6 @@ export class SearchAutocomplete { return (this.originalState = this.serializeState()); } - saveTextLength() { - return (this.lastTextLength = this.searchInput.val().length); - } - createAutocomplete() { return this.searchInput.glDropdown({ filterInputBlur: false, @@ -318,12 +313,16 @@ export class SearchAutocomplete { } bindEvents() { - this.searchInput.on('keydown', this.onSearchInputKeyDown); + this.searchInput.on('input', this.onSearchInputChange); this.searchInput.on('keyup', this.onSearchInputKeyUp); this.searchInput.on('focus', this.onSearchInputFocus); this.searchInput.on('blur', this.onSearchInputBlur); this.clearInput.on('click', this.onClearInputClick); this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250)); + + this.searchInput.on('click', e => { + e.stopPropagation(); + }); } enableAutocomplete() { @@ -338,47 +337,23 @@ export class SearchAutocomplete { if (!this.dropdown.hasClass('show')) { this.loadingSuggestions = false; this.dropdownToggle.dropdown('toggle'); - return this.searchInput.removeClass('disabled'); + return this.searchInput.removeClass('js-autocomplete-disabled'); } } - // Saves last length of the entered text - onSearchInputKeyDown() { - return this.saveTextLength(); + onSearchInputChange() { + this.enableAutocomplete(); } onSearchInputKeyUp(e) { switch (e.keyCode) { - case KEYCODE.BACKSPACE: - // When removing the last character and no badge is present - if (this.lastTextLength === 1) { - this.disableAutocomplete(); - } - // When removing any character from existin value - if (this.lastTextLength > 1) { - this.enableAutocomplete(); - } - break; case KEYCODE.ESCAPE: this.restoreOriginalState(); break; case KEYCODE.ENTER: this.disableAutocomplete(); break; - case KEYCODE.UP: - case KEYCODE.DOWN: - return; default: - // Handle the case when deleting the input value other than backspace - // e.g. Pressing ctrl + backspace or ctrl + x - if (this.searchInput.val() === '') { - this.disableAutocomplete(); - } else { - // We should display the menu only when input is not empty - if (e.keyCode !== KEYCODE.ENTER) { - this.enableAutocomplete(); - } - } } this.wrap.toggleClass('has-value', Boolean(e.target.value)); } @@ -412,36 +387,33 @@ export class SearchAutocomplete { } restoreOriginalState() { - var i, input, inputs, len; - inputs = Object.keys(this.originalState); - for (i = 0, len = inputs.length; i < len; i += 1) { - input = inputs[i]; + const inputs = Object.keys(this.originalState); + for (let i = 0, len = inputs.length; i < len; i += 1) { + const input = inputs[i]; this.getElement(`#${input}`).val(this.originalState[input]); } } resetSearchState() { - var i, input, inputs, len, results; - inputs = Object.keys(this.originalState); - results = []; - for (i = 0, len = inputs.length; i < len; i += 1) { - input = inputs[i]; + const inputs = Object.keys(this.originalState); + const results = []; + for (let i = 0, len = inputs.length; i < len; i += 1) { + const input = inputs[i]; results.push(this.getElement(`#${input}`).val('')); } return results; } disableAutocomplete() { - if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) { - this.searchInput.addClass('disabled'); - this.dropdown.removeClass('show').trigger('hidden.bs.dropdown'); + if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { + this.searchInput.addClass('js-autocomplete-disabled'); + this.dropdown.dropdown('toggle'); this.restoreMenu(); } } restoreMenu() { - var html; - html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; + const html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; return this.dropdownContent.html(html); } diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/sentry/index.js index 4dd0175e528..06e4e0aa507 100644 --- a/app/assets/javascripts/raven/index.js +++ b/app/assets/javascripts/sentry/index.js @@ -1,8 +1,8 @@ -import RavenConfig from './raven_config'; +import SentryConfig from './sentry_config'; const index = function index() { - RavenConfig.init({ - sentryDsn: gon.sentry_dsn, + SentryConfig.init({ + dsn: gon.sentry_dsn, currentUserId: gon.current_user_id, whitelistUrls: process.env.NODE_ENV === 'production' @@ -15,7 +15,7 @@ const index = function index() { }, }); - return RavenConfig; + return SentryConfig; }; index(); diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/sentry/sentry_config.js index 7259e0df104..bc3b2f16a6a 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -1,4 +1,4 @@ -import Raven from 'raven-js'; +import * as Sentry from '@sentry/browser'; import $ from 'jquery'; import { __ } from '~/locale'; @@ -26,7 +26,7 @@ const IGNORE_ERRORS = [ 'conduitPage', ]; -const IGNORE_URLS = [ +const BLACKLIST_URLS = [ // Facebook flakiness /graph\.facebook\.com/i, // Facebook blocked @@ -43,62 +43,62 @@ const IGNORE_URLS = [ /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, ]; -const SAMPLE_RATE = 95; +const SAMPLE_RATE = 0.95; -const RavenConfig = { +const SentryConfig = { IGNORE_ERRORS, - IGNORE_URLS, + BLACKLIST_URLS, SAMPLE_RATE, init(options = {}) { this.options = options; this.configure(); - this.bindRavenErrors(); + this.bindSentryErrors(); if (this.options.currentUserId) this.setUser(); }, configure() { - Raven.config(this.options.sentryDsn, { - release: this.options.release, - tags: this.options.tags, - whitelistUrls: this.options.whitelistUrls, - environment: this.options.environment, - ignoreErrors: this.IGNORE_ERRORS, - ignoreUrls: this.IGNORE_URLS, - shouldSendCallback: this.shouldSendSample.bind(this), - }).install(); + const { dsn, release, tags, whitelistUrls, environment } = this.options; + Sentry.init({ + dsn, + release, + tags, + whitelistUrls, + environment, + ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 + blacklistUrls: this.BLACKLIST_URLS, + sampleRate: SAMPLE_RATE, + }); }, setUser() { - Raven.setUserContext({ + Sentry.setUser({ id: this.options.currentUserId, }); }, - bindRavenErrors() { - $(document).on('ajaxError.raven', this.handleRavenErrors); + bindSentryErrors() { + $(document).on('ajaxError.sentry', this.handleSentryErrors); }, - handleRavenErrors(event, req, config, err) { + handleSentryErrors(event, req, config, err) { const error = err || req.statusText; - const responseText = req.responseText || __('Unknown response text'); + const { responseText = __('Unknown response text') } = req; + const { type, url, data } = config; + const { status } = req; - Raven.captureMessage(error, { + Sentry.captureMessage(error, { extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, + type, + url, + data, + status, response: responseText, error, event, }, }); }, - - shouldSendSample() { - return Math.random() * 100 <= this.SAMPLE_RATE; - }, }; -export default RavenConfig; +export default SentryConfig; diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue index 95a2c8cce6e..91fe5fc50a9 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -33,6 +33,8 @@ export default { <div class="block subscriptions"> <subscriptions :loading="store.isFetching.subscriptions" + :project-emails-disabled="store.projectEmailsDisabled" + :subscribe-disabled-description="store.subscribeDisabledDescription" :subscribed="store.subscribed" @toggleSubscription="onToggleSubscription" /> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index ea5edb3ce3f..0e489b28593 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -26,6 +26,16 @@ export default { required: false, default: false, }, + projectEmailsDisabled: { + type: Boolean, + required: false, + default: false, + }, + subscribeDisabledDescription: { + type: String, + required: false, + default: '', + }, subscribed: { type: Boolean, required: false, @@ -42,11 +52,23 @@ export default { return this.subscribed === null; }, notificationIcon() { + if (this.projectEmailsDisabled) { + return ICON_OFF; + } return this.subscribed ? ICON_ON : ICON_OFF; }, notificationTooltip() { + if (this.projectEmailsDisabled) { + return this.subscribeDisabledDescription; + } return this.subscribed ? LABEL_ON : LABEL_OFF; }, + notificationText() { + if (this.projectEmailsDisabled) { + return this.subscribeDisabledDescription; + } + return __('Notifications'); + }, }, methods: { /** @@ -81,6 +103,7 @@ export default { <template> <div> <span + ref="tooltip" v-tooltip class="sidebar-collapsed-icon" :title="notificationTooltip" @@ -96,8 +119,9 @@ export default { class="sidebar-item-icon is-active" /> </span> - <span class="issuable-header-text hide-collapsed float-left"> {{ __('Notifications') }} </span> + <span class="issuable-header-text hide-collapsed float-left"> {{ notificationText }} </span> <toggle-button + v-if="!projectEmailsDisabled" ref="toggleButton" :is-loading="showLoadingState" :value="subscribed" diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 63c4a2a3f84..66f7f9e3c66 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -28,6 +28,8 @@ export default class SidebarStore { this.moveToProjectId = 0; this.isLockDialogOpen = false; this.participants = []; + this.projectEmailsDisabled = false; + this.subscribeDisabledDescription = ''; this.subscribed = null; SidebarStore.singleton = this; @@ -53,6 +55,8 @@ export default class SidebarStore { } setSubscriptionsData(data) { + this.projectEmailsDisabled = data.project_emails_disabled || false; + this.subscribeDisabledDescription = data.subscribe_disabled_description; this.isFetching.subscriptions = false; this.subscribed = data.subscribed || false; } diff --git a/app/assets/javascripts/sourcegraph/index.js b/app/assets/javascripts/sourcegraph/index.js new file mode 100644 index 00000000000..796e90bf08e --- /dev/null +++ b/app/assets/javascripts/sourcegraph/index.js @@ -0,0 +1,28 @@ +function loadScript(path) { + const script = document.createElement('script'); + script.type = 'application/javascript'; + script.src = path; + script.defer = true; + document.head.appendChild(script); +} + +/** + * Loads the Sourcegraph integration for support for Sourcegraph extensions and + * code intelligence. + */ +export default function initSourcegraph() { + const { url } = gon.sourcegraph || {}; + + if (!url) { + return; + } + + const assetsUrl = new URL('/assets/webpack/sourcegraph/', window.location.href); + const scriptPath = new URL('scripts/integration.bundle.js', assetsUrl).href; + + window.SOURCEGRAPH_ASSETS_URL = assetsUrl.href; + window.SOURCEGRAPH_URL = url; + window.SOURCEGRAPH_INTEGRATION = 'gitlab-integration'; + + loadScript(scriptPath); +} diff --git a/app/assets/javascripts/sourcegraph/load.js b/app/assets/javascripts/sourcegraph/load.js new file mode 100644 index 00000000000..f9491505d42 --- /dev/null +++ b/app/assets/javascripts/sourcegraph/load.js @@ -0,0 +1,6 @@ +import initSourcegraph from './index'; + +/** + * Load sourcegraph in it's own listener so that it's isolated from failures. + */ +document.addEventListener('DOMContentLoaded', initSourcegraph); diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index 69b3d20914a..a530c4a99e2 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */ +/* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; @@ -9,9 +9,8 @@ export default class TreeView { // Code browser tree slider // Make the entire tree-item row clickable, but not if clicking another link (like a commit message) $('.tree-content-holder .tree-item').on('click', function(e) { - var $clickedEl, path; - $clickedEl = $(e.target); - path = $('.tree-item-file-name a', this).attr('href'); + const $clickedEl = $(e.target); + const path = $('.tree-item-file-name a', this).attr('href'); if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) { if (e.metaKey || e.which === 2) { e.preventDefault(); @@ -26,11 +25,10 @@ export default class TreeView { } initKeyNav() { - var li, liSelected; - li = $('tr.tree-item'); - liSelected = null; + const li = $('tr.tree-item'); + let liSelected = null; return $('body').keydown(e => { - var next, path; + let next, path; if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) { return false; } diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index c0b7587be10..7d6a725b30f 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -73,9 +73,14 @@ const handleUserPopoverMouseOver = event => { location: userData.location, bio: userData.bio, organization: userData.organization, + status: userData.status, loaded: true, }); + if (userData.status) { + return Promise.resolve(); + } + return UsersCache.retrieveStatusById(userId); }) .then(status => { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 339e154affc..57be97855e3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -65,9 +65,13 @@ export default { simplePoll(this.checkRebaseStatus); }) .catch(error => { - this.rebasingError = error.merge_error; this.isMakingRequest = false; - Flash(__('Something went wrong. Please try again.')); + + if (error.response && error.response.data && error.response.data.merge_error) { + this.rebasingError = error.response.data.merge_error; + } else { + Flash(__('Something went wrong. Please try again.')); + } }); }, checkRebaseStatus(continuePolling, stopPolling) { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue index 1e6f4c376c1..66155ddcdd9 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -18,6 +18,11 @@ export default { required: false, default: 0, }, + filePath: { + type: String, + required: false, + default: '', + }, projectPath: { type: String, required: false, @@ -52,6 +57,7 @@ export default { <component :is="viewer" :path="path" + :file-path="filePath" :file-size="fileSize" :project-path="projectPath" :content="content" diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 655f0054887..c50304f057d 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -16,6 +16,11 @@ export default { type: String, required: true, }, + filePath: { + type: String, + required: false, + default: '', + }, projectPath: { type: String, required: true, @@ -48,6 +53,7 @@ export default { this.isLoading = true; const postBody = { text: this.content, + path: this.filePath, }; const postOptions = { cancelToken: axiosSource.token, diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index ebb253ff422..b874bedab36 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -23,6 +23,11 @@ export default { type: String, required: true, }, + newSize: { + type: Number, + required: false, + default: 0, + }, oldPath: { type: String, required: true, @@ -31,6 +36,11 @@ export default { type: String, required: true, }, + oldSize: { + type: Number, + required: false, + default: 0, + }, projectPath: { type: String, required: false, @@ -85,6 +95,8 @@ export default { :diff-mode="diffMode" :new-path="fullNewPath" :old-path="fullOldPath" + :old-size="oldSize" + :new-size="newSize" :project-path="projectPath" :a-mode="aMode" :b-mode="bMode" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue index a17fc022195..4dbfdb6d79c 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue @@ -14,6 +14,16 @@ export default { type: String, required: true, }, + newSize: { + type: Number, + required: false, + default: 0, + }, + oldSize: { + type: Number, + required: false, + default: 0, + }, }, }; </script> @@ -22,12 +32,14 @@ export default { <div class="two-up view d-flex"> <image-viewer :path="oldPath" + :file-size="oldSize" :render-info="true" inner-css-classes="frame deleted" class="wrap w-50" /> <image-viewer :path="newPath" + :file-size="newSize" :render-info="true" :inner-css-classes="['frame', 'added']" class="wrap w-50" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue index cab92297ca7..e30871b66fc 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -22,6 +22,16 @@ export default { type: String, required: true, }, + newSize: { + type: Number, + required: false, + default: 0, + }, + oldSize: { + type: Number, + required: false, + default: 0, + }, }, data() { return { diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 341c9534763..611001df32f 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -218,7 +218,7 @@ export default { display: inline-block; flex: 1; max-width: inherit; - height: 18px; + height: 19px; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index 73f4dfef062..80908cbbc9c 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -61,7 +61,7 @@ export default { </script> <template> - <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true"> + <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true" v-on="$listeners"> <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue index 715cf97f0ac..1524b313f9f 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -1,7 +1,6 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; - import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { @@ -16,44 +15,47 @@ export default { type: Array, required: true, }, + iconSize: { + type: Number, + required: false, + default: 24, + }, + imgCssClasses: { + type: String, + required: false, + default: '', + }, + maxVisible: { + type: Number, + required: false, + default: 3, + }, }, data() { return { - maxVisibleAssignees: 2, - maxAssigneeAvatars: 3, maxAssignees: 99, }; }, computed: { - countOverLimit() { - return this.assignees.length - this.maxVisibleAssignees; - }, assigneesToShow() { - if (this.assignees.length > this.maxAssigneeAvatars) { - return this.assignees.slice(0, this.maxVisibleAssignees); - } - return this.assignees; + const numShownAssignees = this.assignees.length - this.numHiddenAssignees; + return this.assignees.slice(0, numShownAssignees); }, assigneesCounterTooltip() { - const { countOverLimit, maxAssignees } = this; - const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit; - - return sprintf(__('%{count} more assignees'), { count }); + return sprintf(__('%{count} more assignees'), { count: this.numHiddenAssignees }); }, - shouldRenderAssigneesCounter() { - const assigneesCount = this.assignees.length; - if (assigneesCount <= this.maxAssigneeAvatars) { - return false; + numHiddenAssignees() { + if (this.assignees.length > this.maxVisible) { + return this.assignees.length - this.maxVisible + 1; } - - return assigneesCount > this.countOverLimit; + return 0; }, assigneeCounterLabel() { - if (this.countOverLimit > this.maxAssignees) { + if (this.numHiddenAssignees > this.maxAssignees) { return `${this.maxAssignees}+`; } - return `+${this.countOverLimit}`; + return `+${this.numHiddenAssignees}`; }, }, methods: { @@ -81,8 +83,9 @@ export default { :key="assignee.id" :link-href="webUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" + :img-css-classes="imgCssClasses" :img-src="avatarUrl(assignee)" - :img-size="24" + :img-size="iconSize" class="js-no-trigger" tooltip-placement="bottom" > @@ -92,7 +95,7 @@ export default { </span> </user-avatar-link> <span - v-if="shouldRenderAssigneesCounter" + v-if="numHiddenAssignees > 0" v-gl-tooltip :title="assigneesCounterTooltip" class="avatar-counter" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 4d27d1c9179..af4ac024e4f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -124,7 +124,7 @@ export default { :cursor-offset="4" :tag-content="lineContent" icon="doc-code" - class="qa-suggestion-btn js-suggestion-btn" + class="js-suggestion-btn" @click="handleSuggestDismissed" /> <gl-popover @@ -168,7 +168,7 @@ export default { :prepend="true" tag="* [ ] " :button-title="__('Add a task list')" - icon="task-done" + icon="list-task" /> <toolbar-button :tag="mdTable" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 12de3671477..cc700440a23 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -55,7 +55,7 @@ export default { <gl-button v-else-if="canApply" v-gl-tooltip.viewport="__('This also resolves the discussion')" - class="btn-inverted qa-apply-btn js-apply-btn" + class="btn-inverted js-apply-btn" :disabled="isApplying" variant="success" @click="applySuggestion" diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index d6dfe9eded8..f8e010c4f42 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -17,9 +17,11 @@ * /> */ import $ from 'jquery'; -import { mapGetters } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; +import { GlSkeletonLoading } from '@gitlab/ui'; import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; import initMRPopovers from '~/mr_popover/'; @@ -32,7 +34,9 @@ export default { Icon, noteHeader, TimelineEntryItem, + GlSkeletonLoading, }, + mixins: [descriptionVersionHistoryMixin], props: { note: { type: Object, @@ -75,13 +79,16 @@ export default { mounted() { initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); }, + methods: { + ...mapActions(['fetchDescriptionVersion']), + }, }; </script> <template> <timeline-entry-item :id="noteAnchorId" - :class="{ target: isTargetNote }" + :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" class="note system-note note-wrapper" > <div class="timeline-icon" v-html="iconHtml"></div> @@ -89,14 +96,18 @@ export default { <div class="note-header"> <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> <span v-html="actionTextHtml"></span> + <template v-if="canSeeDescriptionVersion" slot="extra-controls"> + · + <button type="button" class="btn-blank btn-link" @click="toggleDescriptionVersion"> + {{ __('Compare with previous version') }} + <icon :name="descriptionVersionToggleIcon" :size="12" class="append-left-5" /> + </button> + </template> </note-header> </div> <div class="note-body"> <div - :class="{ - 'system-note-commit-list': hasMoreCommits, - 'hide-shade': expanded, - }" + :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }" class="note-text md" v-html="note.note_html" ></div> @@ -106,6 +117,12 @@ export default { <span>{{ __('Toggle commit list') }}</span> </div> </div> + <div v-if="shouldShowDescriptionVersion" class="description-version pt-2"> + <pre v-if="isLoadingDescriptionVersion" class="loading-state"> + <gl-skeleton-loading /> + </pre> + <pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre> + </div> </div> </div> </timeline-entry-item> 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 index 478e44d104c..f984a0a6203 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; import ProjectListItem from './project_list_item.vue'; const SEARCH_INPUT_TIMEOUT_MS = 500; @@ -10,6 +10,7 @@ export default { components: { GlLoadingIcon, GlSearchBoxByType, + GlInfiniteScroll, ProjectListItem, }, props: { @@ -41,6 +42,11 @@ export default { required: false, default: false, }, + totalResults: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -51,6 +57,9 @@ export default { projectClicked(project) { this.$emit('projectClicked', project); }, + bottomReached() { + this.$emit('bottomReached'); + }, isSelected(project) { return Boolean(_.find(this.selectedProjects, { id: project.id })); }, @@ -71,18 +80,25 @@ export default { @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> + <gl-loading-icon v-if="showLoadingIndicator" :size="1" class="py-2 px-4" /> + <gl-infinite-scroll + :max-list-height="402" + :fetched-items="projectSearchResults.length" + :total-items="totalResults" + @bottomReached="bottomReached" + > + <div v-if="!showLoadingIndicator" slot="items" 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> + </gl-infinite-scroll> <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message"> {{ __('Sorry, no projects matched your search') }} </div> diff --git a/app/assets/javascripts/vue_shared/components/slot_switch.vue b/app/assets/javascripts/vue_shared/components/slot_switch.vue new file mode 100644 index 00000000000..67726f01744 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/slot_switch.vue @@ -0,0 +1,35 @@ +<script> +/** + * Allows to toggle slots based on an array of slot names. + */ +export default { + name: 'SlotSwitch', + + props: { + activeSlotNames: { + type: Array, + required: true, + }, + + tagName: { + type: String, + required: false, + default: 'div', + }, + }, + + computed: { + allSlotNames() { + return Object.keys(this.$slots); + }, + }, +}; +</script> + +<template> + <component :is="tagName"> + <template v-for="slotName in allSlotNames"> + <slot v-if="activeSlotNames.includes(slotName)" :name="slotName"></slot> + </template> + </component> +</template> diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue new file mode 100644 index 00000000000..f7dc00a345c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -0,0 +1,76 @@ +<script> +import _ from 'underscore'; + +import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui'; + +const isValidItem = item => + _.isString(item.eventName) && _.isString(item.title) && _.isString(item.description); + +export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + }, + + props: { + actionItems: { + type: Array, + required: true, + validator(value) { + return value.length > 1 && value.every(isValidItem); + }, + }, + menuClass: { + type: String, + required: false, + default: '', + }, + }, + + data() { + return { + selectedItem: this.actionItems[0], + }; + }, + + computed: { + dropdownToggleText() { + return this.selectedItem.title; + }, + }, + + methods: { + triggerEvent() { + this.$emit(this.selectedItem.eventName); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :menu-class="`dropdown-menu-selectable ${menuClass}`" + split + :text="dropdownToggleText" + v-bind="$attrs" + @click="triggerEvent" + > + <template v-for="(item, itemIndex) in actionItems"> + <gl-dropdown-item + :key="item.eventName" + :active="selectedItem === item" + active-class="is-active" + @click="selectedItem = item" + > + <strong>{{ item.title }}</strong> + <div>{{ item.description }}</div> + </gl-dropdown-item> + + <gl-dropdown-divider + v-if="itemIndex < actionItems.length - 1" + :key="`${item.eventName}-divider`" + /> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index 8bcad7ac765..43935cf31d5 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -32,7 +32,7 @@ export default { </script> <template> <time - v-gl-tooltip="{ placement: tooltipPlacement }" + v-gl-tooltip.viewport="{ placement: tooltipPlacement }" :class="cssClass" :title="tooltipTitle(time)" v-text="timeFormated(time)" diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 7c7d46ee759..4a72cca5f02 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -51,7 +51,7 @@ export default { </script> <template> - <gl-popover :target="target" boundary="viewport" placement="top" show> + <gl-popover :target="target" boundary="viewport" placement="top" offset="0, 1" show> <div class="user-popover d-flex"> <div class="p-1 flex-shrink-1"> <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> @@ -90,7 +90,7 @@ export default { name="location" class="category-icon flex-shrink-0" /> - <span class="ml-1">{{ user.location }}</span> + <span v-if="user.location" class="ml-1">{{ user.location }}</span> <gl-skeleton-loading v-if="locationIsLoading" :lines="1" diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index 7a60ab1380f..044d703630e 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, camelcase, class-methods-use-this */ +/* eslint-disable consistent-return, camelcase, class-methods-use-this */ // Zen Mode (full screen) textarea // @@ -47,26 +47,16 @@ export default class ZenMode { e.preventDefault(); return $(e.currentTarget).trigger('zen_mode:leave'); }); - $(document).on( - 'zen_mode:enter', - (function(_this) { - return function(e) { - return _this.enter( - $(e.target) - .closest('.md-area') - .find('.zen-backdrop'), - ); - }; - })(this), - ); - $(document).on( - 'zen_mode:leave', - (function(_this) { - return function() { - return _this.exit(); - }; - })(this), - ); + $(document).on('zen_mode:enter', e => { + this.enter( + $(e.target) + .closest('.md-area') + .find('.zen-backdrop'), + ); + }); + $(document).on('zen_mode:leave', () => { + this.exit(); + }); $(document).on('keydown', e => { // Esc if (e.keyCode === 27) { |