diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-21 15:21:10 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-21 15:21:10 +0000 |
commit | e33f87ac0fabaab468ce4b457996cc0f1b1bb648 (patch) | |
tree | 8bf0de72a9acac014cfdaddab7d463b208294af2 /app/assets/javascripts | |
parent | 5baf990db20a75078684702782c24399ef9eb0fa (diff) | |
download | gitlab-ce-e33f87ac0fabaab468ce4b457996cc0f1b1bb648.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
128 files changed, 3345 insertions, 807 deletions
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue new file mode 100644 index 00000000000..f7910e5d3fa --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue @@ -0,0 +1,62 @@ +<script> +import { GlEmptyState, GlButton, GlLoadingIcon } from '@gitlab/ui'; + +export default { + components: { + GlEmptyState, + GlButton, + GlLoadingIcon, + }, + props: { + indexPath: { + type: String, + required: true, + }, + enableAlertManagementPath: { + type: String, + required: true, + }, + emptyAlertSvgPath: { + type: String, + required: true, + }, + }, + data() { + return { + alerts: [], + loading: false, + }; + }, +}; +</script> + +<template> + <div> + <div v-if="alerts.length > 0" class="alert-management-list"> + <div v-if="loading" class="py-3"> + <gl-loading-icon size="md" /> + </div> + </div> + <template v-else> + <gl-empty-state :title="__('Surface alerts in GitLab')" :svg-path="emptyAlertSvgPath"> + <template #description> + <div class="d-block"> + <span>{{ + __( + 'Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.', + ) + }}</span> + <a href="/help/user/project/operations/alert_management.html"> + {{ __('More information') }} + </a> + </div> + <div class="d-block center pt-4"> + <gl-button category="primary" variant="success" :href="enableAlertManagementPath">{{ + __('Authorize external service') + }}</gl-button> + </div> + </template> + </gl-empty-state> + </template> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js new file mode 100644 index 00000000000..9f0efbc999a --- /dev/null +++ b/app/assets/javascripts/alert_management/list.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import AlertManagementList from './components/alert_management_list.vue'; + +export default () => { + const selector = '#js-alert_management'; + + const domEl = document.querySelector(selector); + const { indexPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset; + + return new Vue({ + el: selector, + components: { + AlertManagementList, + }, + render(createElement) { + return createElement('alert-management-list', { + props: { + indexPath, + enableAlertManagementPath, + emptyAlertSvgPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/alert_management/services/index.js b/app/assets/javascripts/alert_management/services/index.js new file mode 100644 index 00000000000..787603d3e7a --- /dev/null +++ b/app/assets/javascripts/alert_management/services/index.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + getAlertManagementList({ endpoint }) { + return axios.get(endpoint); + }, +}; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 6301f6a3910..904bf117dc0 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -46,6 +46,7 @@ const Api = { mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', adminStatisticsPath: '/api/:version/application/statistics', pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', + pipelinesPath: '/api/:version/projects/:id/pipelines/', environmentsPath: '/api/:version/projects/:id/environments', rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw', @@ -502,6 +503,15 @@ const Api = { return axios.get(url); }, + // Return all pipelines for a project or filter by query params + pipelines(id, options = {}) { + const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url, { + params: options, + }); + }, + environments(id) { const url = Api.buildUrl(this.environmentsPath).replace(':id', encodeURIComponent(id)); return axios.get(url); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 67164997bd8..8381b050900 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this, @gitlab/require-i18n-strings */ import $ from 'jquery'; -import _ from 'underscore'; +import { uniq } from 'lodash'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import { __ } from './locale'; @@ -513,7 +513,7 @@ export class AwardsHandler { addEmojiToFrequentlyUsedList(emoji) { if (this.emoji.isEmojiNameValid(emoji)) { - this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji)); + this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji)); Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 }); } } @@ -522,9 +522,7 @@ export class AwardsHandler { return ( this.frequentlyUsedEmojis || (() => { - const frequentlyUsedEmojis = _.uniq( - (Cookies.get('frequently_used_emojis') || '').split(','), - ); + const frequentlyUsedEmojis = uniq((Cookies.get('frequently_used_emojis') || '').split(',')); this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(inputName => this.emoji.isEmojiNameValid(inputName), ); diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue index 9a30ed93330..056b4ea4aa8 100644 --- a/app/assets/javascripts/blob/components/blob_edit_content.vue +++ b/app/assets/javascripts/blob/components/blob_edit_content.vue @@ -1,5 +1,6 @@ <script> import { initEditorLite } from '~/blob/utils'; +import { debounce } from 'lodash'; export default { props: { @@ -32,16 +33,14 @@ export default { }); }, methods: { - triggerFileChange() { + triggerFileChange: debounce(function debouncedFileChange() { this.$emit('input', this.editor.getValue()); - }, + }, 250), }, }; </script> <template> <div class="file-content code"> - <pre id="editor" ref="editor" data-editor-loading @focusout="triggerFileChange">{{ - value - }}</pre> + <pre id="editor" ref="editor" data-editor-loading @keyup="triggerFileChange">{{ value }}</pre> </div> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue new file mode 100644 index 00000000000..f5c2cc57f3f --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue @@ -0,0 +1,169 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; + +export default { + name: 'CiKeyField', + components: { + GlButton, + GlFormGroup, + GlFormInput, + }, + model: { + prop: 'value', + event: 'input', + }, + props: { + tokenList: { + type: Array, + required: true, + }, + value: { + type: String, + required: true, + }, + }, + data() { + return { + results: [], + arrowCounter: -1, + userDismissedResults: false, + suggestionsId: uniqueId('token-suggestions-'), + }; + }, + computed: { + showAutocomplete() { + return this.showSuggestions ? 'off' : 'on'; + }, + showSuggestions() { + return this.results.length > 0; + }, + }, + mounted() { + document.addEventListener('click', this.handleClickOutside); + }, + destroyed() { + document.removeEventListener('click', this.handleClickOutside); + }, + methods: { + closeSuggestions() { + this.results = []; + this.arrowCounter = -1; + }, + handleClickOutside(event) { + if (!this.$el.contains(event.target)) { + this.closeSuggestions(); + } + }, + onArrowDown() { + const newCount = this.arrowCounter + 1; + + if (newCount >= this.results.length) { + this.arrowCounter = 0; + return; + } + + this.arrowCounter = newCount; + }, + onArrowUp() { + const newCount = this.arrowCounter - 1; + + if (newCount < 0) { + this.arrowCounter = this.results.length - 1; + return; + } + + this.arrowCounter = newCount; + }, + onEnter() { + const currentToken = this.results[this.arrowCounter] || this.value; + this.selectToken(currentToken); + }, + onEsc() { + if (!this.showSuggestions) { + this.$emit('input', ''); + } + this.closeSuggestions(); + this.userDismissedResults = true; + }, + onEntry(value) { + this.$emit('input', value); + this.userDismissedResults = false; + + // short circuit so that we don't false match on empty string + if (value.length < 1) { + this.closeSuggestions(); + return; + } + + const filteredTokens = this.tokenList.filter(token => + token.toLowerCase().includes(value.toLowerCase()), + ); + + if (filteredTokens.length) { + this.openSuggestions(filteredTokens); + } else { + this.closeSuggestions(); + } + }, + openSuggestions(filteredResults) { + this.results = filteredResults; + }, + selectToken(value) { + this.$emit('input', value); + this.closeSuggestions(); + this.$emit('key-selected'); + }, + }, +}; +</script> +<template> + <div> + <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions"> + <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <gl-form-input + id="ci-variable-key" + :value="value" + type="text" + role="searchbox" + class="form-control pl-2 js-env-input" + :autocomplete="showAutocomplete" + aria-autocomplete="list" + aria-controls="token-suggestions" + aria-haspopup="listbox" + :aria-expanded="showSuggestions" + data-qa-selector="ci_variable_key_field" + @input="onEntry" + @keydown.down="onArrowDown" + @keydown.up="onArrowUp" + @keydown.enter.prevent="onEnter" + @keydown.esc.stop="onEsc" + @keydown.tab="closeSuggestions" + /> + </gl-form-group> + + <div + v-show="showSuggestions && !userDismissedResults" + id="ci-variable-dropdown" + class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width" + :class="{ 'd-block': showSuggestions }" + > + <div class="dropdown-content"> + <ul :id="suggestionsId"> + <li + v-for="(result, i) in results" + :key="i" + role="option" + :class="{ 'gl-bg-gray-100': i === arrowCounter }" + :aria-selected="i === arrowCounter" + > + <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{ + result + }}</gl-button> + </li> + </ul> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js new file mode 100644 index 00000000000..9022bf51514 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js @@ -0,0 +1,29 @@ +import { __ } from '~/locale'; + +import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants'; + +export const awsTokens = { + [AWS_ACCESS_KEY_ID]: { + name: AWS_ACCESS_KEY_ID, + /* Checks for exactly twenty characters that match key. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9]{20}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, + [AWS_DEFAULT_REGION]: { + name: AWS_DEFAULT_REGION, + }, + [AWS_SECRET_ACCESS_KEY]: { + name: AWS_SECRET_ACCESS_KEY, + /* Checks for exactly forty characters that match secret. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, +}; + +export const awsTokenList = Object.keys(awsTokens); diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 316408adfb2..8f5acd4a0a0 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -1,8 +1,4 @@ <script> -import { __ } from '~/locale'; -import { mapActions, mapState } from 'vuex'; -import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; -import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { GlDeprecatedButton, GlModal, @@ -14,11 +10,19 @@ import { GlLink, GlIcon, } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; +import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; +import CiKeyField from './ci_key_field.vue'; +import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; export default { modalId: ADD_CI_VARIABLE_MODAL_ID, components: { CiEnvironmentsDropdown, + CiKeyField, GlDeprecatedButton, GlModal, GlFormSelect, @@ -29,6 +33,9 @@ export default { GlLink, GlIcon, }, + mixins: [glFeatureFlagsMixin()], + tokens: awsTokens, + tokenList: awsTokenList, computed: { ...mapState([ 'projectId', @@ -41,23 +48,24 @@ export default { 'selectedEnvironment', ]), canSubmit() { - if (this.variableData.masked && this.maskedState === false) { - return false; - } - return this.variableData.key !== '' && this.variableData.secret_value !== ''; + return ( + this.variableValidationState && + this.variableData.key !== '' && + this.variableData.secret_value !== '' + ); }, canMask() { const regex = RegExp(this.maskableRegex); return regex.test(this.variableData.secret_value); }, displayMaskedError() { - return !this.canMask && this.variableData.masked && this.variableData.secret_value !== ''; + return !this.canMask && this.variableData.masked; }, maskedState() { if (this.displayMaskedError) { return false; } - return null; + return true; }, variableData() { return this.variableBeingEdited || this.variable; @@ -66,7 +74,41 @@ export default { return this.variableBeingEdited ? __('Update variable') : __('Add variable'); }, maskedFeedback() { - return __('This variable can not be masked'); + return this.displayMaskedError ? __('This variable can not be masked.') : ''; + }, + tokenValidationFeedback() { + const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage; + if (!this.tokenValidationState && tokenSpecificFeedback) { + return tokenSpecificFeedback; + } + return ''; + }, + tokenValidationState() { + // If the feature flag is off, do not validate. Remove when flag is removed. + if (!this.glFeatures.ciKeyAutocomplete) { + return true; + } + + const validator = this.$options.tokens?.[this.variableData.key]?.validation; + + if (validator) { + return validator(this.variableData.secret_value); + } + + return true; + }, + variableValidationFeedback() { + return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; + }, + variableValidationState() { + if ( + this.variableData.secret_value === '' || + (this.tokenValidationState && this.maskedState) + ) { + return true; + } + + return false; }, }, methods: { @@ -82,14 +124,13 @@ export default { 'resetSelectedEnvironment', 'setSelectedEnvironment', ]), - updateOrAddVariable() { - if (this.variableBeingEdited) { - this.updateVariable(this.variableBeingEdited); - } else { - this.addVariable(); - } + deleteVarAndClose() { + this.deleteVariable(this.variableBeingEdited); this.hideModal(); }, + hideModal() { + this.$refs.modal.hide(); + }, resetModalHandler() { if (this.variableBeingEdited) { this.resetEditing(); @@ -98,11 +139,12 @@ export default { } this.resetSelectedEnvironment(); }, - hideModal() { - this.$refs.modal.hide(); - }, - deleteVarAndClose() { - this.deleteVariable(this.variableBeingEdited); + updateOrAddVariable() { + if (this.variableBeingEdited) { + this.updateVariable(this.variableBeingEdited); + } else { + this.addVariable(); + } this.hideModal(); }, }, @@ -119,7 +161,13 @@ export default { @hidden="resetModalHandler" > <form> - <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <ci-key-field + v-if="glFeatures.ciKeyAutocomplete" + v-model="variableData.key" + :token-list="$options.tokenList" + /> + + <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> <gl-form-input id="ci-variable-key" v-model="variableData.key" @@ -130,12 +178,14 @@ export default { <gl-form-group :label="__('Value')" label-for="ci-variable-value" - :state="maskedState" - :invalid-feedback="maskedFeedback" + :state="variableValidationState" + :invalid-feedback="variableValidationFeedback" > <gl-form-textarea id="ci-variable-value" + ref="valueField" v-model="variableData.secret_value" + :state="variableValidationState" rows="3" max-rows="6" data-qa-selector="ci_variable_value_field" diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index d22138db102..5fe1e32e37e 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -14,3 +14,8 @@ export const types = { fileType: 'file', allEnvironmentsType: '*', }; + +// AWS TOKEN CONSTANTS +export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; +export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; +export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 1b11ec355bb..106e15d9382 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -49,6 +49,7 @@ export default class Clusters { installElasticStackPath, installCrossplanePath, installPrometheusPath, + installFluentdPath, managePrometheusPath, clusterEnvironmentsPath, hasRbac, @@ -102,6 +103,7 @@ export default class Clusters { updateKnativeEndpoint: updateKnativePath, installElasticStackEndpoint: installElasticStackPath, clusterEnvironmentsEndpoint: clusterEnvironmentsPath, + installFluentdEndpoint: installFluentdPath, }); this.installApplication = this.installApplication.bind(this); @@ -265,6 +267,7 @@ export default class Clusters { eventHub.$on('setIngressModSecurityEnabled', data => this.setIngressModSecurityEnabled(data)); eventHub.$on('setIngressModSecurityMode', data => this.setIngressModSecurityMode(data)); eventHub.$on('resetIngressModSecurityChanges', id => this.resetIngressModSecurityChanges(id)); + eventHub.$on('setFluentdSettings', data => this.setFluentdSettings(data)); // Add event listener to all the banner close buttons this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); @@ -281,6 +284,7 @@ export default class Clusters { eventHub.$off('setIngressModSecurityEnabled'); eventHub.$off('setIngressModSecurityMode'); eventHub.$off('resetIngressModSecurityChanges'); + eventHub.$off('setFluentdSettings'); } initPolling(method, successCallback, errorCallback) { @@ -506,6 +510,12 @@ export default class Clusters { }); } + setFluentdSettings({ id: appId, port, protocol, host }) { + this.store.updateAppProperty(appId, 'port', port); + this.store.updateAppProperty(appId, 'protocol', protocol); + this.store.updateAppProperty(appId, 'host', host); + } + toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) { if (externalIp !== newExternalIp) { this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp); diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 723030c5b8b..96c00480dfd 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -14,6 +14,7 @@ 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 fluentdLogo from 'images/cluster_app_logos/fluentd.png'; import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; @@ -22,6 +23,7 @@ import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../con import eventHub from '~/clusters/event_hub'; import CrossplaneProviderStack from './crossplane_provider_stack.vue'; import IngressModsecuritySettings from './ingress_modsecurity_settings.vue'; +import FluentdOutputSettings from './fluentd_output_settings.vue'; export default { components: { @@ -31,6 +33,7 @@ export default { KnativeDomainEditor, CrossplaneProviderStack, IngressModsecuritySettings, + FluentdOutputSettings, }, props: { type: { @@ -102,6 +105,7 @@ export default { meltanoLogo, prometheusLogo, elasticStackLogo, + fluentdLogo, }), computed: { isProjectCluster() { @@ -670,6 +674,41 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity </p> </div> </application-row> + + <application-row + id="fluentd" + :logo-url="fluentdLogo" + :title="applications.fluentd.title" + :status="applications.fluentd.status" + :status-reason="applications.fluentd.statusReason" + :request-status="applications.fluentd.requestStatus" + :request-reason="applications.fluentd.requestReason" + :installed="applications.fluentd.installed" + :install-failed="applications.fluentd.installFailed" + :install-application-request-params="{ + host: applications.fluentd.host, + port: applications.fluentd.port, + protocol: applications.fluentd.protocol, + }" + :uninstallable="applications.fluentd.uninstallable" + :uninstall-successful="applications.fluentd.uninstallSuccessful" + :uninstall-failed="applications.fluentd.uninstallFailed" + :disabled="!helmInstalled" + :updateable="false" + title-link="https://github.com/helm/charts/tree/master/stable/fluentd" + > + <div slot="description"> + <p> + {{ + s__( + `ClusterIntegration|Fluentd is an open source data collector, which lets you unify the data collection and consumption for a better use and understanding of data. Export Web Application Firewall logs to your favorite SIEM.`, + ) + }} + </p> + + <fluentd-output-settings :fluentd="applications.fluentd" /> + </div> + </application-row> </div> </section> </template> diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue new file mode 100644 index 00000000000..97b030927df --- /dev/null +++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue @@ -0,0 +1,159 @@ +<script> +import { __ } from '~/locale'; +import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; +import { GlAlert, GlDeprecatedButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import eventHub from '~/clusters/event_hub'; + +const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS; + +export default { + components: { + GlAlert, + GlDeprecatedButton, + GlDropdown, + GlDropdownItem, + }, + props: { + fluentd: { + type: Object, + required: true, + }, + protocols: { + type: Array, + required: false, + default: () => ['TCP', 'UDP'], + }, + }, + computed: { + isSaving() { + return [UPDATING].includes(this.fluentd.status); + }, + saveButtonDisabled() { + return [UNINSTALLING, UPDATING, INSTALLING].includes(this.fluentd.status); + }, + saveButtonLabel() { + return this.isSaving ? __('Saving') : __('Save changes'); + }, + /** + * Returns true either when: + * - The application is getting updated. + * - The user has changed some of the settings for an application which is + * neither getting installed nor updated. + */ + showButtons() { + return ( + this.isSaving || + (this.fluentd.isEditingSettings && [INSTALLED, UPDATED].includes(this.fluentd.status)) + ); + }, + protocolName() { + if (this.fluentd.protocol !== null && this.fluentd.protocol !== undefined) { + return this.fluentd.protocol.toUpperCase(); + } + return __('Protocol'); + }, + fluentdPort: { + get() { + return this.fluentd.port; + }, + set(port) { + this.setFluentSettings({ port }); + }, + }, + fluentdHost: { + get() { + return this.fluentd.host; + }, + set(host) { + this.setFluentSettings({ host }); + }, + }, + }, + methods: { + updateApplication() { + eventHub.$emit('updateApplication', { + id: FLUENTD, + params: { + port: this.fluentd.port, + protocol: this.fluentd.protocol, + host: this.fluentd.host, + }, + }); + this.resetStatus(); + }, + resetStatus() { + this.fluentd.isEditingSettings = false; + }, + selectProtocol(protocol) { + this.setFluentSettings({ protocol }); + }, + setFluentSettings({ port, protocol, host }) { + this.fluentd.isEditingSettings = true; + const newPort = port !== undefined ? port : this.fluentd.port; + const newProtocol = protocol !== undefined ? protocol : this.fluentd.protocol; + const newHost = host !== undefined ? host : this.fluentd.host; + eventHub.$emit('setFluentdSettings', { + id: FLUENTD, + port: newPort, + protocol: newProtocol, + host: newHost, + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="fluentd.updateFailed" class="mb-3" variant="danger" :dismissible="false"> + {{ + s__( + 'ClusterIntegration|Something went wrong while trying to save your settings. Please try again.', + ) + }} + </gl-alert> + <div class="form-horizontal"> + <div class="form-group"> + <label for="fluentd-host"> + <strong>{{ s__('ClusterIntegration|SIEM Hostname') }}</strong> + </label> + <input id="fluentd-host" v-model="fluentdHost" type="text" class="form-control" /> + </div> + <div class="form-group"> + <label for="fluentd-port"> + <strong>{{ s__('ClusterIntegration|SIEM Port') }}</strong> + </label> + <input id="fluentd-port" v-model="fluentdPort" type="text" class="form-control" /> + </div> + <div class="form-group"> + <label for="fluentd-protocol"> + <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong> + </label> + <gl-dropdown :text="protocolName" class="w-100"> + <gl-dropdown-item + v-for="(value, index) in protocols" + :key="index" + @click="selectProtocol(value)" + > + {{ value }} + </gl-dropdown-item> + </gl-dropdown> + </div> + <div v-if="showButtons" class="mt-3"> + <gl-deprecated-button + ref="saveBtn" + class="mr-1" + variant="success" + :loading="isSaving" + :disabled="saveButtonDisabled" + @click="updateApplication" + > + {{ saveButtonLabel }} + </gl-deprecated-button> + <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus"> + {{ __('Cancel') }} + </gl-deprecated-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 6c3046fc56b..60e179c54eb 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -53,6 +53,7 @@ export const CERT_MANAGER = 'cert_manager'; export const CROSSPLANE = 'crossplane'; export const PROMETHEUS = 'prometheus'; export const ELASTIC_STACK = 'elastic_stack'; +export const FLUENTD = 'fluentd'; export const APPLICATIONS = [ HELM, @@ -63,6 +64,7 @@ export const APPLICATIONS = [ CERT_MANAGER, PROMETHEUS, ELASTIC_STACK, + FLUENTD, ]; 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 333fb293a15..2a6c6965dab 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -13,6 +13,7 @@ export default class ClusterService { jupyter: this.options.installJupyterEndpoint, knative: this.options.installKnativeEndpoint, elastic_stack: this.options.installElasticStackEndpoint, + fluentd: this.options.installFluentdEndpoint, }; 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 b09fd6800b6..ca96eb0acea 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -13,6 +13,7 @@ import { UPDATE_EVENT, UNINSTALL_EVENT, ELASTIC_STACK, + FLUENTD, } from '../constants'; import transitionApplicationState from '../services/application_state_machine'; @@ -103,6 +104,14 @@ export default class ClusterStore { ...applicationInitialState, title: s__('ClusterIntegration|Elastic Stack'), }, + fluentd: { + ...applicationInitialState, + title: s__('ClusterIntegration|Fluentd'), + host: null, + port: null, + protocol: null, + isEditingSettings: false, + }, }, environments: [], fetchingEnvironments: false, @@ -253,6 +262,12 @@ export default class ClusterStore { } else if (appId === ELASTIC_STACK) { this.state.applications.elastic_stack.version = version; this.state.applications.elastic_stack.updateAvailable = updateAvailable; + } else if (appId === FLUENTD) { + if (!this.state.applications.fluentd.isEditingSettings) { + this.state.applications.fluentd.port = serverAppEntry.port; + this.state.applications.fluentd.host = serverAppEntry.host; + this.state.applications.fluentd.protocol = serverAppEntry.protocol; + } } }); } diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index ad0f6cc1496..e0d012cef23 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -1,4 +1,3 @@ -import 'underscore'; import './polyfills'; import './jquery'; import './bootstrap'; diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 51879f280e0..41988f321e5 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -1,6 +1,6 @@ import $ from 'jquery'; +import { debounce } from 'lodash'; import Cookies from 'js-cookie'; -import _ from 'underscore'; import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -43,7 +43,7 @@ export default class ContextualSidebar { $(document).trigger('content.resize'); }); - $(window).on('resize', () => _.debounce(this.render(), 100)); + $(window).on('resize', debounce(() => this.render(), 100)); } // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 229612f5e9d..ba585444ba5 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -1,5 +1,5 @@ /* eslint-disable no-new */ -import _ from 'underscore'; +import { debounce } from 'lodash'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; import DropLab from './droplab/drop_lab'; @@ -55,7 +55,7 @@ export default class CreateMergeRequestDropdown { this.isCreatingMergeRequest = false; this.isGettingRef = false; this.mergeRequestCreated = false; - this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500); + this.refDebounce = debounce((value, target) => this.getRef(value, target), 500); this.refIsValid = true; this.refsPath = this.wrapperEl.dataset.refsPath; this.suggestedRef = this.refInput.value; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 6d2b11e39d3..f609ca5f22d 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -59,16 +59,10 @@ export default () => { service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath), }; }, - defaultNumberOfSummaryItems: 3, computed: { currentStage() { return this.store.currentActiveStage(); }, - summaryTableColumnClass() { - return this.state.summary.length === this.$options.defaultNumberOfSummaryItems - ? 'col-sm-3' - : 'col-sm-4'; - }, }, created() { // Conditional check placed here to prevent this method from being called on the diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 9544fbe9fc5..514d26862a3 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -99,8 +99,12 @@ export default { return this.showCommentButton && this.hasDiscussions; }, shouldRenderCommentButton() { - const isDiffHead = parseBoolean(getParameterByName('diff_head')); - return !isDiffHead && this.isLoggedIn && this.showCommentButton; + if (this.isLoggedIn && this.showCommentButton) { + const isDiffHead = parseBoolean(getParameterByName('diff_head')); + return !isDiffHead || gon.features?.mergeRefHeadComments; + } + + return false; }, isMatchLine() { return this.line.type === MATCH_LINE_TYPE; diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index b07dfe5f33d..40e1aec42ed 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -60,3 +60,4 @@ export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines'; export const DIFFS_PER_PAGE = 20; export const DIFF_COMPARE_BASE_VERSION_INDEX = -1; +export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2; diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js index 14c51602f28..dd682060b4b 100644 --- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js +++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js @@ -1,5 +1,6 @@ import { __, n__, sprintf } from '~/locale'; -import { DIFF_COMPARE_BASE_VERSION_INDEX } from '../constants'; +import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { DIFF_COMPARE_BASE_VERSION_INDEX, DIFF_COMPARE_HEAD_VERSION_INDEX } from '../constants'; export const selectedTargetIndex = state => state.startVersion?.version_index || DIFF_COMPARE_BASE_VERSION_INDEX; @@ -9,12 +10,25 @@ export const selectedSourceIndex = state => state.mergeRequestDiff.version_index export const diffCompareDropdownTargetVersions = (state, getters) => { // startVersion only exists if the user has selected a version other // than "base" so if startVersion is null then base must be selected + + const diffHead = parseBoolean(getParameterByName('diff_head')); + const isBaseSelected = !state.startVersion && !diffHead; + const isHeadSelected = !state.startVersion && diffHead; + const baseVersion = { versionName: state.targetBranchName, version_index: DIFF_COMPARE_BASE_VERSION_INDEX, href: state.mergeRequestDiff.base_version_path, isBase: true, - selected: !state.startVersion, + selected: isBaseSelected, + }; + + const headVersion = { + versionName: state.targetBranchName, + version_index: DIFF_COMPARE_HEAD_VERSION_INDEX, + href: state.mergeRequestDiff.head_version_path, + isHead: true, + selected: isHeadSelected, }; // Appended properties here are to make the compare_dropdown_layout easier to reason about const formatVersion = v => { @@ -25,7 +39,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => { ...v, }; }; - return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion]; + return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion]; }; export const diffCompareDropdownSourceVersions = (state, getters) => { diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index cc9bfa2e174..104686993a8 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -182,15 +182,18 @@ export default { [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { const { latestDiff } = state; - const discussionLineCode = discussion.line_code; + const discussionLineCodes = [discussion.line_code, ...(discussion.line_codes || [])]; const fileHash = discussion.diff_file.file_hash; const lineCheck = line => - line.line_code === discussionLineCode && - isDiscussionApplicableToLine({ - discussion, - diffPosition: diffPositionByLineCode[line.line_code], - latestDiff, - }); + discussionLineCodes.some( + discussionLineCode => + line.line_code === discussionLineCode && + isDiscussionApplicableToLine({ + discussion, + diffPosition: diffPositionByLineCode[line.line_code], + latestDiff, + }), + ); const mapDiscussions = (line, extraCheck = () => true) => ({ ...line, discussions: extraCheck() diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 07879ebf7d5..dd8dec49a37 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -440,10 +440,13 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD const { line_code, ...diffPositionCopy } = diffPosition; if (discussion.original_position && discussion.position) { - const originalRefs = discussion.original_position; - const refs = discussion.position; + const discussionPositions = [ + discussion.original_position, + discussion.position, + ...(discussion.positions || []), + ]; - return isEqual(refs, diffPositionCopy) || isEqual(originalRefs, diffPositionCopy); + return discussionPositions.some(position => isEqual(position, diffPositionCopy)); } // eslint-disable-next-line diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index f839e9acf04..490f2330012 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import Dropzone from 'dropzone'; -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import './behaviors/preview_markdown'; import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table'; import csrf from './lib/utils/csrf'; @@ -16,7 +16,7 @@ Dropzone.autoDiscover = false; * @param {String|Object} res */ function getErrorMessage(res) { - if (!res || _.isString(res)) { + if (!res || typeof res === 'string') { return res; } @@ -233,7 +233,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { }; addFileToForm = path => { - $(form).append(`<input type="hidden" name="files[]" value="${_.escape(path)}">`); + $(form).append(`<input type="hidden" name="files[]" value="${esc(path)}">`); }; const showSpinner = () => $uploadingProgressContainer.removeClass('hide'); diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index be2eee828ff..4aad54bed55 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { debounce } from 'lodash'; import axios from './lib/utils/axios_utils'; /** @@ -29,7 +29,7 @@ export default class FilterableList { initSearch() { // Wrap to prevent passing event arguments to .filterResults; - this.debounceFilter = _.debounce(this.onFilterInput.bind(this), 500); + this.debounceFilter = debounce(this.onFilterInput.bind(this), 500); this.unbindEvents(); this.bindEvents(); diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 4d62ec6e385..40d820b1ed5 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import { spriteIcon } from './lib/utils/common_utils'; const FLASH_TYPES = { @@ -39,14 +39,14 @@ const createAction = config => ` class="flash-action" ${config.href ? '' : 'role="button"'} > - ${_.escape(config.title)} + ${esc(config.title)} </a> `; const createFlashEl = (message, type) => ` <div class="flash-${type}"> <div class="flash-text"> - ${_.escape(message)} + ${esc(message)} <div class="close-icon-wrapper js-close-icon"> ${spriteIcon('close', 'close-icon')} </div> diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b6deedfa5e4..c40b0949e70 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '@gitlab/at.js'; -import _ from 'underscore'; +import { escape as esc, template } from 'lodash'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; @@ -11,7 +11,7 @@ function sanitize(str) { } export function membersBeforeSave(members) { - return _.map(members, member => { + return members.map(member => { const GROUP_TYPE = 'Group'; let title = ''; @@ -122,7 +122,7 @@ class GfmAutoComplete { cssClasses.push('has-warning'); } - return _.template(tpl)({ + return template(tpl)({ ...value, className: cssClasses.join(' '), }); @@ -137,7 +137,7 @@ class GfmAutoComplete { tpl += '<%- referencePrefix %>'; } } - return _.template(tpl)({ referencePrefix }); + return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix }); }, suffix: '', callbacks: { @@ -692,14 +692,14 @@ GfmAutoComplete.Emoji = { // Team Members GfmAutoComplete.Members = { templateFunction({ avatarTag, username, title, icon }) { - return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small> ${icon}</li>`; + return `<li>${avatarTag} ${username} <small>${esc(title)}</small> ${icon}</li>`; }, }; GfmAutoComplete.Labels = { templateFunction(color, title) { - return `<li><span class="dropdown-label-box" style="background: ${_.escape( - color, - )}"></span> ${_.escape(title)}</li>`; + return `<li><span class="dropdown-label-box" style="background: ${esc(color)}"></span> ${esc( + title, + )}</li>`; }, }; // Issues, MergeRequests and Snippets @@ -709,13 +709,13 @@ GfmAutoComplete.Issues = { return value.reference || '${atwho-at}${id}'; }, templateFunction({ id, title, reference }) { - return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`; + return `<li><small>${reference || id}</small> ${esc(title)}</li>`; }, }; // Milestones GfmAutoComplete.Milestones = { templateFunction(title) { - return `<li>${_.escape(title)}</li>`; + return `<li>${esc(title)}</li>`; }, }; GfmAutoComplete.Loading = { diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 918276ce329..d9191d48d8f 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,7 +1,7 @@ /* eslint-disable max-classes-per-file, one-var, consistent-return */ import $ from 'jquery'; -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import axios from './lib/utils/axios_utils'; import { visitUrl } from './lib/utils/url_utility'; @@ -145,7 +145,7 @@ class GitLabDropdownFilter { // { prop: 'foo' }, // { prop: 'baz' } // ] - if (_.isArray(data)) { + if (Array.isArray(data)) { results = fuzzaldrinPlus.filter(data, searchText, { key: this.options.keys, }); @@ -261,14 +261,14 @@ class GitLabDropdown { // If no input is passed create a default one self = this; // If selector was passed - if (_.isString(this.filterInput)) { + if (typeof this.filterInput === 'string') { this.filterInput = this.getElement(this.filterInput); } const searchFields = this.options.search ? this.options.search.fields : []; if (this.options.data) { // If we provided data // data could be an array of objects or a group of arrays - if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { + if (typeof this.options.data === 'object' && !(this.options.data instanceof Function)) { this.fullData = this.options.data; currentIndex = -1; this.parseData(this.options.data); @@ -610,7 +610,7 @@ class GitLabDropdown { // eslint-disable-next-line class-methods-use-this highlightTemplate(text, template) { - return `"<b>${_.escape(text)}</b>" ${template}`; + return `"<b>${esc(text)}</b>" ${template}`; } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 1811a942beb..ced10fff129 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -29,6 +29,10 @@ export default class GLForm { if (this.autoComplete) { this.autoComplete.destroy(); } + if (this.formDropzone) { + this.formDropzone.destroy(); + } + this.form.data('glForm', null); } @@ -45,7 +49,7 @@ export default class GLForm { ); this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM); - dropzoneInput(this.form, { parallelUploads: 1 }); + this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 }); autosize(this.textarea); } // form and textarea event listeners diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index f0f5b8395c9..c7acc21378b 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -32,7 +32,7 @@ export default { }, methods: { change(page) { - const filterGroupsParam = getParameterByName('filter_groups'); + const filterGroupsParam = getParameterByName('filter'); const sortParam = getParameterByName('sort'); const archivedParam = getParameterByName('archived'); eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam); diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 7ebcacc530f..40d36063391 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -12,6 +12,7 @@ import RepoEditor from './repo_editor.vue'; import RightPane from './panes/right.vue'; import ErrorMessage from './error_message.vue'; import CommitEditorHeader from './commit_sidebar/editor_header.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -26,6 +27,7 @@ export default { GlDeprecatedButton, GlLoadingIcon, }, + mixins: [glFeatureFlagsMixin()], props: { rightPaneComponent: { type: Vue.Component, @@ -52,10 +54,17 @@ export default { 'allBlobs', 'emptyRepo', 'currentTree', + 'editorTheme', ]), + themeName() { + return this.glFeatures.webideDarkTheme && window.gon?.user_color_scheme; + }, }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); + + if (this.themeName) + document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`); }, methods: { ...mapActions(['toggleFileFinder', 'openNewEntryModal']), @@ -77,7 +86,10 @@ export default { </script> <template> - <article class="ide position-relative d-flex flex-column align-items-stretch"> + <article + class="ide position-relative d-flex flex-column align-items-stretch" + :class="{ [`theme-${themeName}`]: themeName }" + > <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> <find-file diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 1ffd5c61282..35d54816350 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import { __, sprintf } from './locale'; import axios from './lib/utils/axios_utils'; import flash from './flash'; @@ -73,9 +73,9 @@ class ImporterStatus { const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing'); job.find('.import-actions').html( sprintf( - _.escape(__('%{loadingIcon} Started')), + esc(__('%{loadingIcon} Started')), { - loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${_.escape( + loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${esc( connectingVerb, )}"></i>`, }, diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index 45de287d44d..95e10cc75cc 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,7 +1,7 @@ /* eslint-disable consistent-return, func-names, array-callback-return */ import $ from 'jquery'; -import _ from 'underscore'; +import { intersection } from 'lodash'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; import { __ } from './locale'; @@ -111,7 +111,7 @@ export default { this.getElement('.selected-issuable:checked').each((i, el) => { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); }); - return _.intersection.apply(this, labelIds); + return intersection.apply(this, labelIds); }, // From issuable's initial bulk selection @@ -120,7 +120,7 @@ export default { this.getElement('.selected-issuable:checked').each((i, el) => { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); }); - return _.intersection.apply(this, labelIds); + return intersection.apply(this, labelIds); }, // From issuable's initial bulk selection @@ -144,7 +144,7 @@ export default { // Add uniqueIds to add it as argument for _.intersection labelIds.unshift(uniqueIds); // Return IDs that are present but not in all selected issueables - return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); + return uniqueIds.filter(x => !intersection.apply(this, labelIds).includes(x)); }, getElement(selector) { diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index bd6e8433544..50562688c53 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this, no-new */ import $ from 'jquery'; -import { property } from 'underscore'; +import { property } from 'lodash'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import MilestoneSelect from './milestone_select'; import issueStatusSelect from './issue_status_select'; diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 9136a47d542..f0967e77faf 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -12,6 +12,8 @@ export default class Issue { constructor() { if ($('a.btn-close').length) this.initIssueBtnEventListeners(); + if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener(); + Issue.$btnNewBranch = $('#new-branch'); Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); @@ -89,7 +91,7 @@ export default class Issue { return $(document).on( 'click', - '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', + '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen, a.btn-close-anyway', e => { e.preventDefault(); e.stopImmediatePropagation(); @@ -99,19 +101,30 @@ export default class Issue { Issue.submitNoteForm($button.closest('form')); } - this.disableCloseReopenButton($button); - - const url = $button.attr('href'); - return axios - .put(url) - .then(({ data }) => { - const isClosed = $button.hasClass('btn-close'); - this.updateTopState(isClosed, data); - }) - .catch(() => flash(issueFailMessage)) - .then(() => { - this.disableCloseReopenButton($button, false); - }); + const shouldDisplayBlockedWarning = $button.hasClass('btn-issue-blocked'); + const warningBanner = $('.js-close-blocked-issue-warning'); + if (shouldDisplayBlockedWarning) { + this.toggleWarningAndCloseButton(); + } else { + this.disableCloseReopenButton($button); + + const url = $button.attr('href'); + return axios + .put(url) + .then(({ data }) => { + const isClosed = $button.is('.btn-close, .btn-close-anyway'); + this.updateTopState(isClosed, data); + if ($button.hasClass('btn-close-anyway')) { + warningBanner.addClass('hidden'); + if (this.closeReopenReportToggle) + $('.js-issuable-close-dropdown').removeClass('hidden'); + } + }) + .catch(() => flash(issueFailMessage)) + .then(() => { + this.disableCloseReopenButton($button, false); + }); + } }, ); } @@ -137,6 +150,23 @@ export default class Issue { this.reopenButtons.toggleClass('hidden', !isClosed); } + toggleWarningAndCloseButton() { + const warningBanner = $('.js-close-blocked-issue-warning'); + warningBanner.toggleClass('hidden'); + $('.btn-close').toggleClass('hidden'); + if (this.closeReopenReportToggle) { + $('.js-issuable-close-dropdown').toggleClass('hidden'); + } + } + + initIssueWarningBtnEventListener() { + return $(document).on('click', '.js-close-blocked-issue-warning button.btn-secondary', e => { + e.preventDefault(); + e.stopImmediatePropagation(); + this.toggleWarningAndCloseButton(); + }); + } + static submitNoteForm(form) { const noteText = form.find('textarea.js-note-text').val(); if (noteText && noteText.trim().length > 0) { diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue index 437239ce0be..b71c06e4217 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -1,12 +1,20 @@ <script> -import getJiraProjects from '../queries/getJiraProjects.query.graphql'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; +import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; +import { IMPORT_STATE, isInProgress } from '../utils'; import JiraImportForm from './jira_import_form.vue'; +import JiraImportProgress from './jira_import_progress.vue'; import JiraImportSetup from './jira_import_setup.vue'; export default { name: 'JiraImportApp', components: { + GlAlert, + GlLoadingIcon, JiraImportForm, + JiraImportProgress, JiraImportSetup, }, props: { @@ -14,6 +22,18 @@ export default { type: Boolean, required: true, }, + inProgressIllustration: { + type: String, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + jiraProjects: { + type: Array, + required: true, + }, projectPath: { type: String, required: true, @@ -23,26 +43,111 @@ export default { required: true, }, }, + data() { + return { + errorMessage: '', + showAlert: false, + }; + }, apollo: { - getJiraImports: { - query: getJiraProjects, + jiraImportDetails: { + query: getJiraImportDetailsQuery, variables() { return { fullPath: this.projectPath, }; }, - update: data => data.project.jiraImports, + update: ({ project }) => ({ + status: project.jiraImportStatus, + import: project.jiraImports.nodes[0], + }), skip() { return !this.isJiraConfigured; }, }, }, + computed: { + isImportInProgress() { + return isInProgress(this.jiraImportDetails?.status); + }, + jiraProjectsOptions() { + return this.jiraProjects.map(([text, value]) => ({ text, value })); + }, + }, + methods: { + dismissAlert() { + this.showAlert = false; + }, + initiateJiraImport(project) { + this.$apollo + .mutate({ + mutation: initiateJiraImportMutation, + variables: { + input: { + projectPath: this.projectPath, + jiraProjectKey: project, + }, + }, + update: (store, { data }) => { + if (data.jiraImportStart.errors.length) { + return; + } + + store.writeQuery({ + query: getJiraImportDetailsQuery, + variables: { + fullPath: this.projectPath, + }, + data: { + project: { + jiraImportStatus: IMPORT_STATE.SCHEDULED, + jiraImports: { + nodes: [data.jiraImportStart.jiraImport], + __typename: 'JiraImportConnection', + }, + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Project', + }, + }, + }); + }, + }) + .then(({ data }) => { + if (data.jiraImportStart.errors.length) { + this.setAlertMessage(data.jiraImportStart.errors.join('. ')); + } + }) + .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))); + }, + setAlertMessage(message) { + this.errorMessage = message; + this.showAlert = true; + }, + }, }; </script> <template> <div> + <gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert"> + {{ errorMessage }} + </gl-alert> + <jira-import-setup v-if="!isJiraConfigured" :illustration="setupIllustration" /> - <jira-import-form v-else /> + <gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" /> + <jira-import-progress + v-else-if="isImportInProgress" + :illustration="inProgressIllustration" + :import-initiator="jiraImportDetails.import.scheduledBy.name" + :import-project="jiraImportDetails.import.jiraProjectKey" + :import-time="jiraImportDetails.import.scheduledAt" + :issues-path="issuesPath" + /> + <jira-import-form + v-else + :issues-path="issuesPath" + :jira-projects="jiraProjectsOptions" + @initiateJiraImport="initiateJiraImport" + /> </div> </template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index 26e51c02b41..0146f564260 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -12,6 +12,39 @@ export default { }, currentUserAvatarUrl: gon.current_user_avatar_url, currentUsername: gon.current_username, + props: { + issuesPath: { + type: String, + required: true, + }, + jiraProjects: { + type: Array, + required: true, + }, + }, + data() { + return { + selectedOption: null, + selectState: null, + }; + }, + methods: { + initiateJiraImport(event) { + event.preventDefault(); + if (!this.selectedOption) { + this.showValidationError(); + } else { + this.hideValidationError(); + this.$emit('initiateJiraImport', this.selectedOption); + } + }, + hideValidationError() { + this.selectState = null; + }, + showValidationError() { + this.selectState = false; + }, + }, }; </script> @@ -19,14 +52,21 @@ export default { <div> <h3 class="page-title">{{ __('New Jira import') }}</h3> <hr /> - <form> + <form @submit="initiateJiraImport"> <gl-form-group class="row align-items-center" + :invalid-feedback="__('Please select a Jira project')" :label="__('Import from')" label-cols-sm="2" label-for="jira-project-select" > - <gl-form-select id="jira-project-select" class="mb-2" /> + <gl-form-select + id="jira-project-select" + v-model="selectedOption" + class="mb-2" + :options="jiraProjects" + :state="selectState" + /> </gl-form-group> <gl-form-group @@ -86,8 +126,10 @@ export default { </gl-form-group> <div class="footer-block row-content-block d-flex justify-content-between"> - <gl-button category="primary" variant="success">{{ __('Next') }}</gl-button> - <gl-button>{{ __('Cancel') }}</gl-button> + <gl-button type="submit" category="primary" variant="success" class="js-no-auto-disable"> + {{ __('Next') }} + </gl-button> + <gl-button :href="issuesPath">{{ __('Cancel') }}</gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue new file mode 100644 index 00000000000..2d610224658 --- /dev/null +++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue @@ -0,0 +1,66 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; + +export default { + name: 'JiraImportProgress', + components: { + GlEmptyState, + }, + props: { + illustration: { + type: String, + required: true, + }, + importInitiator: { + type: String, + required: true, + }, + importProject: { + type: String, + required: true, + }, + importTime: { + type: String, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + }, + computed: { + importInitiatorText() { + return sprintf(__('Import started by: %{importInitiator}'), { + importInitiator: this.importInitiator, + }); + }, + importProjectText() { + return sprintf(__('Jira project: %{importProject}'), { + importProject: this.importProject, + }); + }, + importTimeText() { + return sprintf(__('Time of import: %{importTime}'), { + importTime: formatDate(this.importTime), + }); + }, + }, +}; +</script> + +<template> + <gl-empty-state + :svg-path="illustration" + :title="__('Import in progress')" + :primary-button-text="__('View issues')" + :primary-button-link="issuesPath" + > + <template #description> + <p class="mb-0">{{ importInitiatorText }}</p> + <p class="mb-0">{{ importTimeText }}</p> + <p class="mb-0">{{ importProjectText }}</p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue index 917930397f4..44773a773d5 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue @@ -1,6 +1,11 @@ <script> +import { GlEmptyState } from '@gitlab/ui'; + export default { name: 'JiraImportSetup', + components: { + GlEmptyState, + }, props: { illustration: { type: String, @@ -11,15 +16,11 @@ export default { </script> <template> - <div class="empty-state"> - <div class="svg-content"> - <img :src="illustration" :alt="__('Set up Jira Integration illustration')" /> - </div> - <div class="text-content d-flex flex-column align-items-center"> - <p>{{ __('You will first need to set up Jira Integration to use this feature.') }}</p> - <a class="btn btn-success" href="../services/jira/edit"> - {{ __('Set up Jira Integration') }} - </a> - </div> - </div> + <gl-empty-state + :svg-path="illustration" + title="" + :description="__('You will first need to set up Jira Integration to use this feature.')" + :primary-button-text="__('Set up Jira Integration')" + primary-button-link="../services/jira/edit" + /> </template> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 13b16b81c49..8bd70e4e277 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -24,7 +24,10 @@ export default function mountJiraImportApp() { render(createComponent) { return createComponent(App, { props: { + inProgressIllustration: el.dataset.inProgressIllustration, isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), + issuesPath: el.dataset.issuesPath, + jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [], projectPath: el.dataset.projectPath, setupIllustration: el.dataset.setupIllustration, }, diff --git a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql b/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql deleted file mode 100644 index 13100eac221..00000000000 --- a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query getJiraProjects($fullPath: ID!) { - project(fullPath: $fullPath) { - jiraImportStatus - jiraImports { - nodes { - jiraProjectKey - scheduledAt - scheduledBy { - username - } - } - } - } -} diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql new file mode 100644 index 00000000000..0eaaad580fc --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql @@ -0,0 +1,12 @@ +#import "./jira_import.fragment.graphql" + +query($fullPath: ID!) { + project(fullPath: $fullPath) { + jiraImportStatus + jiraImports(last: 1) { + nodes { + ...JiraImport + } + } + } +} diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql new file mode 100644 index 00000000000..8fda8287988 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql @@ -0,0 +1,11 @@ +#import "./jira_import.fragment.graphql" + +mutation($input: JiraImportStartInput!) { + jiraImportStart(input: $input) { + clientMutationId + jiraImport { + ...JiraImport + } + errors + } +} diff --git a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql new file mode 100644 index 00000000000..fde2ebeff91 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql @@ -0,0 +1,7 @@ +fragment JiraImport on JiraImport { + jiraProjectKey + scheduledAt + scheduledBy { + name + } +} diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js new file mode 100644 index 00000000000..504cf19e44e --- /dev/null +++ b/app/assets/javascripts/jira_import/utils.js @@ -0,0 +1,10 @@ +export const IMPORT_STATE = { + FAILED: 'failed', + FINISHED: 'finished', + NONE: 'none', + SCHEDULED: 'scheduled', + STARTED: 'started', +}; + +export const isInProgress = state => + state === IMPORT_STATE.SCHEDULED || state === IMPORT_STATE.STARTED; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 7107c970457..47d5a8253dd 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -3,7 +3,7 @@ /* global ListLabel */ import $ from 'jquery'; -import _ from 'underscore'; +import { isEqual, escape as esc, sortBy, template } from 'lodash'; import { sprintf, s__, __ } from './locale'; import axios from './lib/utils/axios_utils'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; @@ -76,7 +76,7 @@ export default class LabelsSelect { }) .get(); - if (_.isEqual(initialSelected, selected)) return; + if (isEqual(initialSelected, selected)) return; initialSelected = selected; const data = {}; @@ -101,7 +101,7 @@ export default class LabelsSelect { let labelCount = 0; if (data.labels.length && issueUpdateURL) { template = LabelsSelect.getLabelTemplate({ - labels: _.sortBy(data.labels, 'title'), + labels: sortBy(data.labels, 'title'), issueUpdateURL, enableScopedLabels: scopedLabels, scopedLabelsDocumentationLink, @@ -269,7 +269,7 @@ export default class LabelsSelect { } linkEl.className = selectedClass.join(' '); - linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; + linkEl.innerHTML = `${colorEl} ${esc(label.title)}`; const listItemEl = document.createElement('li'); listItemEl.appendChild(linkEl); @@ -436,7 +436,7 @@ export default class LabelsSelect { if (isScopedLabel(label)) { const prevIds = oldLabels.map(label => label.id); const newIds = boardsStore.detail.issue.labels.map(label => label.id); - const differentIds = _.difference(prevIds, newIds); + const differentIds = prevIds.filter(x => !newIds.includes(x)); $dropdown.data('marked', newIds); $dropdownMenu .find(differentIds.map(id => `[data-label-id="${id}"]`).join(',')) @@ -483,7 +483,7 @@ export default class LabelsSelect { '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>" class="gl-link gl-label-link has-tooltip" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>">'; const spanOpenTag = '<span class="gl-label-text" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">'; - const labelTemplate = _.template( + const labelTemplate = template( [ '<span class="gl-label">', linkOpenTag, @@ -499,7 +499,7 @@ export default class LabelsSelect { return escapeStr(label.text_color === '#FFFFFF' ? label.color : label.text_color); }; - const infoIconTemplate = _.template( + const infoIconTemplate = template( [ '<a href="<%= scopedLabelsDocumentationLink %>" class="gl-link gl-label-icon" target="_blank" rel="noopener">', '<i class="fa fa-question-circle"></i>', @@ -507,7 +507,7 @@ export default class LabelsSelect { ].join(''), ); - const scopedLabelTemplate = _.template( + const scopedLabelTemplate = template( [ '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>;">', linkOpenTag, @@ -523,7 +523,7 @@ export default class LabelsSelect { ].join(''), ); - const tooltipTitleTemplate = _.template( + const tooltipTitleTemplate = template( [ '<% if (isScopedLabel(label) && enableScopedLabels) { %>', "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>", @@ -535,9 +535,9 @@ export default class LabelsSelect { ].join(''), ); - const tpl = _.template( + const tpl = template( [ - '<% _.each(labels, function(label){ %>', + '<% labels.forEach(function(label){ %>', '<% if (isScopedLabel(label) && enableScopedLabels) { %>', '<span class="d-inline-block position-relative scoped-label-wrapper">', '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, infoIconTemplate, scopedLabelsDocumentationLink, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', @@ -557,7 +557,7 @@ export default class LabelsSelect { scopedLabelTemplate, tooltipTitleTemplate, isScopedLabel, - escapeStr: _.escape, + escapeStr: esc, }); } diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 9e8edd05b88..a464290ffb5 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { debounce, throttle } from 'lodash'; export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; @@ -82,7 +82,7 @@ export default class LazyLoader { } startIntersectionObserver = () => { - this.throttledElementsInView = _.throttle(() => this.checkElementsInView(), 300); + this.throttledElementsInView = throttle(() => this.checkElementsInView(), 300); this.intersectionObserver = new IntersectionObserver(this.onIntersection, { rootMargin: `${SCROLL_THRESHOLD}px 0px`, thresholds: 0.1, @@ -102,8 +102,8 @@ export default class LazyLoader { }; startLegacyObserver() { - this.throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300); - this.debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300); + this.throttledScrollCheck = throttle(() => this.scrollCheck(), 300); + this.debouncedElementsInView = debounce(() => this.checkElementsInView(), 300); window.addEventListener('scroll', this.throttledScrollCheck); window.addEventListener('resize', this.debouncedElementsInView); } diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js index d3aea37e677..adf374db66c 100644 --- a/app/assets/javascripts/lib/utils/unit_format/index.js +++ b/app/assets/javascripts/lib/utils/unit_format/index.js @@ -1,3 +1,4 @@ +import { engineeringNotation } from '@gitlab/ui/src/utils/number_utils'; import { s__ } from '~/locale'; import { @@ -39,15 +40,18 @@ export const SUPPORTED_FORMATS = { gibibytes: 'gibibytes', tebibytes: 'tebibytes', pebibytes: 'pebibytes', + + // Engineering Notation + engineering: 'engineering', }; /** * Returns a function that formats number to different units - * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to number. + * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation. * * */ -export const getFormatter = (format = SUPPORTED_FORMATS.number) => { +export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { // Number if (format === SUPPORTED_FORMATS.number) { @@ -252,6 +256,17 @@ export const getFormatter = (format = SUPPORTED_FORMATS.number) => { return scaledBinaryFormatter('B', 5); } + if (format === SUPPORTED_FORMATS.engineering) { + /** + * Formats via engineering notation + * + * @function + * @param {Number} value - Value to format + * @param {Number} fractionDigits - precision decimals - Defaults to 2 + */ + return engineeringNotation; + } + // Fail so client library addresses issue throw TypeError(`${format} is not a valid number format`); }; diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js index 7ab4e725d99..b4658a159d7 100644 --- a/app/assets/javascripts/locale/sprintf.js +++ b/app/assets/javascripts/locale/sprintf.js @@ -5,7 +5,7 @@ import { escape } from 'lodash'; @param input (translated) text with parameters (e.g. '%{num_users} users use us') @param {Object} parameters object mapping parameter names to values (e.g. { num_users: 5 }) - @param {Boolean} escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape) + @param {Boolean} escapeParameters whether parameter values should be escaped (see https://lodash.com/docs/4.17.15#escape) @returns {String} the text with parameters replaces (e.g. '5 users use us') @see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 81b2e9f13a5..6c8f6372795 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -298,6 +298,18 @@ document.addEventListener('DOMContentLoaded', () => { if ($gutterIcon.hasClass('fa-angle-double-right')) { $sidebarGutterToggle.trigger('click'); } + + const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle'); + + // Sidebar has an icon which corresponds to collapsing the sidebar + // only then trigger the click. + if (sidebarGutterVueToggleEl) { + const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right'); + + if (collapseIcon) { + collapseIcon.click(); + } + } } }); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index d15e4ecb537..5d2825e3cd2 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -3,7 +3,7 @@ /* global ListMilestone */ import $ from 'jquery'; -import _ from 'underscore'; +import { template, escape as esc } from 'lodash'; import { __ } from '~/locale'; import '~/gl_dropdown'; import axios from './lib/utils/axios_utils'; @@ -60,7 +60,7 @@ export default class MilestoneSelect { selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault; if (issueUpdateURL) { - milestoneLinkTemplate = _.template( + milestoneLinkTemplate = template( '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', ); milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`; @@ -106,12 +106,12 @@ export default class MilestoneSelect { if (showMenuAbove) { $dropdown.data('glDropdown').positionMenuAbove(); } - $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active'); + $(`[data-milestone-id="${esc(selectedMilestone)}"] > a`).addClass('is-active'); }), renderRow: milestone => ` - <li data-milestone-id="${_.escape(milestone.name)}"> + <li data-milestone-id="${esc(milestone.name)}"> <a href='#' class='dropdown-menu-milestone-link'> - ${_.escape(milestone.title)} + ${esc(milestone.title)} </a> </li> `, @@ -129,7 +129,7 @@ export default class MilestoneSelect { }, defaultLabel, fieldName: $dropdown.data('fieldName'), - text: milestone => _.escape(milestone.title), + text: milestone => esc(milestone.title), id: milestone => { if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) { return milestone.name; @@ -148,7 +148,7 @@ export default class MilestoneSelect { selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; } $('a.is-active', $el).removeClass('is-active'); - $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active'); + $(`[data-milestone-id="${esc(selectedMilestone)}"] > a`, $el).addClass('is-active'); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: clickEvent => { diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue new file mode 100644 index 00000000000..2c6223c5dd7 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -0,0 +1,286 @@ +<script> +import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import AlertWidgetForm from './alert_widget_form.vue'; +import AlertsService from '../services/alerts_service'; +import { alertsValidator, queriesValidator } from '../validators'; +import { OPERATORS } from '../constants'; +import { values, get } from 'lodash'; + +export default { + components: { + AlertWidgetForm, + GlBadge, + GlLoadingIcon, + GlIcon, + GlTooltip, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + alertsEndpoint: { + type: String, + required: true, + }, + showLoadingState: { + type: Boolean, + required: false, + default: true, + }, + // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls. + // Includes only the metrics/alerts to be managed by this widget. + alertsToManage: { + type: Object, + required: false, + default: () => ({}), + validator: alertsValidator, + }, + // [{ metric+query_attributes }]. Represents queries (and alerts) we know about + // on intial fetch. Essentially used for reference. + relevantQueries: { + type: Array, + required: true, + validator: queriesValidator, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + service: null, + errorMessage: null, + isLoading: false, + apiAction: 'create', + }; + }, + i18n: { + alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'), + singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'), + multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'), + firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'), + }, + computed: { + singleAlertSummary() { + return { + message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0], + alert: this.thresholds[0], + }; + }, + multipleAlertsSummary() { + return { + message: this.isFiring + ? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}` + : this.$options.i18n.alertsCountMsg, + count: this.thresholds.length, + firingCount: this.firingAlerts.length, + }; + }, + shouldShowLoadingIcon() { + return this.showLoadingState && this.isLoading; + }, + thresholds() { + const alertsToManage = Object.keys(this.alertsToManage); + return alertsToManage.map(this.formatAlertSummary); + }, + hasAlerts() { + return Boolean(Object.keys(this.alertsToManage).length); + }, + hasMultipleAlerts() { + return this.thresholds.length > 1; + }, + isFiring() { + return Boolean(this.firingAlerts.length); + }, + firingAlerts() { + return values(this.alertsToManage).filter(alert => + this.passedAlertThreshold(this.getQueryData(alert), alert), + ); + }, + formattedFiringAlerts() { + return this.firingAlerts.map(alert => this.formatAlertSummary(alert.alert_path)); + }, + configuredAlert() { + return this.hasAlerts ? values(this.alertsToManage)[0].metricId : ''; + }, + }, + created() { + this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint }); + this.fetchAlertData(); + }, + methods: { + fetchAlertData() { + this.isLoading = true; + + const queriesWithAlerts = this.relevantQueries.filter(query => query.alert_path); + + return Promise.all( + queriesWithAlerts.map(query => + this.service + .readAlert(query.alert_path) + .then(alertAttributes => this.setAlert(alertAttributes, query.metricId)), + ), + ) + .then(() => { + this.isLoading = false; + }) + .catch(() => { + createFlash(s__('PrometheusAlerts|Error fetching alert')); + this.isLoading = false; + }); + }, + setAlert(alertAttributes, metricId) { + this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId }); + }, + removeAlert(alertPath) { + this.$emit('setAlerts', alertPath, null); + }, + formatAlertSummary(alertPath) { + const alert = this.alertsToManage[alertPath]; + const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId); + + return `${alertQuery.label} ${alert.operator} ${alert.threshold}`; + }, + passedAlertThreshold(data, alert) { + const { threshold, operator } = alert; + + switch (operator) { + case OPERATORS.greaterThan: + return data.some(value => value > threshold); + case OPERATORS.lessThan: + return data.some(value => value < threshold); + case OPERATORS.equalTo: + return data.some(value => value === threshold); + default: + return false; + } + }, + getQueryData(alert) { + const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId); + + return get(alertQuery, 'result[0].values', []).map(value => get(value, '[1]', null)); + }, + showModal() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + hideModal() { + this.errorMessage = null; + this.$root.$emit('bv::hide::modal', this.modalId); + }, + handleSetApiAction(apiAction) { + this.apiAction = apiAction; + }, + handleCreate({ operator, threshold, prometheus_metric_id }) { + const newAlert = { operator, threshold, prometheus_metric_id }; + this.isLoading = true; + this.service + .createAlert(newAlert) + .then(alertAttributes => { + this.setAlert(alertAttributes, prometheus_metric_id); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error creating alert'); + this.isLoading = false; + }); + }, + handleUpdate({ alert, operator, threshold }) { + const updatedAlert = { operator, threshold }; + this.isLoading = true; + this.service + .updateAlert(alert, updatedAlert) + .then(alertAttributes => { + this.setAlert(alertAttributes, this.alertsToManage[alert].metricId); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error saving alert'); + this.isLoading = false; + }); + }, + handleDelete({ alert }) { + this.isLoading = true; + this.service + .deleteAlert(alert) + .then(() => { + this.removeAlert(alert); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error deleting alert'); + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden"> + <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" /> + <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{ + errorMessage + }}</span> + <span + v-else-if="hasAlerts" + ref="alertCurrentSetting" + class="alert-current-setting cursor-pointer d-flex" + @click="showModal" + > + <gl-badge + :variant="isFiring ? 'danger' : 'secondary'" + pill + class="d-flex-center text-truncate" + > + <gl-icon name="warning" :size="16" class="flex-shrink-0" /> + <span class="text-truncate gl-pl-1"> + <gl-sprintf + :message=" + hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message + " + > + <template #alert> + {{ singleAlertSummary.alert }} + </template> + <template #count> + {{ multipleAlertsSummary.count }} + </template> + <template #firingCount> + {{ multipleAlertsSummary.firingCount }} + </template> + </gl-sprintf> + </span> + </gl-badge> + <gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting"> + <gl-sprintf :message="$options.i18n.firingAlertsTooltip"> + <template #alerts> + <div v-for="alert in formattedFiringAlerts" :key="alert.alert_path"> + {{ alert }} + </div> + </template> + </gl-sprintf> + </gl-tooltip> + </span> + <alert-widget-form + ref="widgetForm" + :disabled="isLoading" + :alerts-to-manage="alertsToManage" + :relevant-queries="relevantQueries" + :error-message="errorMessage" + :configured-alert="configuredAlert" + :modal-id="modalId" + @create="handleCreate" + @update="handleUpdate" + @delete="handleDelete" + @cancel="hideModal" + @setAction="handleSetApiAction" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue new file mode 100644 index 00000000000..860d854b5ae --- /dev/null +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -0,0 +1,300 @@ +<script> +import { isEmpty, findKey } from 'lodash'; +import Vue from 'vue'; +import { + GlLink, + GlDeprecatedButton, + GlButtonGroup, + GlFormGroup, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlModal, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import Translate from '~/vue_shared/translate'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import Icon from '~/vue_shared/components/icon.vue'; +import { alertsValidator, queriesValidator } from '../validators'; +import { OPERATORS } from '../constants'; + +Vue.use(Translate); + +const SUBMIT_ACTION_TEXT = { + create: __('Add'), + update: __('Save'), + delete: __('Delete'), +}; + +const SUBMIT_BUTTON_CLASS = { + create: 'btn-success', + update: 'btn-success', + delete: 'btn-remove', +}; + +export default { + components: { + GlDeprecatedButton, + GlButtonGroup, + GlFormGroup, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlModal, + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, + }, + mixins: [glFeatureFlagsMixin()], + props: { + disabled: { + type: Boolean, + required: true, + }, + errorMessage: { + type: String, + required: false, + default: '', + }, + configuredAlert: { + type: String, + required: false, + default: '', + }, + alertsToManage: { + type: Object, + required: false, + default: () => ({}), + validator: alertsValidator, + }, + relevantQueries: { + type: Array, + required: true, + validator: queriesValidator, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + operators: OPERATORS, + operator: null, + threshold: null, + prometheusMetricId: null, + selectedAlert: {}, + alertQuery: '', + }; + }, + computed: { + isValidQuery() { + // TODO: Add query validation check (most likely via http request) + return this.alertQuery.length ? true : null; + }, + currentQuery() { + return this.relevantQueries.find(query => query.metricId === this.prometheusMetricId) || {}; + }, + formDisabled() { + // We need a prometheusMetricId to determine whether we're + // creating/updating/deleting + return this.disabled || !(this.prometheusMetricId || this.isValidQuery); + }, + supportsComputedAlerts() { + return this.glFeatures.prometheusComputedAlerts; + }, + queryDropdownLabel() { + return this.currentQuery.label || s__('PrometheusAlerts|Select query'); + }, + haveValuesChanged() { + return ( + this.operator && + this.threshold === Number(this.threshold) && + (this.operator !== this.selectedAlert.operator || + this.threshold !== this.selectedAlert.threshold) + ); + }, + submitAction() { + if (isEmpty(this.selectedAlert)) return 'create'; + if (this.haveValuesChanged) return 'update'; + return 'delete'; + }, + submitActionText() { + return SUBMIT_ACTION_TEXT[this.submitAction]; + }, + submitButtonClass() { + return SUBMIT_BUTTON_CLASS[this.submitAction]; + }, + isSubmitDisabled() { + return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged); + }, + dropdownTitle() { + return this.submitAction === 'create' + ? s__('PrometheusAlerts|Add alert') + : s__('PrometheusAlerts|Edit alert'); + }, + }, + watch: { + alertsToManage() { + this.resetAlertData(); + }, + submitAction() { + this.$emit('setAction', this.submitAction); + }, + }, + methods: { + selectQuery(queryId) { + const existingAlertPath = findKey(this.alertsToManage, alert => alert.metricId === queryId); + const existingAlert = this.alertsToManage[existingAlertPath]; + + if (existingAlert) { + this.selectedAlert = existingAlert; + this.operator = existingAlert.operator; + this.threshold = existingAlert.threshold; + } else { + this.selectedAlert = {}; + this.operator = this.operators.greaterThan; + this.threshold = null; + } + + this.prometheusMetricId = queryId; + }, + handleHidden() { + this.resetAlertData(); + this.$emit('cancel'); + }, + handleSubmit(e) { + e.preventDefault(); + this.$emit(this.submitAction, { + alert: this.selectedAlert.alert_path, + operator: this.operator, + threshold: this.threshold, + prometheus_metric_id: this.prometheusMetricId, + }); + }, + resetAlertData() { + this.operator = null; + this.threshold = null; + this.prometheusMetricId = null; + this.selectedAlert = {}; + }, + getAlertFormActionTrackingOption() { + const label = `${this.submitAction}_alert`; + return { + category: document.body.dataset.page, + action: 'click_button', + label, + }; + }, + }, + alertQueryText: { + label: __('Query'), + validFeedback: __('Query is valid'), + invalidFeedback: __('Invalid query'), + descriptionTooltip: __( + 'Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula.', + ), + }, +}; +</script> + +<template> + <gl-modal + ref="alertModal" + :title="dropdownTitle" + :modal-id="modalId" + :ok-variant="submitAction === 'delete' ? 'danger' : 'success'" + :ok-disabled="formDisabled" + @ok="handleSubmit" + @hidden="handleHidden" + @shown="selectQuery(configuredAlert)" + > + <div v-if="errorMessage" class="alert-modal-message danger_message">{{ errorMessage }}</div> + <div class="alert-form"> + <gl-form-group + v-if="supportsComputedAlerts" + :label="$options.alertQueryText.label" + label-for="alert-query-input" + :valid-feedback="$options.alertQueryText.validFeedback" + :invalid-feedback="$options.alertQueryText.invalidFeedback" + :state="isValidQuery" + > + <gl-form-input id="alert-query-input" v-model.trim="alertQuery" :state="isValidQuery" /> + <template #description> + <div class="d-flex align-items-center"> + {{ __('Single or combined queries') }} + <icon + v-gl-tooltip="$options.alertQueryText.descriptionTooltip" + name="question" + class="prepend-left-4" + /> + </div> + </template> + </gl-form-group> + <gl-form-group v-else label-for="alert-query-dropdown" :label="$options.alertQueryText.label"> + <gl-dropdown + id="alert-query-dropdown" + :text="queryDropdownLabel" + toggle-class="dropdown-menu-toggle qa-alert-query-dropdown" + > + <gl-dropdown-item + v-for="query in relevantQueries" + :key="query.metricId" + data-qa-selector="alert_query_option" + @click="selectQuery(query.metricId)" + > + {{ query.label }} + </gl-dropdown-item> + </gl-dropdown> + </gl-form-group> + <gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')"> + <gl-deprecated-button + :class="{ active: operator === operators.greaterThan }" + :disabled="formDisabled" + type="button" + @click="operator = operators.greaterThan" + > + {{ operators.greaterThan }} + </gl-deprecated-button> + <gl-deprecated-button + :class="{ active: operator === operators.equalTo }" + :disabled="formDisabled" + type="button" + @click="operator = operators.equalTo" + > + {{ operators.equalTo }} + </gl-deprecated-button> + <gl-deprecated-button + :class="{ active: operator === operators.lessThan }" + :disabled="formDisabled" + type="button" + @click="operator = operators.lessThan" + > + {{ operators.lessThan }} + </gl-deprecated-button> + </gl-button-group> + <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold"> + <gl-form-input + id="alerts-threshold" + v-model.number="threshold" + :disabled="formDisabled" + type="number" + data-qa-selector="alert_threshold_field" + /> + </gl-form-group> + </div> + <template #modal-ok> + <gl-link + v-track-event="getAlertFormActionTrackingOption()" + class="text-reset text-decoration-none" + > + {{ submitActionText }} + </gl-link> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/annotations.js b/app/assets/javascripts/monitoring/components/charts/annotations.js index 947750b3721..418107c4126 100644 --- a/app/assets/javascripts/monitoring/components/charts/annotations.js +++ b/app/assets/javascripts/monitoring/components/charts/annotations.js @@ -1,20 +1,20 @@ -import { graphTypes, symbolSizes, colorValues } from '../../constants'; +import { graphTypes, symbolSizes, colorValues, annotationsSymbolIcon } from '../../constants'; /** * Annotations and deployments are decoration layers on * top of the actual chart data. We use a scatter plot to * display this information. Each chart has its coordinate - * system based on data and irresptive of the data, these + * system based on data and irrespective of the data, these * decorations have to be placed in specific locations. * For this reason, annotations have their own coordinate system, * * As of %12.9, only deployment icons, a type of annotations, need * to be displayed on the chart. * - * After https://gitlab.com/gitlab-org/gitlab/-/issues/211418, - * annotations and deployments will co-exist in the same - * series as they logically belong together. Annotations will be - * passed as markLine objects. + * Annotations and deployments co-exist in the same series as + * they logically belong together. Annotations are passed as + * markLines and markPoints while deployments are passed as + * data points with custom icons. */ /** @@ -45,42 +45,49 @@ export const annotationsYAxis = { * Fetched list of annotations are parsed into a * format the eCharts accepts to draw markLines * - * If Annotation is a single line, the `starting_at` property - * has a value and the `ending_at` is null. Because annotations - * only supports lines the `ending_at` value does not exist yet. - * + * If Annotation is a single line, the `startingAt` property + * has a value and the `endingAt` is null. Because annotations + * only supports lines the `endingAt` value does not exist yet. * * @param {Object} annotation object * @returns {Object} markLine object */ -export const parseAnnotations = ({ starting_at = '', color = colorValues.primaryColor }) => ({ - xAxis: starting_at, - lineStyle: { - color, - }, -}); +export const parseAnnotations = annotations => + annotations.reduce( + (acc, annotation) => { + acc.lines.push({ + xAxis: annotation.startingAt, + lineStyle: { + color: colorValues.primaryColor, + }, + }); + + acc.points.push({ + name: 'annotations', + xAxis: annotation.startingAt, + yAxis: annotationsYAxisCoords.min, + tooltipData: { + title: annotation.startingAt, + content: annotation.description, + }, + }); + + return acc; + }, + { lines: [], points: [] }, + ); /** - * This method currently generates deployments and annotations - * but are not used in the chart. The method calling - * generateAnnotationsSeries will not pass annotations until - * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 is - * implemented. - * - * This method is extracted out of the charts so that - * annotation lines can be easily supported in - * the future. - * - * In order to make hover work, hidden annotation data points - * are created along with the markLines. These data points have - * the necessart metadata that is used to display in the tooltip. + * This method generates a decorative series that has + * deployments as data points with custom icons and + * annotations as markLines and markPoints * * @param {Array} deployments deployments data * @returns {Object} annotation series object */ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } = {}) => { // deployment data points - const deploymentsData = deployments.map(deployment => { + const data = deployments.map(deployment => { return { name: 'deployments', value: [deployment.createdAt, annotationsYAxisCoords.pos], @@ -98,31 +105,29 @@ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } }; }); - // annotation data points - const annotationsData = annotations.map(annotation => { - return { - name: 'annotations', - value: [annotation.starting_at, annotationsYAxisCoords.pos], - // style options - symbol: 'none', - // metadata that are accessible in `formatTooltipText` method - tooltipData: { - description: annotation.description, - }, - }; - }); + const parsedAnnotations = parseAnnotations(annotations); - // annotation markLine option + // markLine option draws the annotations dotted line const markLine = { symbol: 'none', silent: true, - data: annotations.map(parseAnnotations), + data: parsedAnnotations.lines, + }; + + // markPoints are the arrows under the annotations lines + const markPoint = { + symbol: annotationsSymbolIcon, + symbolSize: '8', + symbolOffset: [0, ' 60%'], + data: parsedAnnotations.points, }; return { + name: 'annotations', type: graphTypes.annotationsData, yAxisIndex: 1, // annotationsYAxis index - data: [...deploymentsData, ...annotationsData], + data, markLine, + markPoint, }; }; diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 447f8845506..b3b6f9e7b55 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -3,7 +3,7 @@ import { flattenDeep, isNumber } from 'lodash'; import { 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 { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants'; import { graphDataValidatorForAnomalyValues } from '../../utils'; import MonitorTimeSeriesChart from './time_series.vue'; @@ -91,7 +91,7 @@ export default { ]); return { ...this.graphData, - type: 'line-chart', + type: panelTypes.LINE_CHART, metrics: [metricQuery], }; }, diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue index 5588d9ac060..e015ef32d8c 100644 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -3,12 +3,6 @@ import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-e import { chartHeight } from '../../constants'; export default { - props: { - graphTitle: { - type: String, - required: true, - }, - }, data() { return { height: chartHeight, diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js index d9f49bd81f5..09b03774580 100644 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ b/app/assets/javascripts/monitoring/components/charts/options.js @@ -6,9 +6,8 @@ const yAxisBoundaryGap = [0.1, 0.1]; * Max string length of formatted axis tick */ const maxDataAxisTickLength = 8; - // Defaults -const defaultFormat = SUPPORTED_FORMATS.number; +const defaultFormat = SUPPORTED_FORMATS.engineering; const defaultYAxisFormat = defaultFormat; const defaultYAxisPrecision = 2; @@ -26,8 +25,7 @@ const chartGridLeft = 75; * @param {Object} param - Dashboard .yml definition options */ const getDataAxisOptions = ({ format, precision, name }) => { - const formatter = getFormatter(format); - + const formatter = getFormatter(format); // default to engineeringNotation, same as gitlab-ui return { name, nameLocation: 'center', // same as gitlab-ui's default diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 9041b01088c..547f33faaa2 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -6,7 +6,7 @@ import dateFormat from 'dateformat'; import { s__, __ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { chartHeight, lineTypes, lineWidths, dateFormats, tooltipTypes } from '../../constants'; +import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants'; import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options'; import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; import { makeDataSeries } from '~/helpers/monitor_helper'; @@ -20,7 +20,6 @@ const events = { }; export default { - tooltipTypes, components: { GlAreaChart, GlLineChart, @@ -212,8 +211,8 @@ export default { }, glChartComponent() { const chartTypes = { - 'area-chart': GlAreaChart, - 'line-chart': GlLineChart, + [panelTypes.AREA_CHART]: GlAreaChart, + [panelTypes.LINE_CHART]: GlLineChart, }; return chartTypes[this.graphData.type] || GlAreaChart; }, @@ -262,6 +261,21 @@ export default { isTooltipOfType(tooltipType, defaultType) { return tooltipType === defaultType; }, + /** + * This method is triggered when hovered over a single markPoint. + * + * The annotations title timestamp should match the data tooltip + * title. + * + * @params {Object} params markPoint object + * @returns {Object} + */ + formatAnnotationsTooltipText(params) { + return { + title: dateFormat(params.data?.tooltipData?.title, dateFormats.default), + content: params.data?.tooltipData?.content, + }; + }, formatTooltipText(params) { this.tooltip.title = dateFormat(params.value, dateFormats.default); this.tooltip.content = []; @@ -270,15 +284,10 @@ export default { if (dataPoint.value) { const [, yVal] = dataPoint.value; this.tooltip.type = dataPoint.name; - if (this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.deployments)) { + if (this.tooltip.type === 'deployments') { const { data = {} } = dataPoint; this.tooltip.sha = data?.tooltipData?.sha; this.tooltip.commitUrl = data?.tooltipData?.commitUrl; - } else if ( - this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.annotations) - ) { - const { data } = dataPoint; - this.tooltip.content.push(data?.tooltipData?.description); } else { const { seriesName, color, dataIndex } = dataPoint; @@ -356,6 +365,7 @@ export default { :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" + :format-annotations-tooltip-text="formatAnnotationsTooltipText" :thresholds="thresholds" :width="width" :height="height" @@ -364,7 +374,7 @@ export default { @created="onChartCreated" @updated="onChartUpdated" > - <template v-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.deployments)"> + <template v-if="tooltip.type === 'deployments'"> <template slot="tooltipTitle"> {{ __('Deployed') }} </template> @@ -373,16 +383,6 @@ export default { <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> </div> </template> - <template v-else-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.annotations)"> - <template slot="tooltipTitle"> - <div class="text-nowrap"> - {{ tooltip.title }} - </div> - </template> - <div slot="tooltipContent" class="d-flex align-items-center"> - {{ tooltip.content.join('\n') }} - </div> - </template> <template v-else> <template slot="tooltipTitle"> <div class="text-nowrap"> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 4586ce70ad6..85306023d7d 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -8,17 +8,17 @@ import { GlDropdownItem, GlDropdownHeader, GlDropdownDivider, - GlFormGroup, GlModal, GlLoadingIcon, GlSearchBoxByType, GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import PanelType from './panel_type_with_alerts.vue'; import { s__ } from '~/locale'; import createFlash from '~/flash'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import Icon from '~/vue_shared/components/icon.vue'; @@ -46,8 +46,8 @@ export default { GlDropdownHeader, GlDropdownDivider, GlSearchBoxByType, - GlFormGroup, GlModal, + CustomMetricsFormFields, DateTimePicker, GraphGroup, @@ -206,9 +206,6 @@ export default { }; }, computed: { - canAddMetrics() { - return this.customMetricsAvailable && this.customMetricsPath.length; - }, ...mapState('monitoringDashboard', [ 'dashboard', 'emptyState', @@ -229,7 +226,11 @@ export default { return !this.showEmptyState && this.rearrangePanelsAvailable; }, addingMetricsAvailable() { - return IS_EE && this.canAddMetrics && !this.showEmptyState; + return ( + this.customMetricsAvailable && + !this.showEmptyState && + this.firstDashboard === this.selectedDashboard + ); }, hasHeaderButtons() { return ( @@ -378,177 +379,164 @@ export default { <div v-if="showHeader" ref="prometheusGraphsHeader" - class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light" + class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" > - <div class="row"> - <gl-form-group - :label="__('Dashboard')" - label-size="sm" - label-for="monitor-dashboards-dropdown" - class="col-sm-12 col-md-6 col-lg-2" - > - <dashboards-dropdown - id="monitor-dashboards-dropdown" - data-qa-selector="dashboards_filter_dropdown" - class="mb-0 d-flex" - toggle-class="dropdown-menu-toggle" - :default-branch="defaultBranch" - :selected-dashboard="selectedDashboard" - @selectDashboard="selectDashboard($event)" - /> - </gl-form-group> + <div class="mb-2 pr-2 d-flex d-sm-block"> + <dashboards-dropdown + id="monitor-dashboards-dropdown" + data-qa-selector="dashboards_filter_dropdown" + class="flex-grow-1" + toggle-class="dropdown-menu-toggle" + :default-branch="defaultBranch" + :selected-dashboard="selectedDashboard" + @selectDashboard="selectDashboard($event)" + /> + </div> - <gl-form-group - :label="s__('Metrics|Environment')" - label-size="sm" - label-for="monitor-environments-dropdown" - class="col-sm-6 col-md-6 col-lg-2" + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-dropdown + id="monitor-environments-dropdown" + ref="monitorEnvironmentsDropdown" + class="flex-grow-1" + data-qa-selector="environments_dropdown" + toggle-class="dropdown-menu-toggle" + menu-class="monitor-environment-dropdown-menu" + :text="currentEnvironmentName" > - <gl-dropdown - id="monitor-environments-dropdown" - ref="monitorEnvironmentsDropdown" - data-qa-selector="environments_dropdown" - class="mb-0 d-flex" - toggle-class="dropdown-menu-toggle" - menu-class="monitor-environment-dropdown-menu" - :text="currentEnvironmentName" - > - <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-header class="monitor-environment-dropdown-header text-center">{{ - __('Environment') - }}</gl-dropdown-header> - <gl-dropdown-divider /> - <gl-search-box-by-type - ref="monitorEnvironmentsDropdownSearch" - class="m-2" - @input="debouncedEnvironmentsSearch" - /> - <gl-loading-icon - v-if="environmentsLoading" - ref="monitorEnvironmentsDropdownLoading" - :inline="true" - /> - <div v-else class="flex-fill overflow-auto"> - <gl-dropdown-item - v-for="environment in filteredEnvironments" - :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" - :href="environment.metrics_path" - >{{ environment.name }}</gl-dropdown-item - > - </div> - <div - v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" - ref="monitorEnvironmentsDropdownMsg" - class="text-secondary no-matches-message" + <div class="d-flex flex-column overflow-hidden"> + <gl-dropdown-header class="monitor-environment-dropdown-header text-center"> + {{ __('Environment') }} + </gl-dropdown-header> + <gl-dropdown-divider /> + <gl-search-box-by-type + ref="monitorEnvironmentsDropdownSearch" + class="m-2" + @input="debouncedEnvironmentsSearch" + /> + <gl-loading-icon + v-if="environmentsLoading" + ref="monitorEnvironmentsDropdownLoading" + :inline="true" + /> + <div v-else class="flex-fill overflow-auto"> + <gl-dropdown-item + v-for="environment in filteredEnvironments" + :key="environment.id" + :active="environment.name === currentEnvironmentName" + active-class="is-active" + :href="environment.metrics_path" + >{{ environment.name }}</gl-dropdown-item > - {{ __('No matching results') }} - </div> </div> - </gl-dropdown> - </gl-form-group> + <div + v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" + ref="monitorEnvironmentsDropdownMsg" + class="text-secondary no-matches-message" + > + {{ __('No matching results') }} + </div> + </div> + </gl-dropdown> + </div> - <gl-form-group - :label="s__('Metrics|Show last')" - label-size="sm" - label-for="monitor-time-window-dropdown" - class="col-sm-auto col-md-auto col-lg-auto" + <div class="mb-2 pr-2 d-flex d-sm-block"> + <date-time-picker + ref="dateTimePicker" + class="flex-grow-1 show-last-dropdown" data-qa-selector="show_last_dropdown" + :value="selectedTimeRange" + :options="timeRanges" + @input="onDateTimePickerInput" + @invalid="onDateTimePickerInvalid" + /> + </div> + + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="refreshDashboardBtn" + v-gl-tooltip + class="flex-grow-1" + variant="default" + :title="s__('Metrics|Refresh dashboard')" + @click="refreshDashboard" > - <date-time-picker - ref="dateTimePicker" - :value="selectedTimeRange" - :options="timeRanges" - @input="onDateTimePickerInput" - @invalid="onDateTimePickerInvalid" - /> - </gl-form-group> + <icon name="retry" /> + </gl-deprecated-button> + </div> + + <div class="flex-grow-1"></div> - <gl-form-group class="col-sm-2 col-md-2 col-lg-1 refresh-dashboard-button"> + <div class="d-sm-flex"> + <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex"> <gl-deprecated-button - ref="refreshDashboardBtn" - v-gl-tooltip + :pressed="isRearrangingPanels" variant="default" - :title="s__('Metrics|Refresh dashboard')" - @click="refreshDashboard" + class="flex-grow-1 js-rearrange-button" + @click="toggleRearrangingPanels" > - <icon name="retry" /> + {{ __('Arrange charts') }} </gl-deprecated-button> - </gl-form-group> - - <gl-form-group - 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" - > - <div id="prometheus-graphs-dropdown-buttons"> - <gl-deprecated-button - v-if="showRearrangePanelsBtn" - :pressed="isRearrangingPanels" - variant="default" - class="mr-2 mt-1 js-rearrange-button" - @click="toggleRearrangingPanels" - >{{ __('Arrange charts') }}</gl-deprecated-button - > - <gl-deprecated-button - v-if="addingMetricsAvailable" - ref="addMetricBtn" - v-gl-modal="$options.addMetric.modalId" - variant="outline-success" - data-qa-selector="add_metric_button" - class="mr-2 mt-1" - >{{ $options.addMetric.title }}</gl-deprecated-button - > - <gl-modal - v-if="addingMetricsAvailable" - ref="addMetricModal" - :modal-id="$options.addMetric.modalId" - :title="$options.addMetric.title" - > - <form ref="customMetricsForm" :action="customMetricsPath" method="post"> - <custom-metrics-form-fields - :validate-query-path="validateQueryPath" - form-operation="post" - @formValidation="setFormValidity" - /> - </form> - <div slot="modal-footer"> - <gl-deprecated-button @click="hideAddMetricModal">{{ - __('Cancel') - }}</gl-deprecated-button> - <gl-deprecated-button - ref="submitCustomMetricsFormBtn" - v-track-event="getAddMetricTrackingOptions()" - :disabled="!formIsValid" - variant="success" - @click="submitCustomMetricsForm" - >{{ __('Save changes') }}</gl-deprecated-button - > - </div> - </gl-modal> + </div> + <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="addMetricBtn" + v-gl-modal="$options.addMetric.modalId" + variant="outline-success" + data-qa-selector="add_metric_button" + class="flex-grow-1" + > + {{ $options.addMetric.title }} + </gl-deprecated-button> + <gl-modal + ref="addMetricModal" + :modal-id="$options.addMetric.modalId" + :title="$options.addMetric.title" + > + <form ref="customMetricsForm" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :validate-query-path="validateQueryPath" + form-operation="post" + @formValidation="setFormValidity" + /> + </form> + <div slot="modal-footer"> + <gl-deprecated-button @click="hideAddMetricModal"> + {{ __('Cancel') }} + </gl-deprecated-button> + <gl-deprecated-button + ref="submitCustomMetricsFormBtn" + v-track-event="getAddMetricTrackingOptions()" + :disabled="!formIsValid" + variant="success" + @click="submitCustomMetricsForm" + > + {{ __('Save changes') }} + </gl-deprecated-button> + </div> + </gl-modal> + </div> - <gl-deprecated-button - v-if="selectedDashboard.can_edit" - class="mt-1 js-edit-link" - :href="selectedDashboard.project_blob_path" - data-qa-selector="edit_dashboard_button" - >{{ __('Edit dashboard') }}</gl-deprecated-button - > + <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + class="flex-grow-1 js-edit-link" + :href="selectedDashboard.project_blob_path" + data-qa-selector="edit_dashboard_button" + > + {{ __('Edit dashboard') }} + </gl-deprecated-button> + </div> - <gl-deprecated-button - v-if="externalDashboardUrl.length" - class="mt-1 js-external-dashboard-link" - variant="primary" - :href="externalDashboardUrl" - target="_blank" - rel="noopener noreferrer" - > - {{ __('View full dashboard') }} - <icon name="external-link" /> - </gl-deprecated-button> - </div> - </gl-form-group> + <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + class="flex-grow-1 js-external-dashboard-link" + variant="primary" + :href="externalDashboardUrl" + target="_blank" + rel="noopener noreferrer" + > + {{ __('View full dashboard') }} <icon name="external-link" /> + </gl-deprecated-button> + </div> </div> </div> diff --git a/app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue b/app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue new file mode 100644 index 00000000000..be92414fd56 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue @@ -0,0 +1,25 @@ +<script> +import CeDashboard from '~/monitoring/components/dashboard.vue'; +import AlertWidget from './alert_widget.vue'; + +export default { + components: { + AlertWidget, + }, + extends: CeDashboard, + data() { + return { + allAlerts: {}, + }; + }, + methods: { + setAlerts(alertPath, alertAttributes) { + if (alertAttributes) { + this.$set(this.allAlerts, alertPath, alertAttributes); + } else { + this.$delete(this.allAlerts, alertPath); + } + }, + }, +}; +</script> diff --git a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue index 3f8b0f76997..129de6cc2f6 100644 --- a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue +++ b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions } from 'vuex'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import PanelType from '~/monitoring/components/panel_type_with_alerts.vue'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { defaultTimeRange } from '~/vue_shared/constants'; import { timeRangeFromUrl, removeTimeRangeParams } from '../../utils'; diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 676fc0cca64..eed41b94cd3 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -4,6 +4,7 @@ import { pickBy } from 'lodash'; import invalidUrl from '~/lib/utils/invalid_url'; import { GlResizeObserverDirective, + GlIcon, GlLoadingIcon, GlDropdown, GlDropdownItem, @@ -13,7 +14,9 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { __, n__ } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; +import { panelTypes } from '../constants'; + +import MonitorEmptyChart from './charts/empty_chart.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; @@ -21,7 +24,7 @@ import MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorColumnChart from './charts/column.vue'; import MonitorBarChart from './charts/bar.vue'; import MonitorStackedColumnChart from './charts/stacked_column.vue'; -import MonitorEmptyChart from './charts/empty_chart.vue'; + import TrackEventDirective from '~/vue_shared/directives/track_event'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; @@ -31,13 +34,13 @@ const events = { export default { components: { + MonitorEmptyChart, MonitorSingleStatChart, + MonitorHeatmapChart, MonitorColumnChart, MonitorBarChart, - MonitorHeatmapChart, MonitorStackedColumnChart, - MonitorEmptyChart, - Icon, + GlIcon, GlLoadingIcon, GlTooltip, GlDropdown, @@ -68,7 +71,7 @@ export default { groupId: { type: String, required: false, - default: 'panel-type-chart', + default: 'dashboard-panel', }, namespace: { type: String, @@ -142,7 +145,7 @@ export default { return window.URL.createObjectURL(data); }, timeChartComponent() { - if (this.isPanelType('anomaly-chart')) { + if (this.isPanelType(panelTypes.ANOMALY_CHART)) { return MonitorAnomalyChart; } return MonitorTimeSeriesChart; @@ -150,10 +153,10 @@ export default { isContextualMenuShown() { return ( this.graphDataHasResult && - !this.isPanelType('single-stat') && - !this.isPanelType('heatmap') && - !this.isPanelType('column') && - !this.isPanelType('stacked-column') + !this.isPanelType(panelTypes.SINGLE_STAT) && + !this.isPanelType(panelTypes.HEATMAP) && + !this.isPanelType(panelTypes.COLUMN) && + !this.isPanelType(panelTypes.STACKED_COLUMN) ); }, editCustomMetricLink() { @@ -198,6 +201,7 @@ export default { this.$emit(events.timeRangeZoom, { start, end }); }, }, + panelTypes, }; </script> <template> @@ -227,7 +231,7 @@ export default { </div> <div v-if="isContextualMenuShown" - class="js-graph-widgets" + ref="contextualMenu" data-qa-selector="prometheus_graph_widgets" > <div class="d-flex align-items-center"> @@ -240,7 +244,7 @@ export default { :title="__('More actions')" > <template slot="button-content"> - <icon name="ellipsis_v" class="text-secondary" /> + <gl-icon name="ellipsis_v" class="text-secondary" /> </template> <gl-dropdown-item v-if="editCustomMetricLink" @@ -288,23 +292,23 @@ export default { </div> <monitor-single-stat-chart - v-if="isPanelType('single-stat') && graphDataHasResult" + v-if="isPanelType($options.panelTypes.SINGLE_STAT) && graphDataHasResult" :graph-data="graphData" /> <monitor-heatmap-chart - v-else-if="isPanelType('heatmap') && graphDataHasResult" + v-else-if="isPanelType($options.panelTypes.HEATMAP) && graphDataHasResult" :graph-data="graphData" /> <monitor-bar-chart - v-else-if="isPanelType('bar') && graphDataHasResult" + v-else-if="isPanelType($options.panelTypes.BAR) && graphDataHasResult" :graph-data="graphData" /> <monitor-column-chart - v-else-if="isPanelType('column') && graphDataHasResult" + v-else-if="isPanelType($options.panelTypes.COLUMN) && graphDataHasResult" :graph-data="graphData" /> <monitor-stacked-column-chart - v-else-if="isPanelType('stacked-column') && graphDataHasResult" + v-else-if="isPanelType($options.panelTypes.STACKED_COLUMN) && graphDataHasResult" :graph-data="graphData" /> <component @@ -319,6 +323,6 @@ export default { :group-id="groupId" @datazoom="onDatazoom" /> - <monitor-empty-chart v-else :graph-title="title" v-bind="$attrs" v-on="$listeners" /> + <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue b/app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue new file mode 100644 index 00000000000..ca81242af2e --- /dev/null +++ b/app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue @@ -0,0 +1,55 @@ +<script> +import { mapGetters } from 'vuex'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; +import CePanelType from '~/monitoring/components/panel_type.vue'; +import AlertWidget from './alert_widget.vue'; + +export default { + components: { + AlertWidget, + CustomMetricsFormFields, + }, + extends: CePanelType, + props: { + alertsEndpoint: { + type: String, + required: false, + default: null, + }, + prometheusAlertsAvailable: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + allAlerts: {}, + }; + }, + computed: { + ...mapGetters('monitoringDashboard', ['metricsSavedToDb']), + hasMetricsInDb() { + const { metrics = [] } = this.graphData; + return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId)); + }, + alertWidgetAvailable() { + return ( + this.prometheusAlertsAvailable && + this.alertsEndpoint && + this.graphData && + this.hasMetricsInDb + ); + }, + }, + methods: { + setAlerts(alertPath, alertAttributes) { + if (alertAttributes) { + this.$set(this.allAlerts, alertPath, alertAttributes); + } else { + this.$delete(this.allAlerts, alertPath); + } + }, + }, +}; +</script> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 8d821c27099..f2f0a0eac7b 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -48,6 +48,55 @@ export const metricStates = { UNKNOWN_ERROR: 'UNKNOWN_ERROR', }; +/** + * Supported panel types in dashboards, values of `panel.type`. + * + * Values should not be changed as they correspond to + * values in users the `.yml` dashboard definition. + */ +export const panelTypes = { + /** + * Area Chart + * + * Time Series chart with an area + */ + AREA_CHART: 'area-chart', + /** + * Line Chart + * + * Time Series chart with a line + */ + LINE_CHART: 'line-chart', + /** + * Anomaly Chart + * + * Time Series chart with 3 metrics + */ + ANOMALY_CHART: 'anomaly-chart', + /** + * Single Stat + * + * Single data point visualization + */ + SINGLE_STAT: 'single-stat', + /** + * Heatmap + */ + HEATMAP: 'heatmap', + /** + * Bar chart + */ + BAR: 'bar', + /** + * Column chart + */ + COLUMN: 'column', + /** + * Stacked column chart + */ + STACKED_COLUMN: 'stacked-column', +}; + export const sidebarAnimationDuration = 300; // milliseconds. export const chartHeight = 300; @@ -120,10 +169,32 @@ export const NOT_IN_DB_PREFIX = 'NO_DB'; export const ENVIRONMENT_AVAILABLE_STATE = 'available'; /** - * Time series charts have different types of - * tooltip based on the hovered data point. + * As of %12.10, the svg icon library does not have an annotation + * arrow icon yet. In order to deliver annotations feature, the icon + * is hard coded until the icon is added. The below issue is + * to track the icon. + * + * https://gitlab.com/gitlab-org/gitlab-svgs/-/issues/118 + * + * Once the icon is merged this can be removed. + * https://gitlab.com/gitlab-org/gitlab/-/issues/214540 + */ +export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; + +/** + * As of %12.10, dashboard path is required to create annotation. + * The FE gets the dashboard name from the URL params. It is not + * ideal to store the path this way but there is no other way to + * get this path unless annotations fetch is delayed. This could + * potentially be removed and have the backend send this to the FE. + * + * This technical debt is being tracked here + * https://gitlab.com/gitlab-org/gitlab/-/issues/214671 */ -export const tooltipTypes = { - deployments: 'deployments', - annotations: 'annotations', +export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; + +export const OPERATORS = { + greaterThan: '>', + equalTo: '==', + lessThan: '<', }; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index d296f5b7a66..99af8ccaf05 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; -import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; +import Dashboard from '~/monitoring/components/dashboard_with_alerts.vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; import store from './stores'; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js new file mode 100644 index 00000000000..afe5ee0938d --- /dev/null +++ b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js @@ -0,0 +1,13 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; +import initCeBundle from '~/monitoring/monitoring_bundle'; + +export default () => { + const el = document.getElementById('prometheus-graphs'); + + if (el && el.dataset) { + initCeBundle({ + customMetricsAvailable: parseBoolean(el.dataset.customMetricsAvailable), + prometheusAlertsAvailable: parseBoolean(el.dataset.prometheusAlertsAvailable), + }); + } +}; diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql index 2fd698eadf9..27b49860b8a 100644 --- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql @@ -1,12 +1,25 @@ -query getAnnotations($projectPath: ID!) { - environment(name: $environmentName) { - metricDashboard(id: $dashboardId) { - annotations: nodes { +query getAnnotations( + $projectPath: ID! + $environmentName: String + $dashboardPath: String! + $startingFrom: Time! +) { + project(fullPath: $projectPath) { + environments(name: $environmentName) { + nodes { id - description - starting_at - ending_at - panelId + name + metricsDashboard(path: $dashboardPath) { + annotations(from: $startingFrom) { + nodes { + id + description + startingAt + endingAt + panelId + } + } + } } } } diff --git a/app/assets/javascripts/monitoring/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js new file mode 100644 index 00000000000..4b7337972fe --- /dev/null +++ b/app/assets/javascripts/monitoring/services/alerts_service.js @@ -0,0 +1,32 @@ +import axios from '~/lib/utils/axios_utils'; + +export default class AlertsService { + constructor({ alertsEndpoint }) { + this.alertsEndpoint = alertsEndpoint; + } + + getAlerts() { + return axios.get(this.alertsEndpoint).then(resp => resp.data); + } + + createAlert({ prometheus_metric_id, operator, threshold }) { + return axios + .post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold }) + .then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + readAlert(alertPath) { + return axios.get(alertPath).then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + updateAlert(alertPath, { operator, threshold }) { + return axios.put(alertPath, { operator, threshold }).then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + deleteAlert(alertPath) { + return axios.delete(alertPath).then(resp => resp.data); + } +} diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 3201f1d4584..f04f775761c 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -3,7 +3,12 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { gqClient, parseEnvironmentsResponse, removeLeadingSlash } from './utils'; +import { + gqClient, + parseEnvironmentsResponse, + parseAnnotationsResponse, + removeLeadingSlash, +} from './utils'; import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; @@ -15,7 +20,11 @@ import { } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; -import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE } from '../constants'; +import { + PROMETHEUS_TIMEOUT, + ENVIRONMENT_AVAILABLE_STATE, + DEFAULT_DASHBOARD_PATH, +} from '../constants'; function prometheusMetricQueryParams(timeRange) { const { start, end } = convertToFixedRange(timeRange); @@ -283,16 +292,21 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { }; export const fetchAnnotations = ({ state, dispatch }) => { + const { start } = convertToFixedRange(state.timeRange); + const dashboardPath = + state.currentDashboard === '' ? DEFAULT_DASHBOARD_PATH : state.currentDashboard; return gqClient .mutate({ mutation: getAnnotations, variables: { projectPath: removeLeadingSlash(state.projectPath), - dashboardId: state.currentDashboard, environmentName: state.currentEnvironmentName, + dashboardPath, + startingFrom: start, }, }) - .then(resp => resp.data?.project?.environment?.metricDashboard?.annotations) + .then(resp => resp.data?.project?.environments?.nodes?.[0].metricsDashboard?.annotations.nodes) + .then(parseAnnotationsResponse) .then(annotations => { if (!annotations) { createFlash(s__('Metrics|There was an error fetching annotations. Please try again.')); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index a212e9be703..9f06d18c46f 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -58,6 +58,31 @@ export const parseEnvironmentsResponse = (response = [], projectPath) => }); /** + * Annotation API returns time in UTC. This method + * converts time to local time. + * + * startingAt always exists but endingAt does not. + * If endingAt does not exist, a threshold line is + * drawn. + * + * If endingAt exists, a threshold range is drawn. + * But this is not supported as of %12.10 + * + * @param {Array} response annotations response + * @returns {Array} parsed responses + */ +export const parseAnnotationsResponse = response => { + if (!response) { + return []; + } + return response.map(annotation => ({ + ...annotation, + startingAt: new Date(annotation.startingAt), + endingAt: annotation.endingAt ? new Date(annotation.endingAt) : null, + })); +}; + +/** * Maps metrics to its view model * * This function difers from other in that is maps all @@ -95,15 +120,19 @@ const mapXAxisToViewModel = ({ name = '' }) => ({ name }); /** * Maps Y-axis view model * - * Defaults to a 2 digit precision and `number` format. It only allows + * Defaults to a 2 digit precision and `engineering` format. It only allows * formats in the SUPPORTED_FORMATS array. * * @param {Object} axis */ -const mapYAxisToViewModel = ({ name = '', format = SUPPORTED_FORMATS.number, precision = 2 }) => { +const mapYAxisToViewModel = ({ + name = '', + format = SUPPORTED_FORMATS.engineering, + precision = 2, +}) => { return { name, - format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.number, + format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.engineering, precision, }; }; diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js new file mode 100644 index 00000000000..cd426f1a221 --- /dev/null +++ b/app/assets/javascripts/monitoring/validators.js @@ -0,0 +1,44 @@ +// Prop validator for alert information, expecting an object like the example below. +// +// { +// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': { +// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37", +// metricId: '1', +// operator: ">", +// query: "rate(http_requests_total[5m])[30m:1m]", +// threshold: 0.002, +// title: "Core Usage (Total)", +// } +// } +export function alertsValidator(value) { + return Object.keys(value).every(key => { + const alert = value[key]; + return ( + alert.alert_path && + key === alert.alert_path && + alert.metricId && + typeof alert.metricId === 'string' && + alert.operator && + typeof alert.threshold === 'number' + ); + }); +} + +// Prop validator for query information, expecting an array like the example below. +// +// [ +// { +// metricId: '16', +// label: 'Total Cores' +// }, +// { +// metricId: '17', +// label: 'Sub-total Cores' +// } +// ] +export function queriesValidator(value) { + return value.every( + query => + query.metricId && typeof query.metricId === 'string' && typeof query.label === 'string', + ); +} diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 9a809b71a58..a070cf8866a 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -3,6 +3,7 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import { isEmpty } from 'lodash'; import Autosize from 'autosize'; +import { GlAlert, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; @@ -34,6 +35,10 @@ export default { userAvatarLink, loadingButton, TimelineEntryItem, + GlAlert, + GlIntersperse, + GlLink, + GlSprintf, }, mixins: [issuableStateMixin], props: { @@ -57,8 +62,9 @@ export default { 'getNoteableData', 'getNotesData', 'openState', + 'getBlockedByIssues', ]), - ...mapState(['isToggleStateButtonLoading']), + ...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']), noteableDisplayName() { return splitCamelCase(this.noteableType).toLowerCase(); }, @@ -159,6 +165,7 @@ export default { 'reopenIssue', 'toggleIssueLocalState', 'toggleStateButtonLoading', + 'toggleBlockedIssueWarning', ]), setIsSubmitButtonDisabled(note, isSubmitting) { if (!isEmpty(note) && !isSubmitting) { @@ -220,22 +227,17 @@ export default { this.isSubmitting = false; }, toggleIssueState() { + if ( + this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE && + this.isOpen && + this.getBlockedByIssues && + this.getBlockedByIssues.length > 0 + ) { + this.toggleBlockedIssueWarning(true); + return; + } if (this.isOpen) { - this.closeIssue() - .then(() => { - this.enableButton(); - refreshUserMergeRequestCounts(); - }) - .catch(() => { - this.enableButton(); - this.toggleStateButtonLoading(false); - Flash( - sprintf( - __('Something went wrong while closing the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ), - ); - }); + this.forceCloseIssue(); } else { this.reopenIssue() .then(() => { @@ -258,6 +260,23 @@ export default { }); } }, + forceCloseIssue() { + this.closeIssue() + .then(() => { + this.enableButton(); + refreshUserMergeRequestCounts(); + }) + .catch(() => { + this.enableButton(); + this.toggleStateButtonLoading(false); + Flash( + sprintf( + __('Something went wrong while closing the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, + ), + ); + }); + }, discard(shouldClear = true) { // `blur` is needed to clear slash commands autocomplete cache if event fired. // `focus` is needed to remain cursor in the textarea. @@ -361,6 +380,36 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" > </textarea> </markdown-field> + <gl-alert + v-if="isToggleBlockedIssueWarning" + class="prepend-top-16" + :title="__('Are you sure you want to close this blocked issue?')" + :primary-button-text="__('Yes, close issue')" + :secondary-button-text="__('Cancel')" + variant="warning" + :dismissible="false" + @primaryAction="forceCloseIssue" + @secondaryAction="toggleBlockedIssueWarning(false) && enableButton()" + > + <p> + <gl-sprintf + :message=" + __('This issue is currently blocked by the following issues: %{issues}.') + " + > + <template #issues> + <gl-intersperse> + <gl-link + v-for="blockingIssue in getBlockedByIssues" + :key="blockingIssue.web_url" + :href="blockingIssue.web_url" + >#{{ blockingIssue.iid }}</gl-link + > + </gl-intersperse> + </template> + </gl-sprintf> + </p> + </gl-alert> <div class="note-form-actions"> <div class="float-left btn-group @@ -427,7 +476,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" </div> <loading-button - v-if="canToggleIssueState" + v-if="canToggleIssueState && !isToggleBlockedIssueWarning" :loading="isToggleStateButtonLoading" :container-class="[ actionButtonClassNames, diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index df62e379017..5181b5f26ee 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,17 +1,12 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import tooltip from '~/vue_shared/directives/tooltip'; -import Icon from '~/vue_shared/components/icon.vue'; +import AwardsList from '~/vue_shared/components/awards_list.vue'; import Flash from '../../flash'; -import { glEmojiTag } from '../../emoji'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; export default { components: { - Icon, - }, - directives: { - tooltip, + AwardsList, }, props: { awards: { @@ -37,130 +32,20 @@ export default { }, computed: { ...mapGetters(['getUserData']), - // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. - // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] - // This method will group emojis by their name as an Object. See below. - // { - // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], - // bar: [ { name: bar, user: user1 } ] - // } - // We need to do this otherwise we will render the same emoji over and over again. - groupedAwards() { - const awards = this.awards.reduce((acc, award) => { - if (Object.prototype.hasOwnProperty.call(acc, award.name)) { - acc[award.name].push(award); - } else { - Object.assign(acc, { [award.name]: [award] }); - } - - return acc; - }, {}); - - const orderedAwards = {}; - const { thumbsdown, thumbsup } = awards; - // Always show thumbsup and thumbsdown first - if (thumbsup) { - orderedAwards.thumbsup = thumbsup; - delete awards.thumbsup; - } - if (thumbsdown) { - orderedAwards.thumbsdown = thumbsdown; - delete awards.thumbsdown; - } - - return Object.assign({}, orderedAwards, awards); - }, isAuthoredByMe() { return this.noteAuthorId === this.getUserData.id; }, + addButtonClass() { + return this.isAuthoredByMe ? 'js-user-authored' : ''; + }, }, methods: { ...mapActions(['toggleAwardRequest']), - getAwardHTML(name) { - return glEmojiTag(name); - }, - getAwardClassBindings(awardList) { - return { - active: this.hasReactionByCurrentUser(awardList), - disabled: !this.canInteractWithEmoji(), - }; - }, - canInteractWithEmoji() { - return this.getUserData.id; - }, - hasReactionByCurrentUser(awardList) { - return awardList.filter(award => award.user.id === this.getUserData.id).length; - }, - awardTitle(awardsList) { - const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); - const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; - let awardList = awardsList; - - // Filter myself from list if I am awarded. - if (hasReactionByCurrentUser) { - awardList = awardList.filter(award => award.user.id !== this.getUserData.id); - } - - // Get only 9-10 usernames to show in tooltip text. - const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); - - // Get the remaining list to use in `and x more` text. - const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); - - // Add myself to the beginning of the list so title will start with You. - if (hasReactionByCurrentUser) { - namesToShow.unshift(__('You')); - } - - let title = ''; - - // We have 10+ awarded user, join them with comma and add `and x more`. - if (remainingAwardList.length) { - title = sprintf( - __(`%{listToShow}, and %{awardsListLength} more.`), - { - listToShow: namesToShow.join(', '), - awardsListLength: remainingAwardList.length, - }, - false, - ); - } else if (namesToShow.length > 1) { - // Join all names with comma but not the last one, it will be added with and text. - title = namesToShow.slice(0, namesToShow.length - 1).join(', '); - // If we have more than 2 users we need an extra comma before and text. - title += namesToShow.length > 2 ? ',' : ''; - title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text - } else { - // We have only 2 users so join them with and. - title = namesToShow.join(__(' and ')); - } - - return title; - }, handleAward(awardName) { - if (!this.canAwardEmoji) { - return; - } - - let parsedName; - - // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string - switch (awardName) { - case '100': - parsedName = 100; - break; - case '1234': - parsedName = 1234; - break; - default: - parsedName = awardName; - break; - } - const data = { endpoint: this.toggleAwardPath, noteId: this.noteId, - awardName: parsedName, + awardName, }; this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.'))); @@ -171,46 +56,12 @@ export default { <template> <div class="note-awards"> - <div class="awards js-awards-block"> - <button - v-for="(awardList, awardName, index) in groupedAwards" - :key="index" - v-tooltip - :class="getAwardClassBindings(awardList)" - :title="awardTitle(awardList)" - data-boundary="viewport" - class="btn award-control" - type="button" - @click="handleAward(awardName)" - > - <span v-html="getAwardHTML(awardName)"></span> - <span class="award-control-text js-counter">{{ awardList.length }}</span> - </button> - <div v-if="canAwardEmoji" class="award-menu-holder"> - <button - v-tooltip - :class="{ 'js-user-authored': isAuthoredByMe }" - class="award-control btn js-add-award" - title="Add reaction" - :aria-label="__('Add reaction')" - data-boundary="viewport" - type="button" - > - <span class="award-control-icon award-control-icon-neutral"> - <icon name="slight-smile" /> - </span> - <span class="award-control-icon award-control-icon-positive"> - <icon name="smiley" /> - </span> - <span class="award-control-icon award-control-icon-super-positive"> - <icon name="smiley" /> - </span> - <i - aria-hidden="true" - class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" - ></i> - </button> - </div> - </div> + <awards-list + :awards="awards" + :can-award-emoji="canAwardEmoji" + :current-user-id="getUserData.id" + :add-button-class="addButtonClass" + @award="handleAward($event)" + /> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index f82b3554cac..74a0b69bc54 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -45,6 +45,13 @@ export default { default: true, }, }, + data() { + return { + isUsernameLinkHovered: false, + emojiTitle: '', + authorStatusHasTooltip: false, + }; + }, computed: { toggleChevronClass() { return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; @@ -58,6 +65,28 @@ export default { showGitlabTeamMemberBadge() { return this.author?.is_gitlab_employee; }, + authorLinkClasses() { + return { + hover: this.isUsernameLinkHovered, + 'text-underline': this.isUsernameLinkHovered, + 'author-name-link': true, + 'js-user-link': true, + }; + }, + authorStatus() { + return this.author.status_tooltip_html; + }, + emojiElement() { + return this.$refs?.authorStatus?.querySelector('gl-emoji'); + }, + }, + mounted() { + this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : ''; + + const authorStatusTitle = this.$refs?.authorStatus + ?.querySelector('.user-status-emoji') + ?.getAttribute('title'); + this.authorStatusHasTooltip = authorStatusTitle && authorStatusTitle !== ''; }, methods: { ...mapActions(['setTargetNoteHash']), @@ -69,6 +98,20 @@ export default { this.setTargetNoteHash(this.noteTimestampLink); } }, + removeEmojiTitle() { + this.emojiElement.removeAttribute('title'); + }, + addEmojiTitle() { + this.emojiElement.setAttribute('title', this.emojiTitle); + }, + handleUsernameMouseEnter() { + this.$refs.authorNameLink.dispatchEvent(new Event('mouseenter')); + this.isUsernameLinkHovered = true; + }, + handleUsernameMouseLeave() { + this.$refs.authorNameLink.dispatchEvent(new Event('mouseleave')); + this.isUsernameLinkHovered = false; + }, }, }; </script> @@ -87,18 +130,34 @@ export default { </div> <template v-if="hasAuthor"> <a - v-once + ref="authorNameLink" :href="author.path" - class="js-user-link" + :class="authorLinkClasses" :data-user-id="author.id" :data-username="author.username" > <slot name="note-header-info"></slot> <span class="note-header-author-name bold">{{ author.name }}</span> - <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> - <span class="note-headline-light">@{{ author.username }}</span> </a> - <gitlab-team-member-badge v-if="showGitlabTeamMemberBadge" /> + <span + v-if="authorStatus" + ref="authorStatus" + v-on=" + authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {} + " + v-html="authorStatus" + ></span> + <span class="text-nowrap author-username"> + <a + ref="authorUsernameLink" + class="author-username-link" + :href="author.path" + @mouseenter="handleUsernameMouseEnter" + @mouseleave="handleUsernameMouseLeave" + ><span class="note-headline-light">@{{ author.username }}</span> + </a> + <gitlab-team-member-badge v-if="showGitlabTeamMemberBadge" /> + </span> </template> <span v-else>{{ __('A deleted user') }}</span> <span class="note-headline-light note-headline-meta"> diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 1b80b59621a..a358515c2ec 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -185,12 +185,27 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, }); }; +export const toggleBlockedIssueWarning = ({ commit }, value) => { + commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value); + // Hides Close issue button at the top of issue page + const closeDropdown = document.querySelector('.js-issuable-close-dropdown'); + if (closeDropdown) { + closeDropdown.classList.toggle('d-none'); + } else { + const closeButton = document.querySelector( + '.detail-page-header-actions .btn-close.btn-grouped', + ); + closeButton.classList.toggle('d-md-block'); + } +}; + export const closeIssue = ({ commit, dispatch, state }) => { dispatch('toggleStateButtonLoading', true); return axios.put(state.notesData.closePath).then(({ data }) => { commit(types.CLOSE_ISSUE); dispatch('emitStateChangedEvent', data); dispatch('toggleStateButtonLoading', false); + dispatch('toggleBlockedIssueWarning', false); }); }; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index eb877083bca..85997b44bcc 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -35,6 +35,8 @@ export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; +export const getBlockedByIssues = state => state.noteableData.blocked_by_issues; + export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note); export const openState = state => state.noteableData.state; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 81844ad6e98..2e5e7f47099 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -14,6 +14,7 @@ export default () => ({ // View layer isToggleStateButtonLoading: false, + isToggleBlockedIssueWarning: false, isNotesFetched: false, isLoading: true, isLoadingDescriptionVersion: false, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 5b7225bb3d2..2f7b2788d8a 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -33,6 +33,7 @@ export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT'; export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; +export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING'; // Description version export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index dab09d1d05c..f06874991f0 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -249,6 +249,10 @@ export default { Object.assign(state, { isToggleStateButtonLoading: value }); }, + [types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) { + Object.assign(state, { isToggleBlockedIssueWarning: value }); + }, + [types.SET_NOTES_FETCHED_STATE](state, value) { Object.assign(state, { isNotesFetched: value }); }, diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index 1ef18b356f2..479c82265f2 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,13 +1,10 @@ import initSettingsPanels from '~/settings_panels'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import initVariableList from '~/ci_variable_list'; -import DueDateSelectors from '~/due_date_select'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels initSettingsPanels(); - // eslint-disable-next-line no-new - new DueDateSelectors(); if (gon.features.newVariablesUi) { initVariableList(); diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js new file mode 100644 index 00000000000..f4b26ba81fe --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js @@ -0,0 +1,9 @@ +import initSettingsPanels from '~/settings_panels'; +import DueDateSelectors from '~/due_date_select'; + +document.addEventListener('DOMContentLoaded', () => { + // Initialize expandable settings panels + initSettingsPanels(); + + new DueDateSelectors(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/projects/alert_management/index/index.js b/app/assets/javascripts/pages/projects/alert_management/index/index.js new file mode 100644 index 00000000000..1e98bcfd2eb --- /dev/null +++ b/app/assets/javascripts/pages/projects/alert_management/index/index.js @@ -0,0 +1,5 @@ +import AlertManagementList from '~/alert_management/list'; + +document.addEventListener('DOMContentLoaded', () => { + AlertManagementList(); +}); diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index 0d69a689316..31ec4e29ad2 100644 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js @@ -1,3 +1,3 @@ -import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle'; +import monitoringBundle from '~/monitoring/monitoring_bundle_with_alerts'; document.addEventListener('DOMContentLoaded', monitoringBundle); 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 a3743ded601..6efddec1172 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 @@ -3,6 +3,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; @@ -24,7 +25,7 @@ export default { GlSprintf, GlLink, }, - mixins: [settingsMixin], + mixins: [settingsMixin, glFeatureFlagsMixin()], props: { currentSettings: { @@ -116,6 +117,8 @@ export default { const defaults = { visibilityOptions, visibilityLevel: visibilityOptions.PUBLIC, + // TODO: Change all of these to use the visibilityOptions constants + // https://gitlab.com/gitlab-org/gitlab/-/issues/214667 issuesAccessLevel: 20, repositoryAccessLevel: 20, forkingAccessLevel: 20, @@ -124,11 +127,14 @@ export default { wikiAccessLevel: 20, snippetsAccessLevel: 20, pagesAccessLevel: 20, + metricsAccessLevel: visibilityOptions.PRIVATE, containerRegistryEnabled: true, lfsEnabled: true, requestAccessEnabled: true, highlightChangesClass: false, emailsDisabled: false, + featureAccessLevelEveryone, + featureAccessLevelMembers, }; return { ...defaults, ...this.currentSettings }; @@ -189,6 +195,10 @@ export default { 'ProjectSettings|View and edit files in this project. Non-project members will only have read access', ); }, + + metricsDashboardVisibilitySwitchingAvailable() { + return this.glFeatures.metricsDashboardVisibilitySwitchingAvailable; + }, }, watch: { @@ -462,6 +472,38 @@ export default { name="project[project_feature_attributes][pages_access_level]" /> </project-setting-row> + <project-setting-row + v-if="metricsDashboardVisibilitySwitchingAvailable" + ref="metrics-visibility-settings" + :label="__('Metrics Dashboard')" + :help-text=" + s__( + 'ProjectSettings|With Metrics Dashboard you can visualize this project performance metrics', + ) + " + > + <div class="project-feature-controls"> + <div class="select-wrapper"> + <select + v-model="metricsAccessLevel" + name="project[project_feature_attributes][metrics_dashboard_access_level]" + class="form-control select-control" + > + <option + :value="visibilityOptions.PRIVATE" + :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" + >{{ featureAccessLevelMembers[1] }}</option + > + <option + :value="visibilityOptions.PUBLIC" + :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" + >{{ featureAccessLevelEveryone[1] }}</option + > + </select> + <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + </div> + </div> + </project-setting-row> </div> <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3"> <label class="js-emails-disabled"> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 388b300b39d..06ab45adf80 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -21,7 +21,8 @@ export default { return this.selectedSuite.total_count > 0; }, showTests() { - return this.testReports.total_count > 0; + const { test_suites: testSuites = [] } = this.testReports; + return testSuites.length > 0; }, }, methods: { 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 index 6effd6e949d..4dfb67dd8e8 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -1,14 +1,19 @@ <script> import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import store from '~/pipelines/stores/test_reports'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { name: 'TestsSummaryTable', components: { + GlIcon, SmartVirtualList, }, + directives: { + GlTooltip: GlTooltipDirective, + }, store, props: { heading: { @@ -75,7 +80,10 @@ export default { 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" + class="gl-responsive-table-row test-reports-summary-row rounded js-suite-row" + :class="{ + 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error, + }" @click="tableRowClick(testSuite)" > <div class="table-section section-25"> @@ -84,6 +92,14 @@ export default { </div> <div class="table-mobile-content underline cgray pl-3"> {{ testSuite.name }} + <gl-icon + v-if="testSuite.suite_error" + ref="suiteErrorIcon" + v-gl-tooltip + name="error" + :title="testSuite.suite_error" + class="vertical-align-middle" + /> </div> </div> diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index daeae071d6a..a3a53c2f975 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; @@ -26,6 +27,9 @@ export default { }, }) .then(({ data }) => dispatch('receiveAuthorsSuccess', data)) - .catch(() => dispatch('receiveAuthorsError')); + .catch(error => { + Sentry.captureException(error); + dispatch('receiveAuthorsError'); + }); }, }; diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index e5898c3b047..2d321ead33e 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -53,6 +53,10 @@ export default { text: s__('ProjectTemplates|Pages/Hexo'), icon: '.template-option .icon-hexo', }, + sse_middleman: { + text: s__('ProjectTemplates|Static Site Editor/Middleman'), + icon: '.template-option .icon-sse_middleman', + }, nfhugo: { text: s__('ProjectTemplates|Netlify/Hugo'), icon: '.template-option .icon-nfhugo', diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue index 6acf366e531..88a0710574f 100644 --- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue +++ b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue @@ -53,7 +53,6 @@ export default { :primary-button-text="alertConfiguration.primaryButton" :primary-button-link="config.settingsPath" :title="alertConfiguration.title" - class="my-2" > <gl-sprintf :message="alertConfiguration.message"> <template #days> diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js index 586231d19c7..d4b9d25b212 100644 --- a/app/assets/javascripts/registry/explorer/constants.js +++ b/app/assets/javascripts/registry/explorer/constants.js @@ -1,16 +1,44 @@ import { s__ } from '~/locale'; +// List page + +export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); +export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); +export const CONNECTION_ERROR_MESSAGE = s__( + `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, +); +export const LIST_INTRO_TEXT = s__( + `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, +); + +export const LIST_DELETE_BUTTON_DISABLED = s__( + 'ContainerRegistry|Missing or insufficient permission, delete button disabled', +); +export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository'); +export const REMOVE_REPOSITORY_MODAL_TEXT = s__( + 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', +); +export const ROW_SCHEDULED_FOR_DELETION = s__( + `ContainerRegistry|This image repository is scheduled for deletion`, +); export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the packages list.', + 'ContainerRegistry|Something went wrong while fetching the repository list.', ); export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while fetching the tags list.', ); - export const DELETE_IMAGE_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while deleting the image.', + 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.', ); -export const DELETE_IMAGE_SUCCESS_MESSAGE = s__('ContainerRegistry|Image deleted successfully'); +export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__( + `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`, +); +export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|%{title} was successfully scheduled for deletion', +); + +// Image details page + export const DELETE_TAG_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while deleting the tag.', ); @@ -37,6 +65,8 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); +// Expiration policies + export const EXPIRATION_POLICY_ALERT_TITLE = s__( 'ContainerRegistry|Retention policy has been Enabled', ); @@ -48,6 +78,8 @@ export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__( 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}', ); +// Quick Start + export const QUICK_START = s__('ContainerRegistry|Quick Start'); export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); @@ -55,3 +87,8 @@ export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); + +// Image state + +export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; +export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 7204cbd90eb..8923c305b2d 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -9,16 +9,28 @@ import { GlModal, GlSprintf, GlLink, + GlAlert, GlSkeletonLoader, } from '@gitlab/ui'; import Tracking from '~/tracking'; -import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ProjectEmptyState from '../components/project_empty_state.vue'; import GroupEmptyState from '../components/group_empty_state.vue'; import ProjectPolicyAlert from '../components/project_policy_alert.vue'; import QuickstartDropdown from '../components/quickstart_dropdown.vue'; -import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE } from '../constants'; +import { + DELETE_IMAGE_SUCCESS_MESSAGE, + DELETE_IMAGE_ERROR_MESSAGE, + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CONTAINER_REGISTRY_TITLE, + CONNECTION_ERROR_TITLE, + CONNECTION_ERROR_MESSAGE, + LIST_INTRO_TEXT, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + REMOVE_REPOSITORY_MODAL_TEXT, + ROW_SCHEDULED_FOR_DELETION, +} from '../constants'; export default { name: 'RegistryListApp', @@ -35,6 +47,7 @@ export default { GlModal, GlSprintf, GlLink, + GlAlert, GlSkeletonLoader, }, directives: { @@ -47,25 +60,20 @@ export default { height: 40, }, i18n: { - containerRegistryTitle: s__('ContainerRegistry|Container Registry'), - connectionErrorTitle: s__('ContainerRegistry|Docker connection error'), - connectionErrorMessage: s__( - `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, - ), - introText: s__( - `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, - ), - deleteButtonDisabled: s__( - 'ContainerRegistry|Missing or insufficient permission, delete button disabled', - ), - removeRepositoryLabel: s__('ContainerRegistry|Remove repository'), - removeRepositoryModalText: s__( - 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', - ), + containerRegistryTitle: CONTAINER_REGISTRY_TITLE, + connectionErrorTitle: CONNECTION_ERROR_TITLE, + connectionErrorMessage: CONNECTION_ERROR_MESSAGE, + introText: LIST_INTRO_TEXT, + deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED, + removeRepositoryLabel: REMOVE_REPOSITORY_LABEL, + removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT, + rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION, + asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE, }, data() { return { itemToDelete: {}, + deleteAlertType: null, }; }, computed: { @@ -86,43 +94,61 @@ export default { showQuickStartDropdown() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, + showDeleteAlert() { + return this.deleteAlertType && this.itemToDelete?.path; + }, + deleteImageAlertMessage() { + return this.deleteAlertType === 'success' + ? DELETE_IMAGE_SUCCESS_MESSAGE + : DELETE_IMAGE_ERROR_MESSAGE; + }, }, methods: { ...mapActions(['requestImagesList', 'requestDeleteImage']), deleteImage(item) { - // This event is already tracked in the system and so the name must be kept to aggregate the data this.track('click_button'); this.itemToDelete = item; this.$refs.deleteModal.show(); }, handleDeleteImage() { this.track('confirm_delete'); - return this.requestDeleteImage(this.itemToDelete.destroy_path) - .then(() => - this.$toast.show(DELETE_IMAGE_SUCCESS_MESSAGE, { - type: 'success', - }), - ) - .catch(() => - this.$toast.show(DELETE_IMAGE_ERROR_MESSAGE, { - type: 'error', - }), - ) - .finally(() => { - this.itemToDelete = {}; + return this.requestDeleteImage(this.itemToDelete) + .then(() => { + this.deleteAlertType = 'success'; + }) + .catch(() => { + this.deleteAlertType = 'danger'; }); }, encodeListItem(item) { const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); return window.btoa(params); }, + dismissDeleteAlert() { + this.deleteAlertType = null; + this.itemToDelete = {}; + }, }, }; </script> <template> <div class="w-100 slide-enter-from-element"> - <project-policy-alert v-if="!config.isGroupPage" /> + <gl-alert + v-if="showDeleteAlert" + :variant="deleteAlertType" + class="mt-2" + dismissible + @dismiss="dismissDeleteAlert" + > + <gl-sprintf :message="deleteImageAlertMessage"> + <template #title> + {{ itemToDelete.path }} + </template> + </gl-sprintf> + </gl-alert> + + <project-policy-alert v-if="!config.isGroupPage" class="mt-2" /> <gl-empty-state v-if="config.characterError" @@ -178,41 +204,57 @@ export default { v-for="(listItem, index) in images" :key="index" ref="rowItem" - :class="{ 'border-top': index === 0 }" - class="d-flex justify-content-between align-items-center py-2 border-bottom" + v-gl-tooltip="{ + placement: 'left', + disabled: !listItem.deleting, + title: $options.i18n.rowScheduledForDeletion, + }" > - <div> - <router-link - ref="detailsLink" - :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" - > - {{ listItem.path }} - </router-link> - <clipboard-button - v-if="listItem.location" - ref="clipboardButton" - :text="listItem.location" - :title="listItem.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> <div - v-gl-tooltip="{ disabled: listItem.destroy_path }" - class="d-none d-sm-block" - :title="$options.i18n.deleteButtonDisabled" + class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom" + :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }" > - <gl-deprecated-button - ref="deleteImageButton" - v-gl-tooltip - :disabled="!listItem.destroy_path" - :title="$options.i18n.removeRepositoryLabel" - :aria-label="$options.i18n.removeRepositoryLabel" - class="btn-inverted" - variant="danger" - @click="deleteImage(listItem)" + <div class="d-felx align-items-center"> + <router-link + ref="detailsLink" + :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" + > + {{ listItem.path }} + </router-link> + <clipboard-button + v-if="listItem.location" + ref="clipboardButton" + :disabled="listItem.deleting" + :text="listItem.location" + :title="listItem.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + <gl-icon + v-if="listItem.failedDelete" + v-gl-tooltip + :title="$options.i18n.asyncDeleteErrorMessage" + name="warning" + class="text-warning align-middle" + /> + </div> + <div + v-gl-tooltip="{ disabled: listItem.destroy_path }" + class="d-none d-sm-block" + :title="$options.i18n.deleteButtonDisabled" > - <gl-icon name="remove" /> - </gl-deprecated-button> + <gl-deprecated-button + ref="deleteImageButton" + v-gl-tooltip + :disabled="!listItem.destroy_path || listItem.deleting" + :title="$options.i18n.removeRepositoryLabel" + :aria-label="$options.i18n.removeRepositoryLabel" + class="btn-inverted" + variant="danger" + @click="deleteImage(listItem)" + > + <gl-icon name="remove" /> + </gl-deprecated-button> + </div> </div> </div> <gl-pagination diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 2abd72cb9a8..b4f66dbbcd6 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -88,14 +88,12 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) }); }; -export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => { +export const requestDeleteImage = ({ commit }, image) => { commit(types.SET_MAIN_LOADING, true); - return axios - .delete(destroyPath) + .delete(image.destroy_path) .then(() => { - dispatch('setShowGarbageCollectionTip', true); - dispatch('requestImagesList', { pagination: state.pagination }); + commit(types.UPDATE_IMAGE, { ...image, deleting: true }); }) .finally(() => { commit(types.SET_MAIN_LOADING, false); diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js index 86eaa0dd2f1..f32cdf90783 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js +++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js @@ -1,6 +1,7 @@ export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; +export const UPDATE_IMAGE = 'UPDATE_IMAGE'; export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION'; diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js index fda788051c0..b25a0221dc1 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ b/app/assets/javascripts/registry/explorer/stores/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants'; export default { [types.SET_INITIAL_STATE](state, config) { @@ -12,7 +13,17 @@ export default { }, [types.SET_IMAGES_LIST_SUCCESS](state, images) { - state.images = images; + state.images = images.map(i => ({ + ...i, + status: undefined, + deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS, + failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS, + })); + }, + + [types.UPDATE_IMAGE](state, image) { + const index = state.images.findIndex(i => i.id === image.id); + state.images.splice(index, 1, { ...image }); }, [types.SET_TAGS_LIST_SUCCESS](state, tags) { diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue index 6aae9195be1..256b0e33e79 100644 --- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue +++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue @@ -26,18 +26,11 @@ export default { * The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation. * Here we simply split the string on `.` and get the code in the 5th position */ - if (this.issue.code === undefined) { - return null; - } - - return this.issue.code.split('.')[4] || null; + return this.issue.code?.split('.')[4]; }, learnMoreUrl() { - if (this.parsedTECHSCode === null) { - return 'https://www.w3.org/TR/WCAG20-TECHS/Overview.html'; - } - - return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode}.html`; + // eslint-disable-next-line @gitlab/require-i18n-strings + return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode || 'Overview'}.html`; }, }, }; diff --git a/app/assets/javascripts/reports/accessibility_report/store/actions.js b/app/assets/javascripts/reports/accessibility_report/store/actions.js new file mode 100644 index 00000000000..f145b352e7d --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/actions.js @@ -0,0 +1,47 @@ +import axios from '~/lib/utils/axios_utils'; +import * as types from './mutation_types'; +import { parseAccessibilityReport, compareAccessibilityReports } from './utils'; +import { s__ } from '~/locale'; + +export const fetchReport = ({ state, dispatch, commit }) => { + commit(types.REQUEST_REPORT); + + // If we don't have both endpoints, throw an error. + if (!state.baseEndpoint || !state.headEndpoint) { + commit( + types.RECEIVE_REPORT_ERROR, + s__('AccessibilityReport|Accessibility report artifact not found'), + ); + return; + } + + Promise.all([ + axios.get(state.baseEndpoint).then(response => ({ + ...response.data, + isHead: false, + })), + axios.get(state.headEndpoint).then(response => ({ + ...response.data, + isHead: true, + })), + ]) + .then(responses => dispatch('receiveReportSuccess', responses)) + .catch(() => + commit( + types.RECEIVE_REPORT_ERROR, + s__('AccessibilityReport|Failed to retrieve accessibility report'), + ), + ); +}; + +export const receiveReportSuccess = ({ commit }, responses) => { + const parsedReports = responses.map(response => ({ + isHead: response.isHead, + issues: parseAccessibilityReport(response), + })); + const report = compareAccessibilityReports(parsedReports); + commit(types.RECEIVE_REPORT_SUCCESS, report); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/index.js b/app/assets/javascripts/reports/accessibility_report/store/index.js new file mode 100644 index 00000000000..c1413499802 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default initialState => + new Vuex.Store({ + actions, + mutations, + state: state(initialState), + }); diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js new file mode 100644 index 00000000000..381736bbd38 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js @@ -0,0 +1,3 @@ +export const REQUEST_REPORT = 'REQUEST_REPORT'; +export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS'; +export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR'; diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutations.js b/app/assets/javascripts/reports/accessibility_report/store/mutations.js new file mode 100644 index 00000000000..66cf9f3d69d --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/mutations.js @@ -0,0 +1,18 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_REPORT](state) { + state.isLoading = true; + }, + [types.RECEIVE_REPORT_SUCCESS](state, report) { + state.hasError = false; + state.isLoading = false; + state.report = report; + }, + [types.RECEIVE_REPORT_ERROR](state, message) { + state.isLoading = false; + state.hasError = true; + state.errorMessage = message; + state.report = {}; + }, +}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/state.js b/app/assets/javascripts/reports/accessibility_report/store/state.js new file mode 100644 index 00000000000..7d560a9f419 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/state.js @@ -0,0 +1,30 @@ +export default (initialState = {}) => ({ + baseEndpoint: initialState.baseEndpoint || '', + headEndpoint: initialState.headEndpoint || '', + + isLoading: initialState.isLoading || false, + hasError: initialState.hasError || false, + + /** + * Report will have the following format: + * { + * status: {String}, + * summary: { + * total: {Number}, + * notes: {Number}, + * warnings: {Number}, + * errors: {Number}, + * }, + * existing_errors: {Array.<Object>}, + * existing_notes: {Array.<Object>}, + * existing_warnings: {Array.<Object>}, + * new_errors: {Array.<Object>}, + * new_notes: {Array.<Object>}, + * new_warnings: {Array.<Object>}, + * resolved_errors: {Array.<Object>}, + * resolved_notes: {Array.<Object>}, + * resolved_warnings: {Array.<Object>}, + * } + */ + report: initialState.report || {}, +}); diff --git a/app/assets/javascripts/reports/accessibility_report/store/utils.js b/app/assets/javascripts/reports/accessibility_report/store/utils.js new file mode 100644 index 00000000000..f2de65445b0 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/utils.js @@ -0,0 +1,83 @@ +import { difference, intersection } from 'lodash'; +import { + STATUS_FAILED, + STATUS_SUCCESS, + ACCESSIBILITY_ISSUE_ERROR, + ACCESSIBILITY_ISSUE_WARNING, +} from '../../constants'; + +export const parseAccessibilityReport = data => { + // Combine all issues into one array + return Object.keys(data.results) + .map(key => [...data.results[key]]) + .flat() + .map(issue => JSON.stringify(issue)); // stringify to help with comparisons +}; + +export const compareAccessibilityReports = reports => { + const result = { + status: '', + summary: { + total: 0, + notes: 0, + errors: 0, + warnings: 0, + }, + new_errors: [], + new_notes: [], + new_warnings: [], + resolved_errors: [], + resolved_notes: [], + resolved_warnings: [], + existing_errors: [], + existing_notes: [], + existing_warnings: [], + }; + + const headReport = reports.filter(report => report.isHead)[0]; + const baseReport = reports.filter(report => !report.isHead)[0]; + + // existing issues are those that exist in both the head report and the base report + const existingIssues = intersection(headReport.issues, baseReport.issues); + // new issues are those that exist in only the head report + const newIssues = difference(headReport.issues, baseReport.issues); + // resolved issues are those that exist in only the base report + const resolvedIssues = difference(baseReport.issues, headReport.issues); + + const parseIssues = (issue, issueType, shouldCount) => { + const parsedIssue = JSON.parse(issue); + switch (parsedIssue.type) { + case ACCESSIBILITY_ISSUE_ERROR: + result[`${issueType}_errors`].push(parsedIssue); + if (shouldCount) { + result.summary.errors += 1; + } + break; + case ACCESSIBILITY_ISSUE_WARNING: + result[`${issueType}_warnings`].push(parsedIssue); + if (shouldCount) { + result.summary.warnings += 1; + } + break; + default: + result[`${issueType}_notes`].push(parsedIssue); + if (shouldCount) { + result.summary.notes += 1; + } + break; + } + }; + + existingIssues.forEach(issue => parseIssues(issue, 'existing', true)); + newIssues.forEach(issue => parseIssues(issue, 'new', true)); + resolvedIssues.forEach(issue => parseIssues(issue, 'resolved', false)); + + result.summary.total = result.summary.errors + result.summary.warnings + result.summary.notes; + const hasErrorsOrWarnings = result.summary.errors > 0 || result.summary.warnings > 0; + result.status = hasErrorsOrWarnings ? STATUS_FAILED : STATUS_SUCCESS; + + return result; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 88d174f96ed..0f7a0e60dc0 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; +import { sprintf, s__ } from '~/locale'; import { componentNames } from './issue_body'; import ReportSection from './report_section.vue'; import SummaryRow from './summary_row.vue'; @@ -52,8 +52,17 @@ export default { methods: { ...mapActions(['setEndpoint', 'fetchReports']), reportText(report) { - const summary = report.summary || {}; - return reportTextBuilder(report.name, summary); + const { name, summary } = report || {}; + + if (report.status === 'error') { + return sprintf(s__('Reports|An error occurred while loading %{name} results'), { name }); + } + + if (!report.name) { + return s__('Reports|An error occured while loading report'); + } + + return reportTextBuilder(name, summary); }, getReportIcon(report) { return statusIcon(report.status); diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index 1845b51e6b2..b3905cbfcfb 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -22,3 +22,6 @@ export const status = { ERROR: 'ERROR', SUCCESS: 'SUCCESS', }; + +export const ACCESSIBILITY_ISSUE_ERROR = 'error'; +export const ACCESSIBILITY_ISSUE_WARNING = 'warning'; diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js index 68f6de3a7ee..35ab72bf694 100644 --- a/app/assets/javascripts/reports/store/mutations.js +++ b/app/assets/javascripts/reports/store/mutations.js @@ -8,8 +8,7 @@ export default { state.isLoading = true; }, [types.RECEIVE_REPORTS_SUCCESS](state, response) { - // Make sure to clean previous state in case it was an error - state.hasError = false; + state.hasError = response.suites.some(suite => suite.status === 'error'); state.isLoading = false; diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 6c58f48dc74..d78b2d9d962 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -108,14 +108,14 @@ export default { return acc.concat({ name, path, - to: `/-/tree/${joinPaths(escape(this.ref), path)}`, + to: `/-/tree/${joinPaths(escapeFileUrl(this.ref), path)}`, }); }, [ { name: this.projectShortPath, path: '/', - to: `/-/tree/${escape(this.ref)}/`, + to: `/-/tree/${escapeFileUrl(this.ref)}/`, }, ], ); diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index f9fcbc356e8..0a8ee5f2fc5 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -1,5 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; +import { escapeFileUrl } from '~/lib/utils/url_utility'; export default { components: { @@ -28,7 +29,7 @@ export default { return splitArray.map(p => encodeURIComponent(p)).join('/'); }, parentRoute() { - return { path: `/-/tree/${escape(this.commitRef)}/${this.parentPath}` }; + return { path: `/-/tree/${escapeFileUrl(this.commitRef)}/${this.parentPath}` }; }, }, methods: { diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 00ccc49d770..6bd1c702a82 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -99,7 +99,7 @@ export default { computed: { routerLinkTo() { return this.isFolder - ? { path: `/-/tree/${escape(this.ref)}/${escapeFileUrl(this.path)}` } + ? { path: `/-/tree/${escapeFileUrl(this.ref)}/${escapeFileUrl(this.path)}` } : null; }, isFolder() { diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 0c68b5a599b..6640b636597 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -48,7 +48,7 @@ const defaultClient = createDefaultClient( case 'TreeEntry': case 'Submodule': case 'Blob': - return `${escape(obj.flatPath)}-${obj.id}`; + return `${encodeURIComponent(obj.flatPath)}-${obj.id}`; default: // If the type doesn't match any of the above we fallback // to using the default Apollo ID diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 637060f6ed9..05783fc3b5d 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -100,7 +100,9 @@ export default function setupVueRepositoryList() { render(h) { return h(TreeActionLink, { props: { - path: `${historyLink}/${this.$route.params.path ? escape(this.$route.params.path) : ''}`, + path: `${historyLink}/${ + this.$route.params.path ? encodeURIComponent(this.$route.params.path) : '' + }`, text: __('History'), }, }); diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 550ec3cb0d1..0bb33de0234 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -1,7 +1,6 @@ /* eslint-disable func-names, consistent-return, no-param-reassign */ import $ from 'jquery'; -import _ from 'underscore'; import Cookies from 'js-cookie'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -142,7 +141,7 @@ Sidebar.prototype.sidebarCollapseClicked = function(e) { }; Sidebar.prototype.openDropdown = function(blockOrName) { - const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; + const $block = typeof blockOrName === 'string' ? this.getBlock(blockOrName) : blockOrName; if (!this.isOpen()) { this.setCollapseAfterUpdate($block); this.toggleSidebar('open'); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 3eaa34c8a93..0e32bb5e49f 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,7 +1,7 @@ /* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; -import { escape, throttle } from 'underscore'; +import { escape as esc, throttle } from 'lodash'; import { s__, __ } from '~/locale'; import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; import axios from './lib/utils/axios_utils'; @@ -448,7 +448,7 @@ export class SearchAutocomplete { const avatar = avatarUrl ? `<img class="search-item-avatar" src="${avatarUrl}" />` : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle( - escape(label), + esc(label), )}</div>`; return avatar; diff --git a/app/assets/javascripts/snippet/snippet_edit.js b/app/assets/javascripts/snippet/snippet_edit.js index a098d17a226..b0d373b1a4b 100644 --- a/app/assets/javascripts/snippet/snippet_edit.js +++ b/app/assets/javascripts/snippet/snippet_edit.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import initSnippet from '~/snippet/snippet_bundle'; import ZenMode from '~/zen_mode'; import GLForm from '~/gl_form'; +import { SnippetEditInit } from '~/snippets'; document.addEventListener('DOMContentLoaded', () => { const form = document.querySelector('.snippet-form'); @@ -17,9 +18,15 @@ document.addEventListener('DOMContentLoaded', () => { const projectSnippetOptions = {}; const options = - form.dataset.snippetType === 'project' ? projectSnippetOptions : personalSnippetOptions; + form.dataset.snippetType === 'project' || form.dataset.projectPath + ? projectSnippetOptions + : personalSnippetOptions; - initSnippet(); + if (gon?.features?.snippetsEditVue) { + SnippetEditInit(); + } else { + initSnippet(); + new GLForm($(form), options); // eslint-disable-line no-new + } new ZenMode(); // eslint-disable-line no-new - new GLForm($(form), options); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue new file mode 100644 index 00000000000..7f93014b93b --- /dev/null +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -0,0 +1,216 @@ +<script> +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; + +import Flash from '~/flash'; +import { __, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import TitleField from '~/vue_shared/components/form/title.vue'; +import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility'; +import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; + +import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; +import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql'; +import { getSnippetMixin } from '../mixins/snippets'; +import { SNIPPET_VISIBILITY_PRIVATE } from '../constants'; +import SnippetBlobEdit from './snippet_blob_edit.vue'; +import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; +import SnippetDescriptionEdit from './snippet_description_edit.vue'; + +export default { + components: { + SnippetDescriptionEdit, + SnippetVisibilityEdit, + SnippetBlobEdit, + TitleField, + FormFooterActions, + GlButton, + GlLoadingIcon, + }, + mixins: [getSnippetMixin], + props: { + markdownPreviewPath: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, + visibilityHelpLink: { + type: String, + default: '', + required: false, + }, + projectPath: { + type: String, + default: '', + required: false, + }, + }, + data() { + return { + blob: {}, + fileName: '', + content: '', + isContentLoading: true, + isUpdating: false, + newSnippet: false, + }; + }, + computed: { + updatePrevented() { + return this.snippet.title === '' || this.content === '' || this.isUpdating; + }, + isProjectSnippet() { + return Boolean(this.projectPath); + }, + apiData() { + return { + id: this.snippet.id, + title: this.snippet.title, + description: this.snippet.description, + visibilityLevel: this.snippet.visibilityLevel, + fileName: this.fileName, + content: this.content, + }; + }, + saveButtonLabel() { + if (this.newSnippet) { + return __('Create snippet'); + } + return this.isUpdating ? __('Saving') : __('Save changes'); + }, + cancelButtonHref() { + if (this.newSnippet) { + return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`; + } + return this.snippet.webUrl; + }, + titleFieldId() { + return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`; + }, + descriptionFieldId() { + return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`; + }, + }, + methods: { + updateFileName(newName) { + this.fileName = newName; + }, + flashAPIFailure(err) { + Flash(sprintf(__("Can't update snippet: %{err}"), { err })); + }, + onNewSnippetFetched() { + this.newSnippet = true; + this.snippet = this.$options.newSnippetSchema; + this.blob = this.snippet.blob; + this.isContentLoading = false; + }, + onExistingSnippetFetched() { + this.newSnippet = false; + const { blob } = this.snippet; + this.blob = blob; + this.fileName = blob.name; + const baseUrl = getBaseURL(); + const url = joinPaths(baseUrl, blob.rawPath); + + axios + .get(url) + .then(res => { + this.content = res.data; + this.isContentLoading = false; + }) + .catch(e => this.flashAPIFailure(e)); + }, + onSnippetFetch(snippetRes) { + if (snippetRes.data.snippets.edges.length === 0) { + this.onNewSnippetFetched(); + } else { + this.onExistingSnippetFetched(); + } + }, + handleFormSubmit() { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: this.newSnippet ? CreateSnippetMutation : UpdateSnippetMutation, + variables: { + input: { + ...this.apiData, + projectPath: this.newSnippet ? this.projectPath : undefined, + }, + }, + }) + .then(({ data }) => { + const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet; + + const errors = baseObj?.errors; + if (errors.length) { + this.flashAPIFailure(errors[0]); + } + redirectTo(baseObj.snippet.webUrl); + }) + .catch(e => { + this.isUpdating = false; + this.flashAPIFailure(e); + }); + }, + }, + newSnippetSchema: { + title: '', + description: '', + visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, + blob: {}, + }, +}; +</script> +<template> + <form + class="snippet-form js-requires-input js-quick-submit common-note-form" + :data-snippet-type="isProjectSnippet ? 'project' : 'personal'" + > + <gl-loading-icon + v-if="isLoading" + :label="__('Loading snippet')" + size="lg" + class="loading-animation prepend-top-20 append-bottom-20" + /> + <template v-else> + <title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" /> + <snippet-description-edit + :id="descriptionFieldId" + v-model="snippet.description" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + /> + <snippet-blob-edit + v-model="content" + :file-name="fileName" + :is-loading="isContentLoading" + @name-change="updateFileName" + /> + <snippet-visibility-edit + v-model="snippet.visibilityLevel" + :help-link="visibilityHelpLink" + :is-project-snippet="isProjectSnippet" + /> + <form-footer-actions> + <template #prepend> + <gl-button + type="submit" + category="primary" + variant="success" + :disabled="updatePrevented" + @click="handleFormSubmit" + >{{ saveButtonLabel }}</gl-button + > + </template> + <template #append> + <gl-button data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{ + __('Cancel') + }}</gl-button> + </template> + </form-footer-actions> + </template> + </form> +</template> diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 68810f8ab3f..6f3a86be8d7 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -50,7 +50,6 @@ export default { :markdown-docs-path="markdownDocsPath" > <textarea - id="snippet-description" slot="textarea" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" @@ -59,6 +58,7 @@ export default { :value="value" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files hereā¦')" + v-bind="$attrs" @input="$emit('input', $event.target.value)" > </textarea> diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index b826110117c..1c79492957d 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -3,7 +3,8 @@ import Translate from '~/vue_shared/translate'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import SnippetsApp from './components/show.vue'; +import SnippetsShow from './components/show.vue'; +import SnippetsEdit from './components/edit.vue'; Vue.use(VueApollo); Vue.use(Translate); @@ -31,7 +32,11 @@ function appFactory(el, Component) { } export const SnippetShowInit = () => { - appFactory(document.getElementById('js-snippet-view'), SnippetsApp); + appFactory(document.getElementById('js-snippet-view'), SnippetsShow); +}; + +export const SnippetEditInit = () => { + appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit); }; export default () => {}; diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql new file mode 100644 index 00000000000..f688868d1b9 --- /dev/null +++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql @@ -0,0 +1,8 @@ +mutation CreateSnippet($input: CreateSnippetInput!) { + createSnippet(input: $input) { + errors + snippet { + webUrl + } + } +} diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql new file mode 100644 index 00000000000..548725f7357 --- /dev/null +++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql @@ -0,0 +1,8 @@ +mutation UpdateSnippet($input: UpdateSnippetInput!) { + updateSnippet(input: $input) { + errors + snippet { + webUrl + } + } +} diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue index f8cc6d1630b..82917319fc3 100644 --- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue +++ b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue @@ -4,6 +4,7 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import EditArea from './edit_area.vue'; import EditHeader from './edit_header.vue'; +import SavedChangesMessage from './saved_changes_message.vue'; import Toolbar from './publish_toolbar.vue'; import InvalidContentMessage from './invalid_content_message.vue'; import SubmitChangesError from './submit_changes_error.vue'; @@ -14,6 +15,7 @@ export default { EditHeader, InvalidContentMessage, GlSkeletonLoader, + SavedChangesMessage, Toolbar, SubmitChangesError, }, @@ -27,6 +29,7 @@ export default { 'returnUrl', 'title', 'submitChangesError', + 'savedContentMeta', ]), ...mapGetters(['contentChanged']), }, @@ -41,8 +44,18 @@ export default { }; </script> <template> - <div class="d-flex justify-content-center h-100 pt-2"> - <template v-if="isSupportedContent"> + <div class="d-flex justify-content-center h-100 pt-2"> + <!-- Success view --> + <saved-changes-message + v-if="savedContentMeta" + :branch="savedContentMeta.branch" + :commit="savedContentMeta.commit" + :merge-request="savedContentMeta.mergeRequest" + :return-url="returnUrl" + /> + + <!-- Main view --> + <template v-else-if="isSupportedContent"> <div v-if="isLoadingContent" class="w-50 h-50"> <gl-skeleton-loader :width="500" :height="102"> <rect width="500" height="16" rx="4" /> @@ -75,6 +88,8 @@ export default { /> </div> </template> + + <!-- Error view --> <invalid-content-message v-else class="w-75" /> </div> </template> diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 09fe952e5f0..42ab44aa03c 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { omitBy, isUndefined } from 'lodash'; const DEFAULT_SNOWPLOW_OPTIONS = { namespace: 'gl', @@ -29,7 +29,7 @@ const eventHandler = (e, func, opts = {}) => { context: el.dataset.trackContext, }; - func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined)); + func(opts.category, action + (opts.suffix || ''), omitBy(data, isUndefined)); }; const eventHandlers = (category, func) => { diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index f8c1c3634c2..bde00d72620 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -38,8 +38,7 @@ const populateUserInfo = user => { name: userData.name, location: userData.location, bio: userData.bio, - organization: userData.organization, - jobTitle: userData.job_title, + workInformation: userData.work_information, loaded: true, }); } @@ -71,7 +70,7 @@ export default (elements = document.querySelectorAll('.js-user-link')) => { const user = { location: null, bio: null, - organization: null, + workInformation: null, status: null, loaded: false, }; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 6821df57b5a..debf8c57b43 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -3,7 +3,7 @@ /* global emitSidebarEvent */ import $ from 'jquery'; -import _ from 'underscore'; +import { escape as esc, template, uniqBy } from 'lodash'; import axios from './lib/utils/axios_utils'; import { s__, __, sprintf } from './locale'; import ModalStore from './boards/stores/modal_store'; @@ -81,7 +81,7 @@ function UsersSelect(currentUser, els, options = {}) { const userName = currentUserInfo.name; const userId = currentUserInfo.id || currentUser.id; - const inputHtmlString = _.template(` + const inputHtmlString = template(` <input type="hidden" name="<%- fieldName %>" data-meta="<%- userName %>" value="<%- userId %>" /> @@ -205,7 +205,7 @@ function UsersSelect(currentUser, els, options = {}) { username: data.assignee.username, avatar: data.assignee.avatar_url, }; - tooltipTitle = _.escape(user.name); + tooltipTitle = esc(user.name); } else { user = { name: s__('UsersSelect|Unassigned'), @@ -219,10 +219,10 @@ function UsersSelect(currentUser, els, options = {}) { return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); }); }; - collapsedAssigneeTemplate = _.template( + collapsedAssigneeTemplate = template( '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', ); - assigneeTemplate = _.template( + assigneeTemplate = template( `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { openingTag: '<a href="#" class="js-assign-yourself">', @@ -248,7 +248,7 @@ function UsersSelect(currentUser, els, options = {}) { // Potential duplicate entries when dealing with issue board // because issue board is also managed by vue - const selectedUsers = _.uniq(selectedInputs, false, a => a.value) + const selectedUsers = uniqBy(selectedInputs, a => a.value) .filter(input => { const userId = parseInt(input.value, 10); const inUsersArray = users.find(u => u.id === userId); @@ -543,7 +543,7 @@ function UsersSelect(currentUser, els, options = {}) { let img = ''; if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape( + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${esc( user.name, )}</a></li>`; } else { @@ -672,10 +672,10 @@ UsersSelect.prototype.formatResult = function(user) { </div> <div class='user-info'> <div class='user-name dropdown-menu-user-full-name'> - ${_.escape(user.name)} + ${esc(user.name)} </div> <div class='user-username dropdown-menu-user-username text-secondary'> - ${!user.invite ? `@${_.escape(user.username)}` : ''} + ${!user.invite ? `@${esc(user.username)}` : ''} </div> </div> </div> @@ -683,7 +683,7 @@ UsersSelect.prototype.formatResult = function(user) { }; UsersSelect.prototype.formatSelection = function(user) { - return _.escape(user.name); + return esc(user.name); }; UsersSelect.prototype.user = function(user_id, callback) { @@ -746,7 +746,7 @@ UsersSelect.prototype.renderRow = function(issuableType, user, selected, usernam ${this.renderRowAvatar(issuableType, user, img)} <span class="d-flex flex-column overflow-hidden"> <strong class="dropdown-menu-user-full-name"> - ${_.escape(user.name)} + ${esc(user.name)} </strong> ${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''} </span> diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue new file mode 100644 index 00000000000..848295cc984 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -0,0 +1,178 @@ +<script> +import { groupBy } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { glEmojiTag } from '../../emoji'; +import { __, sprintf } from '~/locale'; + +// Internal constant, specific to this component, used when no `currentUserId` is given +const NO_USER_ID = -1; + +export default { + components: { + GlIcon, + }, + directives: { + tooltip, + }, + props: { + awards: { + type: Array, + required: true, + }, + canAwardEmoji: { + type: Boolean, + required: true, + }, + currentUserId: { + type: Number, + required: false, + default: NO_USER_ID, + }, + addButtonClass: { + type: String, + required: false, + default: '', + }, + }, + computed: { + groupedAwards() { + const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name); + + return [ + ...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []), + ...(thumbsdown ? [this.createAwardList('thumbsdown', thumbsdown)] : []), + ...Object.entries(rest).map(([name, list]) => this.createAwardList(name, list)), + ]; + }, + isAuthoredByMe() { + return this.noteAuthorId === this.currentUserId; + }, + }, + methods: { + getAwardClassBindings(awardList) { + return { + active: this.hasReactionByCurrentUser(awardList), + disabled: this.currentUserId === NO_USER_ID, + }; + }, + hasReactionByCurrentUser(awardList) { + if (this.currentUserId === NO_USER_ID) { + return false; + } + + return awardList.some(award => award.user.id === this.currentUserId); + }, + createAwardList(name, list) { + return { + name, + list, + title: this.getAwardListTitle(list), + classes: this.getAwardClassBindings(list), + html: glEmojiTag(name), + }; + }, + getAwardListTitle(awardsList) { + const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); + const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; + let awardList = awardsList; + + // Filter myself from list if I am awarded. + if (hasReactionByCurrentUser) { + awardList = awardList.filter(award => award.user.id !== this.currentUserId); + } + + // Get only 9-10 usernames to show in tooltip text. + const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); + + // Get the remaining list to use in `and x more` text. + const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); + + // Add myself to the beginning of the list so title will start with You. + if (hasReactionByCurrentUser) { + namesToShow.unshift(__('You')); + } + + let title = ''; + + // We have 10+ awarded user, join them with comma and add `and x more`. + if (remainingAwardList.length) { + title = sprintf( + __(`%{listToShow}, and %{awardsListLength} more.`), + { + listToShow: namesToShow.join(', '), + awardsListLength: remainingAwardList.length, + }, + false, + ); + } else if (namesToShow.length > 1) { + // Join all names with comma but not the last one, it will be added with and text. + title = namesToShow.slice(0, namesToShow.length - 1).join(', '); + // If we have more than 2 users we need an extra comma before and text. + title += namesToShow.length > 2 ? ',' : ''; + title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text + } else { + // We have only 2 users so join them with and. + title = namesToShow.join(__(' and ')); + } + + return title; + }, + handleAward(awardName) { + if (!this.canAwardEmoji) { + return; + } + + // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string + const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName; + + this.$emit('award', parsedName); + }, + }, +}; +</script> + +<template> + <div class="awards js-awards-block"> + <button + v-for="awardList in groupedAwards" + :key="awardList.name" + v-tooltip + :class="awardList.classes" + :title="awardList.title" + data-boundary="viewport" + data-testid="award-button" + class="btn award-control" + type="button" + @click="handleAward(awardList.name)" + > + <span data-testid="award-html" v-html="awardList.html"></span> + <span class="award-control-text js-counter">{{ awardList.list.length }}</span> + </button> + <div v-if="canAwardEmoji" class="award-menu-holder"> + <button + v-tooltip + :class="addButtonClass" + class="award-control btn js-add-award" + title="Add reaction" + :aria-label="__('Add reaction')" + data-boundary="viewport" + type="button" + > + <span class="award-control-icon award-control-icon-neutral"> + <gl-icon aria-hidden="true" name="slight-smile" /> + </span> + <span class="award-control-icon award-control-icon-positive"> + <gl-icon aria-hidden="true" name="smiley" /> + </span> + <span class="award-control-icon award-control-icon-super-positive"> + <gl-icon aria-hidden="true" name="smiley" /> + </span> + <i + aria-hidden="true" + class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" + ></i> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index cdcd5cdef7f..ffc616d7309 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -158,7 +158,7 @@ export default { <template> <tooltip-on-truncate :title="timeWindowText" - :truncate-target="elem => elem.querySelector('.date-time-picker-toggle')" + :truncate-target="elem => elem.querySelector('.gl-dropdown-toggle-text')" placement="top" class="d-inline-block" > diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue index f8f70529bd1..fad69dc1e24 100644 --- a/app/assets/javascripts/vue_shared/components/form/title.vue +++ b/app/assets/javascripts/vue_shared/components/form/title.vue @@ -10,6 +10,6 @@ export default { </script> <template> <gl-form-group :label="__('Title')" label-for="title-field-edit"> - <gl-form-input id="title-field-edit" v-bind="$attrs" v-on="$listeners" /> + <gl-form-input v-bind="$attrs" v-on="$listeners" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index 913c971a512..040a15406e0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -37,7 +37,7 @@ export default { :title="tooltipLabel" :class="cssClasses" type="button" - class="btn btn-blank gutter-toggle btn-sidebar-action" + class="btn btn-blank gutter-toggle btn-sidebar-action js-sidebar-vue-toggle" data-container="body" data-placement="left" data-boundary="viewport" 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 602d4ab89e1..595baeeb14f 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 @@ -1,10 +1,8 @@ <script> -import { GlPopover, GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; -import { s__ } from '~/locale'; -import { isString } from 'lodash'; export default { name: 'UserPopover', @@ -12,7 +10,6 @@ export default { Icon, GlPopover, GlSkeletonLoading, - GlSprintf, UserAvatarImage, }, props: { @@ -49,26 +46,7 @@ export default { return !this.user.name; }, workInformationIsLoading() { - return !this.user.loaded && this.workInformation === null; - }, - workInformation() { - const { jobTitle, organization } = this.user; - - if (organization && jobTitle) { - return { - message: s__('Profile|%{job_title} at %{organization}'), - placeholders: { job_title: jobTitle, organization }, - }; - } else if (organization) { - return organization; - } else if (jobTitle) { - return jobTitle; - } - - return null; - }, - workInformationShouldUseSprintf() { - return !isString(this.workInformation); + return !this.user.loaded && this.user.workInformation === null; }, locationIsLoading() { return !this.user.loaded && this.user.location === null; @@ -98,23 +76,13 @@ export default { <icon name="profile" class="category-icon flex-shrink-0" /> <span ref="bio" class="ml-1">{{ user.bio }}</span> </div> - <div v-if="workInformation" class="d-flex mb-1"> + <div v-if="user.workInformation" class="d-flex mb-1"> <icon v-show="!workInformationIsLoading" name="work" class="category-icon flex-shrink-0" /> - <span ref="workInformation" class="ml-1"> - <gl-sprintf v-if="workInformationShouldUseSprintf" :message="workInformation.message"> - <template - v-for="(placeholder, slotName) in workInformation.placeholders" - v-slot:[slotName] - > - <span :key="slotName">{{ placeholder }}</span> - </template> - </gl-sprintf> - <span v-else>{{ workInformation }}</span> - </span> + <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span> </div> <gl-skeleton-loading v-if="workInformationIsLoading" |