summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-04-21 15:21:10 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-04-21 15:21:10 +0000
commite33f87ac0fabaab468ce4b457996cc0f1b1bb648 (patch)
tree8bf0de72a9acac014cfdaddab7d463b208294af2 /app/assets/javascripts
parent5baf990db20a75078684702782c24399ef9eb0fa (diff)
downloadgitlab-ce-e33f87ac0fabaab468ce4b457996cc0f1b1bb648.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list.vue62
-rw-r--r--app/assets/javascripts/alert_management/list.js25
-rw-r--r--app/assets/javascripts/alert_management/services/index.js7
-rw-r--r--app/assets/javascripts/api.js10
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_content.vue9
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_key_field.vue169
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js29
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue100
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js5
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js10
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue39
-rw-r--r--app/assets/javascripts/clusters/components/fluentd_output_settings.vue159
-rw-r--r--app/assets/javascripts/clusters/constants.js2
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js1
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js15
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/contextual_sidebar.js4
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js6
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue8
-rw-r--r--app/assets/javascripts/diffs/constants.js1
-rw-r--r--app/assets/javascripts/diffs/store/getters_versions_dropdowns.js20
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js17
-rw-r--r--app/assets/javascripts/diffs/store/utils.js9
-rw-r--r--app/assets/javascripts/dropzone_input.js6
-rw-r--r--app/assets/javascripts/filterable_list.js4
-rw-r--r--app/assets/javascripts/flash.js6
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js20
-rw-r--r--app/assets/javascripts/gl_dropdown.js10
-rw-r--r--app/assets/javascripts/gl_form.js6
-rw-r--r--app/assets/javascripts/groups/components/groups.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue14
-rw-r--r--app/assets/javascripts/importer_status.js6
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js8
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js2
-rw-r--r--app/assets/javascripts/issue.js58
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_app.vue115
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue50
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_progress.vue66
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_setup.vue23
-rw-r--r--app/assets/javascripts/jira_import/index.js3
-rw-r--r--app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql14
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql12
-rw-r--r--app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql11
-rw-r--r--app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql7
-rw-r--r--app/assets/javascripts/jira_import/utils.js10
-rw-r--r--app/assets/javascripts/labels_select.js24
-rw-r--r--app/assets/javascripts/lazy_loader.js8
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/index.js19
-rw-r--r--app/assets/javascripts/locale/sprintf.js2
-rw-r--r--app/assets/javascripts/main.js12
-rw-r--r--app/assets/javascripts/milestone_select.js14
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget.vue286
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue300
-rw-r--r--app/assets/javascripts/monitoring/components/charts/annotations.js97
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/charts/empty_chart.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/options.js6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue42
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue314
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue25
-rw-r--r--app/assets/javascripts/monitoring/components/embeds/metric_embed.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue42
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue55
-rw-r--r--app/assets/javascripts/monitoring/constants.js81
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js2
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js13
-rw-r--r--app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql29
-rw-r--r--app/assets/javascripts/monitoring/services/alerts_service.js32
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js22
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js35
-rw-r--r--app/assets/javascripts/monitoring/validators.js44
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue83
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue177
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue69
-rw-r--r--app/assets/javascripts/notes/stores/actions.js15
-rw-r--r--app/assets/javascripts/notes/stores/getters.js2
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js4
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/repository/show/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/alert_management/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue44
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue18
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js6
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js4
-rw-r--r--app/assets/javascripts/registry/explorer/components/project_policy_alert.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/constants.js45
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue168
-rw-r--r--app/assets/javascripts/registry/explorer/stores/actions.js8
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutations.js13
-rw-r--r--app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue13
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/actions.js47
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/index.js14
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/mutations.js18
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/state.js30
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/utils.js83
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue15
-rw-r--r--app/assets/javascripts/reports/constants.js3
-rw-r--r--app/assets/javascripts/reports/store/mutations.js3
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue4
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue3
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue2
-rw-r--r--app/assets/javascripts/repository/graphql.js2
-rw-r--r--app/assets/javascripts/repository/index.js4
-rw-r--r--app/assets/javascripts/right_sidebar.js3
-rw-r--r--app/assets/javascripts/search_autocomplete.js4
-rw-r--r--app/assets/javascripts/snippet/snippet_edit.js13
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue216
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue2
-rw-r--r--app/assets/javascripts/snippets/index.js9
-rw-r--r--app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql8
-rw-r--r--app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql8
-rw-r--r--app/assets/javascripts/static_site_editor/components/static_site_editor.vue19
-rw-r--r--app/assets/javascripts/tracking.js4
-rw-r--r--app/assets/javascripts/user_popovers.js5
-rw-r--r--app/assets/javascripts/users_select.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue178
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/form/title.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue40
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"