Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-04-20 13:00:54 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-04-20 13:00:54 +0300
commit3cccd102ba543e02725d247893729e5c73b38295 (patch)
treef36a04ec38517f5deaaacb5acc7d949688d1e187 /app/assets/javascripts/vue_shared
parent205943281328046ef7b4528031b90fbda70c75ac (diff)
Add latest changes from gitlab-org/gitlab@14-10-stable-eev14.10.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared')
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue11
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js12
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/service.js43
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue26
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue47
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/line_numbers.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue119
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue266
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/actions.js85
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/index.js14
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js39
-rw-r--r--app/assets/javascripts/vue_shared/components/metric_images/store/state.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue50
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue103
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js25
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue169
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/utils.js28
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue1
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue7
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js22
-rw-r--r--app/assets/javascripts/vue_shared/translate.js2
50 files changed, 1131 insertions, 409 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
index d595c49f9aa..948d2505966 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
@@ -21,12 +21,12 @@ import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue';
import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants';
import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql';
import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql';
import alertQuery from '../graphql/queries/alert_sidebar_details.query.graphql';
import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql';
-import AlertMetrics from './alert_metrics.vue';
import AlertSidebar from './alert_sidebar.vue';
import AlertSummaryRow from './alert_summary_row.vue';
import SystemNote from './system_notes/system_note.vue';
@@ -74,7 +74,7 @@ export default {
TimeAgoTooltip,
AlertSidebar,
SystemNote,
- AlertMetrics,
+ MetricImagesTab,
},
inject: {
projectPath: {
@@ -372,13 +372,12 @@ export default {
</alert-summary-row>
<alert-details-table :alert="alert" :loading="loading" :statuses="statuses" />
</gl-tab>
- <gl-tab
+
+ <metric-images-tab
v-if="!isThreatMonitoringPage"
:data-testid="$options.tabsConfig[1].id"
:title="$options.tabsConfig[1].title"
- >
- <alert-metrics :dashboard-url="alert.metricsDashboardUrl" />
- </gl-tab>
+ />
<gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title">
<div v-if="alert.notes.nodes.length > 0" class="issuable-discussion">
<ul class="notes main-notes-list timeline">
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index d0155c18b9c..614748fa80d 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -3,6 +3,9 @@ import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import createStore from '~/vue_shared/components/metric_images/store';
+import service from './service';
import AlertDetails from './components/alert_details.vue';
import { PAGE_CONFIG } from './constants';
import sidebarStatusQuery from './graphql/queries/alert_sidebar_status.query.graphql';
@@ -12,7 +15,8 @@ Vue.use(VueApollo);
export default (selector) => {
const domEl = document.querySelector(selector);
- const { alertId, projectPath, projectIssuesPath, projectId, page } = domEl.dataset;
+ const { alertId, projectPath, projectIssuesPath, projectId, page, canUpdate } = domEl.dataset;
+ const iid = alertId;
const router = createRouter();
const resolvers = {
@@ -54,15 +58,20 @@ export default (selector) => {
page,
projectIssuesPath,
projectId,
+ iid,
statuses: PAGE_CONFIG[page].STATUSES,
+ canUpdate: parseBoolean(canUpdate),
};
+ const opsProperties = {};
+
if (page === PAGE_CONFIG.OPERATIONS.TITLE) {
const { TRACK_ALERTS_DETAILS_VIEWS_OPTIONS, TRACK_ALERT_STATUS_UPDATE_OPTIONS } = PAGE_CONFIG[
page
];
provide.trackAlertsDetailsViewsOptions = TRACK_ALERTS_DETAILS_VIEWS_OPTIONS;
provide.trackAlertStatusUpdateOptions = TRACK_ALERT_STATUS_UPDATE_OPTIONS;
+ opsProperties.store = createStore({}, service);
} else if (page === PAGE_CONFIG.THREAT_MONITORING.TITLE) {
provide.isThreatMonitoringPage = true;
}
@@ -74,6 +83,7 @@ export default (selector) => {
components: {
AlertDetails,
},
+ ...opsProperties,
provide,
apolloProvider,
router,
diff --git a/app/assets/javascripts/vue_shared/alert_details/service.js b/app/assets/javascripts/vue_shared/alert_details/service.js
new file mode 100644
index 00000000000..90f4961103b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/service.js
@@ -0,0 +1,43 @@
+import {
+ fetchAlertMetricImages,
+ uploadAlertMetricImage,
+ updateAlertMetricImage,
+ deleteAlertMetricImage,
+} from '~/rest_api';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+function replaceModelIId(payload = {}) {
+ delete Object.assign(payload, { alertIid: payload.modelIid }).modelIid;
+ return payload;
+}
+
+export const getMetricImages = async (payload) => {
+ const apiPayload = replaceModelIId(payload);
+ const response = await fetchAlertMetricImages(apiPayload);
+ return convertObjectPropsToCamelCase(response.data, { deep: true });
+};
+
+export const uploadMetricImage = async (payload) => {
+ const apiPayload = replaceModelIId(payload);
+ const response = await uploadAlertMetricImage(apiPayload);
+ return convertObjectPropsToCamelCase(response.data);
+};
+
+export const updateMetricImage = async (payload) => {
+ const apiPayload = replaceModelIId(payload);
+ const response = await updateAlertMetricImage(apiPayload);
+ return convertObjectPropsToCamelCase(response.data);
+};
+
+export const deleteMetricImage = async (payload) => {
+ const apiPayload = replaceModelIId(payload);
+ const response = await deleteAlertMetricImage(apiPayload);
+ return convertObjectPropsToCamelCase(response.data);
+};
+
+export default {
+ getMetricImages,
+ uploadMetricImage,
+ updateMetricImage,
+ deleteMetricImage,
+};
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 96970f4ce2f..f5d8811e83c 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -4,7 +4,7 @@ import { groupBy } from 'lodash';
import EmojiPicker from '~/emoji/components/picker.vue';
import { __, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { glEmojiTag } from '../../emoji';
+import { glEmojiTag } from '~/emoji';
// Internal constant, specific to this component, used when no `currentUserId` is given
const NO_USER_ID = -1;
@@ -93,12 +93,14 @@ export default {
return awardList.some((award) => award.user.id === this.currentUserId);
},
createAwardList(name, list) {
+ const url = list.length ? list[0].url : null;
+
return {
name,
list,
title: this.getAwardListTitle(list, name),
classes: this.getAwardClassBindings(list),
- html: glEmojiTag(name),
+ html: glEmojiTag(name, { url }),
};
},
getAwardListTitle(awardsList, name) {
@@ -198,10 +200,10 @@ export default {
</gl-button>
<div v-if="canAwardEmoji" class="award-menu-holder gl-my-2">
<emoji-picker
- v-if="glFeatures.improvedEmojiPicker"
v-gl-tooltip.viewport
:title="__('Add reaction')"
:toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]"
+ data-testid="emoji-picker"
@click="handleAward"
@shown="setIsMenuOpen(true)"
@hidden="setIsMenuOpen(false)"
@@ -219,24 +221,6 @@ export default {
</span>
</template>
</emoji-picker>
- <gl-button
- v-else
- v-gl-tooltip.viewport
- :class="addButtonClass"
- class="add-reaction-button js-add-award"
- title="Add reaction"
- :aria-label="__('Add reaction')"
- >
- <span class="reaction-control-icon reaction-control-icon-neutral">
- <gl-icon name="slight-smile" />
- </span>
- <span class="reaction-control-icon reaction-control-icon-positive">
- <gl-icon name="smiley" />
- </span>
- <span class="reaction-control-icon reaction-control-icon-super-positive">
- <gl-icon name="smile" />
- </span>
- </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index 3aaa7d915ea..0117c06c3d5 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -1,7 +1,5 @@
<script>
import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import LineHighlighter from '~/blob/line_highlighter';
import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
@@ -13,7 +11,7 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
- mixins: [ViewerMixin, glFeatureFlagsMixin()],
+ mixins: [ViewerMixin],
inject: ['blobHash'],
data() {
return {
@@ -21,21 +19,14 @@ export default {
};
},
computed: {
- refactorBlobViewerEnabled() {
- return this.glFeatures.refactorBlobViewer;
- },
-
lineNumbers() {
return this.content.split('\n').length;
},
},
mounted() {
- if (this.refactorBlobViewerEnabled) {
- // This line will be removed once we start using highlight.js on the frontend (https://gitlab.com/groups/gitlab-org/-/epics/7146)
- new LineHighlighter(); // eslint-disable-line no-new
- } else {
- const { hash } = window.location;
- if (hash) this.scrollToLine(hash, true);
+ const { hash } = window.location;
+ if (hash) {
+ this.scrollToLine(hash, true);
}
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
index af85a2fda06..f28a2801bc0 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
-import { numberToHumanSize } from '../../../../lib/utils/number_utils';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
export default {
components: {
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue
deleted file mode 100644
index 733accdff44..00000000000
--- a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/default.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<script>
-import Identicon from '../identicon.vue';
-import ProjectAvatarImage from './image.vue';
-
-export default {
- name: 'DeprecatedProjectAvatar',
- components: {
- Identicon,
- ProjectAvatarImage,
- },
- props: {
- project: {
- type: Object,
- required: true,
- },
- size: {
- type: Number,
- default: 40,
- required: false,
- },
- },
- computed: {
- sizeClass() {
- return `s${this.size}`;
- },
- },
-};
-</script>
-
-<template>
- <span :class="sizeClass" class="avatar-container rect-avatar project-avatar">
- <project-avatar-image
- v-if="project.avatar_url"
- :link-href="project.path"
- :img-src="project.avatar_url"
- :img-alt="project.name"
- :img-size="size"
- />
- <identicon
- v-else
- :entity-id="project.id"
- :entity-name="project.name"
- :size-class="sizeClass"
- class="rect-avatar"
- />
- </span>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue
deleted file mode 100644
index 269736c799c..00000000000
--- a/app/assets/javascripts/vue_shared/components/deprecated_project_avatar/image.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-<script>
-/* This is a re-usable vue component for rendering a project avatar that
- does not need to link to the project's profile. The image and an optional
- tooltip can be configured by props passed to this component.
-
- Sample configuration:
-
- <project-avatar-image
- :lazy="true"
- :img-src="projectAvatarSrc"
- :img-alt="tooltipText"
- :tooltip-text="tooltipText"
- tooltip-placement="top"
- />
-
- */
-import defaultAvatarUrl from 'images/no_avatar.png';
-import { __ } from '~/locale';
-import { placeholderImage } from '../../../lazy_loader';
-
-export default {
- name: 'ProjectAvatarImage',
- props: {
- lazy: {
- type: Boolean,
- required: false,
- default: false,
- },
- imgSrc: {
- type: String,
- required: false,
- default: defaultAvatarUrl,
- },
- cssClasses: {
- type: String,
- required: false,
- default: '',
- },
- imgAlt: {
- type: String,
- required: false,
- default: __('project avatar'),
- },
- size: {
- type: Number,
- required: false,
- default: 20,
- },
- },
- computed: {
- // API response sends null when gravatar is disabled and
- // we provide an empty string when we use it inside project avatar link.
- // In both cases we should render the defaultAvatarUrl
- sanitizedSource() {
- return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- },
- resultantSrcAttribute() {
- return this.lazy ? placeholderImage : this.sanitizedSource;
- },
- avatarSizeClass() {
- return `s${this.size}`;
- },
- },
-};
-</script>
-
-<template>
- <img
- :class="{
- lazy: lazy,
- [avatarSizeClass]: true,
- [cssClasses]: true,
- }"
- :src="resultantSrcAttribute"
- :width="size"
- :height="size"
- :alt="imgAlt"
- :data-src="sanitizedSource"
- class="avatar"
- />
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
index 014276c7e36..d14d8c9b92e 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -37,7 +37,7 @@ export default {
<template>
<div v-show="showAlert">
- <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json />
+ <local-storage-sync v-model="isDismissed" :storage-key="storageKey" />
<gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert">
<slot></slot>
</gl-alert>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 5cdf7b6a3b2..6638a5de62f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -79,6 +79,16 @@ export default {
required: false,
default: '',
},
+ searchButtonAttributes: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ searchInputAttributes: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending;
@@ -163,33 +173,6 @@ export default {
return undefined;
},
},
- watch: {
- /**
- * GlFilteredSearch currently doesn't emit any event when
- * tokens are manually removed from search field so we'd
- * never know when user actually clears all the tokens.
- * This watcher listens for updates to `filterValue` on
- * such instances. :(
- */
- filterValue(newValue, oldValue) {
- const [firstVal] = newValue;
- if (
- !this.initialRender &&
- newValue.length === 1 &&
- firstVal.type === 'filtered-search-term' &&
- !firstVal.value.data
- ) {
- const filtersCleared =
- oldValue[0].type !== 'filtered-search-term' || oldValue[0].value.data !== '';
- this.$emit('onFilter', [], filtersCleared);
- }
-
- // Set initial render flag to false
- // as we don't want to emit event
- // on initial load when value is empty already.
- this.initialRender = false;
- },
- },
created() {
if (this.recentSearchesStorageKey) this.setupRecentSearch();
},
@@ -322,6 +305,10 @@ export default {
return tokenOption.title;
},
+ onClear() {
+ const cleared = true;
+ this.$emit('onFilter', [], cleared);
+ },
},
};
</script>
@@ -343,8 +330,11 @@ export default {
:available-tokens="tokens"
:history-items="filteredRecentSearches"
:suggestions-list-class="suggestionsListClass"
+ :search-button-attributes="searchButtonAttributes"
+ :search-input-attributes="searchInputAttributes"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
+ @clear="onClear"
@clear-history="handleClearHistory"
@submit="handleFilterSubmit"
>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index b70317b2ec4..696456be990 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -95,7 +95,6 @@ export default {
v-if="activeTokenValue"
:size="16"
:src="getAvatarUrl(activeTokenValue)"
- shape="circle"
class="gl-mr-2"
/>
{{ activeTokenValue ? activeTokenValue.name : inputValue }}
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index 06949b59823..69548f0e7a8 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -110,7 +110,7 @@ export default {
v-gl-tooltip.hover="toggleVisibilityLabel"
:aria-label="toggleVisibilityLabel"
:icon="toggleVisibilityIcon"
- @click="handleToggleVisibilityButtonClick"
+ @click.stop="handleToggleVisibilityButtonClick"
/>
<clipboard-button
v-if="showCopyButton"
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 9bff469b670..f2abade8036 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -8,8 +8,8 @@ import {
GlTooltip,
} from '@gitlab/ui';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { glEmojiTag } from '../../emoji';
-import { __, sprintf } from '../../locale';
+import { glEmojiTag } from '~/emoji';
+import { __, sprintf } from '~/locale';
import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
@@ -117,7 +117,7 @@ export default {
<template>
<header
- class="page-content-header gl-display-flex gl-min-h-7"
+ class="page-content-header gl-md-display-flex gl-min-h-7"
data-qa-selector="pipeline_header"
data-testid="ci-header-content"
>
@@ -163,11 +163,7 @@ export default {
</template>
</section>
- <section
- v-if="$slots.default"
- data-testid="ci-header-action-buttons"
- class="gl-display-flex gl-mr-3"
- >
+ <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex">
<slot></slot>
</section>
<gl-button
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index f3b871c91b6..c3f184446a8 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -21,12 +21,17 @@ export default {
default: () => ({}),
},
},
+ methods: {
+ targetFn() {
+ return this.$refs.popoverTrigger?.$el;
+ },
+ },
};
</script>
<template>
<span>
- <gl-button ref="popoverTrigger" variant="link" icon="question" :aria-label="__('Help')" />
- <gl-popover :target="() => $refs.popoverTrigger.$el" v-bind="options">
+ <gl-button ref="popoverTrigger" variant="link" icon="question-o" :aria-label="__('Help')" />
+ <gl-popover :target="targetFn" v-bind="options">
<template v-if="options.title" #title>
<span v-safe-html="options.title"></span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue
deleted file mode 100644
index 87a995464fa..00000000000
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<script>
-import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
-
-export default {
- props: {
- entityId: {
- type: [Number, String],
- required: true,
- },
- entityName: {
- type: String,
- required: true,
- },
- sizeClass: {
- type: String,
- required: false,
- default: 's40',
- },
- },
- computed: {
- identiconBackgroundClass() {
- return getIdenticonBackgroundClass(this.entityId);
- },
- identiconTitle() {
- return getIdenticonTitle(this.entityName);
- },
- },
-};
-</script>
-
-<template>
- <div ref="identicon" :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon">
- {{ identiconTitle }}
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue
deleted file mode 100644
index 11caf3be00a..00000000000
--- a/app/assets/javascripts/vue_shared/components/line_numbers.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<script>
-import { GlIcon, GlLink } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- GlLink,
- },
- props: {
- lines: {
- type: Number,
- required: true,
- },
- },
-};
-</script>
-<template>
- <div class="line-numbers">
- <gl-link
- v-for="line in lines"
- :id="`L${line}`"
- :key="line"
- class="diff-line-num gl-shadow-none!"
- :to="`#LC${line}`"
- :data-line-number="line"
- >
- <gl-icon :size="12" name="link" />
- {{ line }}
- </gl-link>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
index 33e77b6510c..4ece87310c7 100644
--- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue
@@ -1,6 +1,18 @@
<script>
-import { isEqual } from 'lodash';
+import { isEqual, isString } from 'lodash';
+/**
+ * This component will save and restore a value to and from localStorage.
+ * The value will be saved only when the value changes; the initial value won't be saved.
+ *
+ * By default, the value will be saved using JSON.stringify(), and retrieved back using JSON.parse().
+ *
+ * If you would like to save the raw string instead, you may set the 'asString' prop to true, though be aware that this is a
+ * legacy prop to maintain backwards compatibility.
+ *
+ * For new components saving data for the first time, it's recommended to not use 'asString' even if you're saving a string; it will still be
+ * saved and restored properly using JSON.stringify()/JSON.parse().
+ */
export default {
props: {
storageKey: {
@@ -12,7 +24,7 @@ export default {
required: false,
default: '',
},
- asJson: {
+ asString: {
type: Boolean,
required: false,
default: false,
@@ -30,6 +42,8 @@ export default {
},
watch: {
value(newVal) {
+ if (!this.persist) return;
+
this.saveValue(this.serialize(newVal));
},
clear(newVal) {
@@ -67,15 +81,22 @@ export default {
}
},
saveValue(val) {
- if (!this.persist) return;
-
localStorage.setItem(this.storageKey, val);
},
serialize(val) {
- return this.asJson ? JSON.stringify(val) : val;
+ if (!isString(val) && this.asString) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[gitlab] LocalStorageSync is saving`,
+ val,
+ `to the key "${this.storageKey}", but it is not a string and the 'asString' prop is true. This will save and restore the stringified value rather than the original value. If this is not intended, please remove or set the 'asString' prop to false.`,
+ );
+ }
+
+ return this.asString ? val : JSON.stringify(val);
},
deserialize(val) {
- return this.asJson ? JSON.parse(val) : val;
+ return this.asString ? val : JSON.parse(val);
},
},
render() {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
index 709d3592828..926034efd10 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -1,9 +1,9 @@
<script>
-import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui';
+import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui';
import { __, n__ } from '~/locale';
export default {
- components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton },
+ components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert },
props: {
disabled: {
type: Boolean,
@@ -19,6 +19,11 @@ export default {
required: false,
default: 0,
},
+ errorMessage: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -55,6 +60,9 @@ export default {
>
<gl-dropdown-form class="gl-px-4! gl-m-0!">
<label for="commit-message">{{ __('Commit message') }}</label>
+ <gl-alert v-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-4">
+ {{ errorMessage }}
+ </gl-alert>
<gl-form-textarea
id="commit-message"
ref="commitMessage"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index e1020ce656b..722df3cc58b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { debounce, unescape } from 'lodash';
@@ -24,6 +24,9 @@ export default {
GlIcon,
Suggestions,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
mixins: [glFeatureFlagsMixin()],
props: {
/**
@@ -308,6 +311,9 @@ export default {
);
},
},
+ safeHtmlConfig: {
+ ADD_TAGS: ['gl-emoji'],
+ },
};
</script>
@@ -369,18 +375,19 @@ export default {
<div
v-show="previewMarkdown"
ref="markdown-preview"
+ v-safe-html:[$options.safeHtmlConfig]="markdownPreview"
class="js-vue-md-preview md md-preview-holder"
- v-html="markdownPreview /* eslint-disable-line vue/no-v-html */"
></div>
</template>
<div
v-if="referencedCommands && previewMarkdown && !markdownPreviewLoading"
+ v-safe-html:[$options.safeHtmlConfig]="referencedCommands"
class="referenced-commands"
- v-html="referencedCommands /* eslint-disable-line vue/no-v-html */"
+ data-testid="referenced-commands"
></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
<gl-icon name="warning-solid" />
- <span v-html="addMultipleToDiscussionWarning /* eslint-disable-line vue/no-v-html */"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="addMultipleToDiscussionWarning"></span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 13189670e17..d0bd5046bf0 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -10,7 +10,7 @@ import {
} from '~/behaviors/shortcuts/keybindings';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
-import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
+import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
export default {
@@ -187,7 +187,7 @@ export default {
<template #tabs-end>
<div
data-testid="md-header-toolbar"
- :class="{ 'gl-display-none': previewMarkdown }"
+ :class="{ 'gl-display-none!': previewMarkdown }"
class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center"
>
<toolbar-button
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index 7d8d8c0b90e..4d10c3f0a51 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -36,6 +36,11 @@ export default {
required: false,
default: 0,
},
+ failedToLoadMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
batchSuggestionsCount() {
@@ -80,6 +85,7 @@ export default {
:help-page-path="helpPagePath"
:default-commit-message="defaultCommitMessage"
:inapplicable-reason="suggestion.inapplicable_reason"
+ :failed-to-load-metadata="failedToLoadMetadata"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 648e9c9462f..8a1b8363f19 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -4,6 +4,10 @@ import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import ApplySuggestion from './apply_suggestion.vue';
+const APPLY_SUGGESTION_ERROR_MESSAGE = __(
+ 'Unable to fully load the default commit message. You can still apply this suggestion and the commit message will be correct.',
+);
+
export default {
components: { GlBadge, GlIcon, GlButton, GlLoadingIcon, ApplySuggestion },
directives: { 'gl-tooltip': GlTooltipDirective },
@@ -52,6 +56,11 @@ export default {
required: false,
default: 0,
},
+ failedToLoadMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -94,6 +103,9 @@ export default {
return true;
},
+ applySuggestionErrorMessage() {
+ return this.failedToLoadMetadata ? APPLY_SUGGESTION_ERROR_MESSAGE : null;
+ },
},
methods: {
apply(message) {
@@ -171,6 +183,7 @@ export default {
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
:batch-suggestions-count="batchSuggestionsCount"
+ :error-message="applySuggestionErrorMessage"
class="gl-ml-3"
@apply="apply"
/>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 2f6776f835e..de3eda6b04f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -47,6 +47,11 @@ export default {
required: false,
default: 0,
},
+ failedToLoadMetadata: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -60,6 +65,9 @@ export default {
noteHtml() {
this.reset();
},
+ failedToLoadMetadata() {
+ this.reset();
+ },
},
mounted() {
this.renderSuggestions();
@@ -105,6 +113,7 @@ export default {
helpPagePath,
defaultCommitMessage,
suggestionsCount,
+ failedToLoadMetadata,
} = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
@@ -117,6 +126,7 @@ export default {
helpPagePath,
defaultCommitMessage,
suggestionsCount,
+ failedToLoadMetadata,
},
});
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue
new file mode 100644
index 00000000000..3e796a73f72
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal, GlTab } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { __, s__ } from '~/locale';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+import MetricImagesTable from '~/vue_shared/components/metric_images/metric_images_table.vue';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlLoadingIcon,
+ GlModal,
+ GlTab,
+ MetricImagesTable,
+ UploadDropzone,
+ },
+ inject: ['canUpdate', 'projectId', 'iid'],
+ data() {
+ return {
+ currentFiles: [],
+ modalVisible: false,
+ modalUrl: '',
+ modalUrlText: '',
+ };
+ },
+ computed: {
+ ...mapState(['metricImages', 'isLoadingMetricImages', 'isUploadingImage']),
+ actionPrimaryProps() {
+ return {
+ text: this.$options.i18n.modalUpload,
+ attributes: {
+ loading: this.isUploadingImage,
+ disabled: this.isUploadingImage,
+ category: 'primary',
+ variant: 'confirm',
+ },
+ };
+ },
+ },
+ mounted() {
+ this.setInitialData({ modelIid: this.iid, projectId: this.projectId });
+ this.fetchImages();
+ },
+ methods: {
+ ...mapActions(['fetchImages', 'uploadImage', 'setInitialData']),
+ clearInputs() {
+ this.modalVisible = false;
+ this.modalUrl = '';
+ this.modalUrlText = '';
+ this.currentFile = false;
+ },
+ openMetricDialog(files) {
+ this.modalVisible = true;
+ this.currentFiles = files;
+ },
+ async onUpload() {
+ try {
+ await this.uploadImage({
+ files: this.currentFiles,
+ url: this.modalUrl,
+ urlText: this.modalUrlText,
+ });
+ // Error case handled within action
+ } finally {
+ this.clearInputs();
+ }
+ },
+ },
+ i18n: {
+ modalUpload: __('Upload'),
+ modalCancel: __('Cancel'),
+ modalTitle: s__('Incidents|Add image details'),
+ modalDescription: s__(
+ "Incidents|Add text or a link to display with your image. If you don't add either, the file name displays instead.",
+ ),
+ dropDescription: s__(
+ 'Incidents|Drop or %{linkStart}upload%{linkEnd} a metric screenshot to attach it to the incident',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-tab :title="s__('Incident|Metrics')" data-testid="metrics-tab">
+ <div v-if="isLoadingMetricImages">
+ <gl-loading-icon class="gl-p-5" size="sm" />
+ </div>
+ <gl-modal
+ modal-id="upload-metric-modal"
+ size="sm"
+ :action-primary="actionPrimaryProps"
+ :action-cancel="{ text: $options.i18n.modalCancel }"
+ :title="$options.i18n.modalTitle"
+ :visible="modalVisible"
+ @hidden="clearInputs"
+ @primary.prevent="onUpload"
+ >
+ <p>{{ $options.i18n.modalDescription }}</p>
+ <gl-form-group :label="__('Text (optional)')" label-for="upload-text-input">
+ <gl-form-input id="upload-text-input" v-model="modalUrlText" />
+ </gl-form-group>
+
+ <gl-form-group
+ :label="__('Link (optional)')"
+ label-for="upload-url-input"
+ :description="s__('Incidents|Must start with http or https')"
+ >
+ <gl-form-input id="upload-url-input" v-model="modalUrl" />
+ </gl-form-group>
+ </gl-modal>
+ <metric-images-table v-for="metric in metricImages" :key="metric.id" v-bind="metric" />
+ <upload-dropzone
+ v-if="canUpdate"
+ :drop-description-message="$options.i18n.dropDescription"
+ @change="openMetricDialog"
+ />
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue
new file mode 100644
index 00000000000..8eb8e52728d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_table.vue
@@ -0,0 +1,266 @@
+<script>
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlCard,
+ GlIcon,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import { __, s__ } from '~/locale';
+
+export default {
+ i18n: {
+ modalDelete: __('Delete'),
+ modalDescription: s__('Incident|Are you sure you wish to delete this image?'),
+ modalCancel: __('Cancel'),
+ modalTitle: s__('Incident|Deleting %{filename}'),
+ editModalUpdate: __('Update'),
+ editModalTitle: s__('Incident|Editing %{filename}'),
+ editIconTitle: s__('Incident|Edit image text or link'),
+ deleteIconTitle: s__('Incident|Delete image'),
+ },
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlCard,
+ GlIcon,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['canUpdate'],
+ props: {
+ id: {
+ type: Number,
+ required: true,
+ },
+ filePath: {
+ type: String,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ urlText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ isCollapsed: false,
+ isDeleting: false,
+ isUpdating: false,
+ modalVisible: false,
+ editModalVisible: false,
+ modalUrl: this.url,
+ modalUrlText: this.urlText,
+ };
+ },
+ computed: {
+ deleteActionPrimaryProps() {
+ return {
+ text: this.$options.i18n.modalDelete,
+ attributes: {
+ loading: this.isDeleting,
+ disabled: this.isDeleting,
+ category: 'primary',
+ variant: 'danger',
+ },
+ };
+ },
+ updateActionPrimaryProps() {
+ return {
+ text: this.$options.i18n.editModalUpdate,
+ attributes: {
+ loading: this.isUpdating,
+ disabled: this.isUpdating,
+ category: 'primary',
+ variant: 'confirm',
+ },
+ };
+ },
+ arrowIconName() {
+ return this.isCollapsed ? 'chevron-right' : 'chevron-down';
+ },
+ bodyClass() {
+ return [
+ 'gl-border-1',
+ 'gl-border-t-solid',
+ 'gl-border-gray-100',
+ { 'gl-display-none': this.isCollapsed },
+ ];
+ },
+ },
+ methods: {
+ ...mapActions(['deleteImage', 'updateImage']),
+ toggleCollapsed() {
+ this.isCollapsed = !this.isCollapsed;
+ },
+ resetEditFields() {
+ this.modalUrl = this.url;
+ this.modalUrlText = this.urlText;
+ this.editModalVisible = false;
+ this.modalVisible = false;
+ },
+ async onDelete() {
+ try {
+ this.isDeleting = true;
+ await this.deleteImage(this.id);
+ } finally {
+ this.isDeleting = false;
+ this.modalVisible = false;
+ }
+ },
+ async onUpdate() {
+ try {
+ this.isUpdating = true;
+ await this.updateImage({
+ imageId: this.id,
+ url: this.modalUrl,
+ urlText: this.modalUrlText,
+ });
+ } finally {
+ this.isUpdating = false;
+ this.modalUrl = '';
+ this.modalUrlText = '';
+ this.editModalVisible = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-card
+ class="collapsible-card border gl-p-0 gl-mb-5"
+ header-class="gl-display-flex gl-align-items-center gl-border-b-0 gl-py-3"
+ :body-class="bodyClass"
+ >
+ <gl-modal
+ body-class="gl-pb-0! gl-min-h-6!"
+ modal-id="delete-metric-modal"
+ size="sm"
+ :visible="modalVisible"
+ :action-primary="deleteActionPrimaryProps"
+ :action-cancel="{ text: $options.i18n.modalCancel }"
+ @primary.prevent="onDelete"
+ @hidden="resetEditFields"
+ >
+ <template #modal-title>
+ <gl-sprintf :message="$options.i18n.modalTitle">
+ <template #filename>
+ {{ filename }}
+ </template>
+ </gl-sprintf>
+ </template>
+ <p>{{ $options.i18n.modalDescription }}</p>
+ </gl-modal>
+
+ <gl-modal
+ modal-id="edit-metric-modal"
+ size="sm"
+ :action-primary="updateActionPrimaryProps"
+ :action-cancel="{ text: $options.i18n.modalCancel }"
+ :visible="editModalVisible"
+ data-testid="metric-image-edit-modal"
+ @hidden="resetEditFields"
+ @primary.prevent="onUpdate"
+ >
+ <template #modal-title>
+ <gl-sprintf :message="$options.i18n.editModalTitle">
+ <template #filename>
+ {{ filename }}
+ </template>
+ </gl-sprintf>
+ </template>
+
+ <gl-form-group :label="__('Text (optional)')" label-for="upload-text-input">
+ <gl-form-input
+ id="upload-text-input"
+ v-model="modalUrlText"
+ data-testid="metric-image-text-field"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ :label="__('Link (optional)')"
+ label-for="upload-url-input"
+ :description="s__('Incidents|Must start with http or https')"
+ >
+ <gl-form-input
+ id="upload-url-input"
+ v-model="modalUrl"
+ data-testid="metric-image-url-field"
+ />
+ </gl-form-group>
+ </gl-modal>
+
+ <template #header>
+ <div class="gl-w-full gl-display-flex gl-flex-direction-row gl-justify-content-space-between">
+ <div class="gl-display-flex gl-flex-direction-row gl-align-items-center gl-w-full">
+ <gl-button
+ class="collapsible-card-btn gl-display-flex gl-text-decoration-none gl-reset-color! gl-hover-text-blue-800! gl-shadow-none!"
+ :aria-label="filename"
+ variant="link"
+ category="tertiary"
+ data-testid="collapse-button"
+ @click="toggleCollapsed"
+ >
+ <gl-icon class="gl-mr-2" :name="arrowIconName" />
+ </gl-button>
+ <gl-link v-if="url" :href="url" target="_blank" data-testid="metric-image-label-span">
+ {{ urlText == null || urlText == '' ? filename : urlText }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ <span v-else data-testid="metric-image-label-span">{{
+ urlText == null || urlText == '' ? filename : urlText
+ }}</span>
+ <div class="gl-ml-auto btn-group">
+ <gl-button
+ v-if="canUpdate"
+ v-gl-tooltip.bottom
+ icon="pencil"
+ :aria-label="__('Edit')"
+ :title="$options.i18n.editIconTitle"
+ data-testid="edit-button"
+ @click="editModalVisible = true"
+ />
+ <gl-button
+ v-if="canUpdate"
+ v-gl-tooltip.bottom
+ icon="remove"
+ :aria-label="__('Delete')"
+ :title="$options.i18n.deleteIconTitle"
+ data-testid="delete-button"
+ @click="modalVisible = true"
+ />
+ </div>
+ </div>
+ </div>
+ </template>
+ <div
+ v-show="!isCollapsed"
+ class="gl-display-flex gl-flex-direction-column"
+ data-testid="metric-image-body"
+ >
+ <img class="gl-max-w-full gl-align-self-center" :src="filePath" />
+ </div>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
new file mode 100644
index 00000000000..832fb891838
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/actions.js
@@ -0,0 +1,85 @@
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import * as types from './mutation_types';
+
+export const fetchImagesFactory = (service) => async ({ state, commit }) => {
+ commit(types.REQUEST_METRIC_IMAGES);
+ const { modelIid, projectId } = state;
+
+ try {
+ const response = await service.getMetricImages({ id: projectId, modelIid });
+ commit(types.RECEIVE_METRIC_IMAGES_SUCCESS, response);
+ } catch (error) {
+ commit(types.RECEIVE_METRIC_IMAGES_ERROR);
+ createFlash({ message: s__('MetricImages|There was an issue loading metric images.') });
+ }
+};
+
+export const uploadImageFactory = (service) => async (
+ { state, commit },
+ { files, url, urlText },
+) => {
+ commit(types.REQUEST_METRIC_UPLOAD);
+
+ const { modelIid, projectId } = state;
+
+ try {
+ const response = await service.uploadMetricImage({
+ file: files.item(0),
+ id: projectId,
+ modelIid,
+ url,
+ urlText,
+ });
+ commit(types.RECEIVE_METRIC_UPLOAD_SUCCESS, response);
+ } catch (error) {
+ commit(types.RECEIVE_METRIC_UPLOAD_ERROR);
+ createFlash({ message: s__('MetricImages|There was an issue uploading your image.') });
+ }
+};
+
+export const updateImageFactory = (service) => async (
+ { state, commit },
+ { imageId, url, urlText },
+) => {
+ commit(types.REQUEST_METRIC_UPLOAD);
+
+ const { modelIid, projectId } = state;
+
+ try {
+ const response = await service.updateMetricImage({
+ modelIid,
+ id: projectId,
+ imageId,
+ url,
+ urlText,
+ });
+ commit(types.RECEIVE_METRIC_UPDATE_SUCCESS, response);
+ } catch (error) {
+ commit(types.RECEIVE_METRIC_UPLOAD_ERROR);
+ createFlash({ message: s__('MetricImages|There was an issue updating your image.') });
+ }
+};
+
+export const deleteImageFactory = (service) => async ({ state, commit }, imageId) => {
+ const { modelIid, projectId } = state;
+
+ try {
+ await service.deleteMetricImage({ imageId, id: projectId, modelIid });
+ commit(types.RECEIVE_METRIC_DELETE_SUCCESS, imageId);
+ } catch (error) {
+ createFlash({ message: s__('MetricImages|There was an issue deleting the image.') });
+ }
+};
+
+export const setInitialData = ({ commit }, data) => {
+ commit(types.SET_INITIAL_DATA, data);
+};
+
+export default (service) => ({
+ fetchImages: fetchImagesFactory(service),
+ uploadImage: uploadImageFactory(service),
+ updateImage: updateImageFactory(service),
+ deleteImage: deleteImageFactory(service),
+ setInitialData,
+});
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/index.js b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js
new file mode 100644
index 00000000000..f13dde9a2bc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import actionsFactory from './actions';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export default (initialState, service) =>
+ new Vuex.Store({
+ actions: actionsFactory(service),
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js
new file mode 100644
index 00000000000..8f1b31217a2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/mutation_types.js
@@ -0,0 +1,13 @@
+export const REQUEST_METRIC_IMAGES = 'REQUEST_METRIC_IMAGES';
+export const RECEIVE_METRIC_IMAGES_SUCCESS = 'RECEIVE_METRIC_IMAGES_SUCCESS';
+export const RECEIVE_METRIC_IMAGES_ERROR = 'RECEIVE_METRIC_IMAGES_ERROR';
+
+export const REQUEST_METRIC_UPLOAD = 'REQUEST_METRIC_UPLOAD';
+export const RECEIVE_METRIC_UPLOAD_SUCCESS = 'RECEIVE_METRIC_UPLOAD_SUCCESS';
+export const RECEIVE_METRIC_UPLOAD_ERROR = 'RECEIVE_METRIC_UPLOAD_ERROR';
+
+export const RECEIVE_METRIC_UPDATE_SUCCESS = 'RECEIVE_METRIC_UPDATE_SUCCESS';
+
+export const RECEIVE_METRIC_DELETE_SUCCESS = 'RECEIVE_METRIC_DELETE_SUCCESS';
+
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js b/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js
new file mode 100644
index 00000000000..b42234b2829
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/mutations.js
@@ -0,0 +1,39 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_METRIC_IMAGES](state) {
+ state.isLoadingMetricImages = true;
+ },
+ [types.RECEIVE_METRIC_IMAGES_SUCCESS](state, images) {
+ state.metricImages = images || [];
+ state.isLoadingMetricImages = false;
+ },
+ [types.RECEIVE_METRIC_IMAGES_ERROR](state) {
+ state.isLoadingMetricImages = false;
+ },
+ [types.REQUEST_METRIC_UPLOAD](state) {
+ state.isUploadingImage = true;
+ },
+ [types.RECEIVE_METRIC_UPLOAD_SUCCESS](state, image) {
+ state.metricImages.push(image);
+ state.isUploadingImage = false;
+ },
+ [types.RECEIVE_METRIC_UPLOAD_ERROR](state) {
+ state.isUploadingImage = false;
+ },
+ [types.RECEIVE_METRIC_UPDATE_SUCCESS](state, image) {
+ state.isUploadingImage = false;
+ const metricIndex = state.metricImages.findIndex((img) => img.id === image.id);
+ if (metricIndex >= 0) {
+ state.metricImages.splice(metricIndex, 1, image);
+ }
+ },
+ [types.RECEIVE_METRIC_DELETE_SUCCESS](state, imageId) {
+ const metricIndex = state.metricImages.findIndex((image) => image.id === imageId);
+ state.metricImages.splice(metricIndex, 1);
+ },
+ [types.SET_INITIAL_DATA](state, { modelIid, projectId }) {
+ state.modelIid = modelIid;
+ state.projectId = projectId;
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/metric_images/store/state.js b/app/assets/javascripts/vue_shared/components/metric_images/store/state.js
new file mode 100644
index 00000000000..b734e5c87a6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/metric_images/store/state.js
@@ -0,0 +1,10 @@
+export default ({ modelIid, projectId } = {}) => ({
+ // Initial state
+ modelIid,
+ projectId,
+
+ // View state
+ metricImages: [],
+ isLoadingMetricImages: false,
+ isUploadingImage: false,
+});
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 1963d1aa7fe..dd7a851b1be 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -31,7 +31,7 @@ import { __ } from '~/locale';
import initMRPopovers from '~/mr_popover/';
import noteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { spriteIcon } from '../../../lib/utils/common_utils';
+import { spriteIcon } from '~/lib/utils/common_utils';
import TimelineEntryItem from './timeline_entry_item.vue';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue
index f16187022a5..402e75962d2 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatar } from '@gitlab/ui';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
components: {
@@ -31,12 +32,13 @@ export default {
return this.alt ?? this.projectName;
},
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
<gl-avatar
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
:entity-name="projectName"
:src="projectAvatarUrl"
:alt="avatarAlt"
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 0bd57c84018..19ffbe37ce7 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -3,7 +3,7 @@ import { GlButton, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { isString } from 'lodash';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
-import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
export default {
name: 'ProjectListItem',
@@ -22,6 +22,9 @@ export default {
matcher: { type: String, required: false, default: '' },
},
computed: {
+ projectAvatarUrl() {
+ return this.project.avatar_url || this.project.avatarUrl;
+ },
projectNameWithNamespace() {
return this.project.nameWithNamespace || this.project.name_with_namespace;
},
@@ -49,7 +52,11 @@ export default {
class="gl-display-flex gl-align-items-center gl-flex-wrap project-namespace-name-container"
>
<gl-icon v-if="selected" class="js-selected-icon" name="mobile-issue-close" />
- <project-avatar class="gl-flex-shrink-0 js-project-avatar" :project="project" :size="32" />
+ <project-avatar
+ :project-avatar-url="projectAvatarUrl"
+ :project-name="projectNameWithNamespace"
+ class="gl-mr-3"
+ />
<div
v-if="truncatedNamespace"
:title="projectNameWithNamespace"
diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
index 36b1a9c49f4..43a8e241d77 100644
--- a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
@@ -43,7 +43,7 @@ export default {
</script>
<template>
- <local-storage-sync :storage-key="storageKey" :value="selected" @input="setSelected">
+ <local-storage-sync :storage-key="storageKey" :value="selected" as-string @input="setSelected">
<gl-dropdown :text="dropdownText" lazy>
<gl-dropdown-item
v-for="option in parsedOptions"
diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
index d108d8d689d..fc0976b0792 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -1,6 +1,7 @@
<script>
import { GlAvatar, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui';
import { isEqual } from 'lodash';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
name: 'TitleArea',
@@ -53,6 +54,7 @@ export default {
}
},
},
+ AVATAR_SHAPE_OPTION_RECT,
};
</script>
@@ -64,7 +66,7 @@ export default {
<gl-avatar
v-if="avatar"
:src="avatar"
- shape="rect"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
class="gl-align-self-center gl-mr-4"
/>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index f53b75df4eb..522fbc07f5e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -1,8 +1,11 @@
<script>
import { debounce } from 'lodash';
+import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import createFlash from '~/flash';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issues/constants';
+
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { issuableLabelsQueries } from '~/sidebar/constants';
@@ -21,6 +24,7 @@ export default {
DropdownContents,
SidebarEditableItem,
},
+ mixins: [glFeatureFlagsMixin()],
inject: {
allowLabelEdit: {
default: false,
@@ -106,7 +110,7 @@ export default {
data() {
return {
contentIsOnViewport: true,
- issuableLabels: [],
+ issuable: null,
labelsSelectInProgress: false,
oldIid: null,
sidebarExpandedOnClick: false,
@@ -114,14 +118,23 @@ export default {
},
computed: {
isLoading() {
- return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading;
+ return this.labelsSelectInProgress || this.$apollo.queries.issuable.loading;
},
issuableLabelIds() {
return this.issuableLabels.map((label) => label.id);
},
+ issuableLabels() {
+ return this.issuable?.labels.nodes || [];
+ },
+ issuableId() {
+ return this.issuable?.id;
+ },
+ isRealtimeEnabled() {
+ return this.glFeatures.realtimeLabels;
+ },
},
apollo: {
- issuableLabels: {
+ issuable: {
query() {
return issuableLabelsQueries[this.issuableType].issuableQuery;
},
@@ -135,11 +148,40 @@ export default {
};
},
update(data) {
- return data.workspace?.issuable?.labels.nodes || [];
+ return data.workspace?.issuable;
},
error() {
createFlash({ message: __('Error fetching labels.') });
},
+ subscribeToMore: {
+ document() {
+ return issuableLabelsSubscription;
+ },
+ variables() {
+ return {
+ issuableId: this.issuableId,
+ };
+ },
+ skip() {
+ return !this.issuableId || !this.isDropdownVariantSidebar || !this.isRealtimeEnabled;
+ },
+ updateQuery(
+ _,
+ {
+ subscriptionData: {
+ data: { issuableLabelsUpdated },
+ },
+ },
+ ) {
+ if (issuableLabelsUpdated) {
+ const {
+ id,
+ labels: { nodes },
+ } = issuableLabelsUpdated;
+ this.$emit('updateSelectedLabels', { id, labels: nodes });
+ }
+ },
+ },
},
},
watch: {
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
new file mode 100644
index 00000000000..6babbca58c3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlIntersectionObserver, GlSafeHtmlDirective } from '@gitlab/ui';
+import ChunkLine from './chunk_line.vue';
+
+/*
+ * We only highlight the chunk that is currently visible to the user.
+ * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
+ *
+ * Content that is not visible to the user (i.e. not highlighted) do not need to look nice,
+ * so by making text transparent and rendering raw (non-highlighted) text,
+ * the browser spends less resources on painting content that is not immediately relevant.
+ *
+ * Why use transparent text as opposed to hiding content entirely?
+ * 1. If content is hidden entirely, native find text (⌘ + F) won't work.
+ * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line.
+ */
+export default {
+ components: {
+ ChunkLine,
+ GlIntersectionObserver,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ chunkIndex: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ isHighlighted: {
+ type: Boolean,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ startingFrom: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ totalLines: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ language: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ lines() {
+ return this.content.split('\n');
+ },
+ },
+ methods: {
+ handleChunkAppear() {
+ if (!this.isHighlighted) {
+ this.$emit('appear', this.chunkIndex);
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-intersection-observer @appear="handleChunkAppear">
+ <div v-if="isHighlighted">
+ <chunk-line
+ v-for="(line, index) in lines"
+ :key="index"
+ :number="startingFrom + index + 1"
+ :content="line"
+ :language="language"
+ />
+ </div>
+ <div v-else class="gl-display-flex">
+ <div class="gl-display-flex gl-flex-direction-column">
+ <a
+ v-for="(n, index) in totalLines"
+ :id="`L${startingFrom + index + 1}`"
+ :key="index"
+ class="gl-ml-5 gl-text-transparent"
+ :href="`#L${startingFrom + index + 1}`"
+ :data-line-number="startingFrom + index + 1"
+ data-testid="line-number"
+ >
+ {{ startingFrom + index + 1 }}
+ </a>
+ </div>
+ <div
+ class="gl-white-space-pre-wrap! gl-text-transparent"
+ data-testid="content"
+ v-text="content"
+ ></div>
+ </div>
+ </gl-intersection-observer>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
new file mode 100644
index 00000000000..1b8e4bcfec6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+import { setAttributes } from '~/lib/utils/dom_utils';
+import { BIDI_CHARS, BIDI_CHARS_CLASS_LIST, BIDI_CHAR_TOOLTIP } from '../constants';
+
+export default {
+ components: {
+ GlLink,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ number: {
+ type: Number,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ language: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ formattedContent() {
+ let { content } = this;
+
+ BIDI_CHARS.forEach((bidiChar) => {
+ if (content.includes(bidiChar)) {
+ content = content.replace(bidiChar, this.wrapBidiChar(bidiChar));
+ }
+ });
+
+ return content;
+ },
+ },
+ methods: {
+ wrapBidiChar(bidiChar) {
+ const span = document.createElement('span');
+
+ setAttributes(span, {
+ class: BIDI_CHARS_CLASS_LIST,
+ title: BIDI_CHAR_TOOLTIP,
+ 'data-testid': 'bidi-wrapper',
+ });
+
+ span.innerText = bidiChar;
+
+ return span.outerHTML;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex">
+ <div class="line-numbers gl-pt-0! gl-pb-0! gl-absolute gl-z-index-3">
+ <gl-link
+ :id="`L${number}`"
+ class="file-line-num diff-line-num gl-user-select-none"
+ :to="`#L${number}`"
+ :data-line-number="number"
+ >
+ {{ number }}
+ </gl-link>
+ </div>
+
+ <pre
+ class="code highlight gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11!"
+ ><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 9efe0147c37..bed6dd4d5c6 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
// Language map from Rouge::Lexer to highlight.js
// Rouge::Lexer - We use it on the BE to determine the language of a source file (https://github.com/rouge-ruby/rouge/blob/master/docs/Languages.md).
// Highlight.js - We use it on the FE to highlight the syntax of a source file (https://github.com/highlightjs/highlight.js/tree/main/src/languages).
@@ -109,3 +111,26 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
xquery: 'xquery',
yaml: 'yaml',
};
+
+export const LINES_PER_CHUNK = 70;
+
+export const BIDI_CHARS = [
+ '\u202A', // Left-to-Right Embedding (Try treating following text as left-to-right)
+ '\u202B', // Right-to-Left Embedding (Try treating following text as right-to-left)
+ '\u202D', // Left-to-Right Override (Force treating following text as left-to-right)
+ '\u202E', // Right-to-Left Override (Force treating following text as right-to-left)
+ '\u2066', // Left-to-Right Isolate (Force treating following text as left-to-right without affecting adjacent text)
+ '\u2067', // Right-to-Left Isolate (Force treating following text as right-to-left without affecting adjacent text)
+ '\u2068', // First Strong Isolate (Force treating following text in direction indicated by the next character)
+ '\u202C', // Pop Directional Formatting (Terminate nearest LRE, RLE, LRO, or RLO)
+ '\u2069', // Pop Directional Isolate (Terminate nearest LRI or RLI)
+ '\u061C', // Arabic Letter Mark (Right-to-left zero-width Arabic character)
+ '\u200F', // Right-to-Left Mark (Right-to-left zero-width character non-Arabic character)
+ '\u200E', // Left-to-Right Mark (Left-to-right zero-width character)
+];
+
+export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip';
+
+export const BIDI_CHAR_TOOLTIP = __(
+ 'Potentially unwanted character detected: Unicode BiDi Control',
+);
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 4a78cbacec0..edf2229a9a1 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -1,16 +1,22 @@
<script>
import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
-import LineNumbers from '~/vue_shared/components/line_numbers.vue';
-import { sanitize } from '~/lib/dompurify';
-import { ROUGE_TO_HLJS_LANGUAGE_MAP } from './constants';
-import { wrapLines } from './utils';
-
-const LINE_SELECT_CLASS_NAME = 'hll';
+import LineHighlighter from '~/blob/line_highlighter';
+import eventHub from '~/notes/event_hub';
+import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants';
+import Chunk from './components/chunk.vue';
+/*
+ * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
+ * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible.
+ *
+ * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback).
+ * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes,
+ * it does not trigger a repaint on a parent element that wraps all 1000 lines.
+ */
export default {
components: {
- LineNumbers,
GlLoadingIcon,
+ Chunk,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -27,46 +33,94 @@ export default {
content: this.blob.rawTextBlob,
language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language],
hljs: null,
+ firstChunk: null,
+ chunks: {},
+ isLoading: true,
+ isLineSelected: false,
+ lineHighlighter: null,
};
},
computed: {
+ splitContent() {
+ return this.content.split('\n');
+ },
lineNumbers() {
- return this.content.split('\n').length;
+ return this.splitContent.length;
},
- highlightedContent() {
- let highlightedContent;
- let { language } = this;
+ },
+ async created() {
+ this.generateFirstChunk();
+ this.hljs = await this.loadHighlightJS();
- if (this.hljs) {
- if (!language) {
- const hljsHighlightAuto = this.hljs.highlightAuto(this.content);
+ if (this.language) {
+ this.languageDefinition = await this.loadLanguage();
+ }
- highlightedContent = hljsHighlightAuto.value;
- language = hljsHighlightAuto.language;
- } else if (this.languageDefinition) {
- highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
- }
+ // Highlight the first chunk as soon as highlight.js is available
+ this.highlightChunk(null, true);
+
+ window.requestIdleCallback(async () => {
+ // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first
+ this.generateRemainingChunks();
+ this.isLoading = false;
+ await this.$nextTick();
+ this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
+ });
+ },
+ methods: {
+ generateFirstChunk() {
+ const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
+ this.firstChunk = this.createChunk(lines);
+ },
+ generateRemainingChunks() {
+ const result = {};
+ for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) {
+ const chunkIndex = Math.floor(i / LINES_PER_CHUNK);
+ const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK);
+ result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK);
}
- return wrapLines(highlightedContent, language);
+ this.chunks = result;
},
- },
- watch: {
- highlightedContent() {
- this.$nextTick(() => this.selectLine());
+ createChunk(lines, startingFrom = 0) {
+ return {
+ content: lines.join('\n'),
+ startingFrom,
+ totalLines: lines.length,
+ language: this.language,
+ isHighlighted: false,
+ };
},
- $route() {
+ highlightChunk(index, isFirstChunk) {
+ const chunk = isFirstChunk ? this.firstChunk : this.chunks[index];
+
+ if (chunk.isHighlighted) {
+ return;
+ }
+
+ const { highlightedContent, language } = this.highlight(chunk.content, this.language);
+
+ Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
+
this.selectLine();
+
+ this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path));
},
- },
- async mounted() {
- this.hljs = await this.loadHighlightJS();
+ highlight(content, language) {
+ let detectedLanguage = language;
+ let highlightedContent;
+ if (this.hljs) {
+ if (!detectedLanguage) {
+ const hljsHighlightAuto = this.hljs.highlightAuto(content);
+ highlightedContent = hljsHighlightAuto.value;
+ detectedLanguage = hljsHighlightAuto.language;
+ } else if (this.languageDefinition) {
+ highlightedContent = this.hljs.highlight(content, { language: this.language }).value;
+ }
+ }
- if (this.language) {
- this.languageDefinition = await this.loadLanguage();
- }
- },
- methods: {
+ return { highlightedContent, language: detectedLanguage };
+ },
loadHighlightJS() {
// If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
@@ -83,21 +137,14 @@ export default {
return languageDefinition;
},
- selectLine() {
- const hash = sanitize(this.$route.hash);
- const lineToSelect = hash && this.$el.querySelector(hash);
-
- if (!lineToSelect) {
+ async selectLine() {
+ if (this.isLineSelected || !this.lineHighlighter) {
return;
}
- if (this.$options.currentlySelectedLine) {
- this.$options.currentlySelectedLine.classList.remove(LINE_SELECT_CLASS_NAME);
- }
-
- lineToSelect.classList.add(LINE_SELECT_CLASS_NAME);
- this.$options.currentlySelectedLine = lineToSelect;
- lineToSelect.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ this.isLineSelected = true;
+ await this.$nextTick();
+ this.lineHighlighter.highlightHash(this.$route.hash);
},
},
userColorScheme: window.gon.user_color_scheme,
@@ -105,16 +152,36 @@ export default {
};
</script>
<template>
- <gl-loading-icon v-if="!highlightedContent" size="sm" class="gl-my-5" />
<div
- v-else
- class="file-content code js-syntax-highlight blob-content gl-display-flex"
+ class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
:class="$options.userColorScheme"
data-type="simple"
+ :data-path="blob.path"
data-qa-selector="blob_viewer_file_content"
>
- <line-numbers :lines="lineNumbers" />
- <pre class="code highlight gl-pb-0!"><code v-safe-html="highlightedContent"></code>
- </pre>
+ <chunk
+ v-if="firstChunk"
+ :lines="firstChunk.lines"
+ :total-lines="firstChunk.totalLines"
+ :content="firstChunk.content"
+ :starting-from="firstChunk.startingFrom"
+ :is-highlighted="firstChunk.isHighlighted"
+ :language="firstChunk.language"
+ />
+
+ <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
+ <chunk
+ v-for="(chunk, key, index) in chunks"
+ v-else
+ :key="key"
+ :lines="chunk.lines"
+ :content="chunk.content"
+ :total-lines="chunk.totalLines"
+ :starting-from="chunk.startingFrom"
+ :is-highlighted="chunk.isHighlighted"
+ :chunk-index="index"
+ :language="chunk.language"
+ @appear="highlightChunk"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
deleted file mode 100644
index d726a8a55ff..00000000000
--- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
+++ /dev/null
@@ -1,28 +0,0 @@
-export const wrapLines = (content, language) => {
- const isValidLanguage = /^[a-z\d\-_]+$/.test(language); // To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_).
-
- return (
- content &&
- content
- .split('\n')
- .map((line, i) => {
- let formattedLine;
- const attributes = `id="LC${i + 1}" lang="${isValidLanguage ? language : ''}"`;
-
- if (line.includes('<span class="hljs') && !line.includes('</span>')) {
- /**
- * In some cases highlight.js will wrap multiple lines in a span, in these cases we want to append the line number to the existing span
- *
- * example (before): <span class="hljs-code">```bash
- * example (after): <span id="LC67" class="hljs-code">```bash
- */
- formattedLine = line.replace(/(?=class="hljs)/, `${attributes} `);
- } else {
- formattedLine = `<span ${attributes} class="line">${line}</span>`;
- }
-
- return formattedLine;
- })
- .join('\n')
- );
-};
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 66088b33c99..e784bba6698 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective } from '@gitlab/ui';
import timeagoMixin from '../mixins/timeago';
-import '../../lib/utils/datetime_utility';
+import '~/lib/utils/datetime_utility';
/**
* Port of ruby helper time_ago_with_tooltip
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
index f52a3471ea4..c58a5357883 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
@@ -18,7 +18,7 @@
import { GlTooltip, GlAvatar } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
-import { placeholderImage } from '../../../lazy_loader';
+import { placeholderImage } from '~/lazy_loader';
export default {
name: 'UserAvatarImageNew',
@@ -96,11 +96,12 @@ export default {
/>
<gl-tooltip
+ v-if="tooltipText || $slots.default"
:target="() => $refs.userAvatar.$el"
:placement="tooltipPlacement"
boundary="window"
>
- <slot> {{ tooltipText }}</slot>
+ <slot>{{ tooltipText }}</slot>
</gl-tooltip>
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
index bca10c76038..15ba8e3b39b 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
@@ -18,7 +18,7 @@
import { GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
-import { placeholderImage } from '../../../lazy_loader';
+import { placeholderImage } from '~/lazy_loader';
export default {
name: 'UserAvatarImageOld',
@@ -100,11 +100,12 @@ export default {
class="avatar"
/>
<gl-tooltip
+ v-if="tooltipText || $slots.default"
:target="() => $refs.userAvatarImage"
:placement="tooltipPlacement"
boundary="window"
>
- <slot> {{ tooltipText }}</slot>
+ <slot>{{ tooltipText }}</slot>
</gl-tooltip>
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
index e19d659c179..60b26d688b2 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf, __ } from '~/locale';
import UserAvatarLink from './user_avatar_link.vue';
@@ -8,6 +9,7 @@ export default {
UserAvatarLink,
GlButton,
},
+ mixins: [glFeatureFlagMixin()],
props: {
items: {
type: Array,
@@ -57,6 +59,9 @@ export default {
return sprintf(__('%{count} more'), { count });
},
+ imgCssClasses() {
+ return this.glFeatures.glAvatarForAllUserAvatars ? 'gl-mr-3' : '';
+ },
},
methods: {
expand() {
@@ -80,6 +85,7 @@ export default {
:img-alt="item.name"
:tooltip-text="item.name"
:img-size="imgSize"
+ :img-css-classes="imgCssClasses"
/>
<template v-if="hasBreakpoint">
<gl-button v-if="hasHiddenItems" variant="link" @click="expand">
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 41507ca94e2..cac8f0a9aa5 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
@@ -8,7 +8,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
-import { glEmojiTag } from '../../../emoji';
+import { glEmojiTag } from '~/emoji';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
const MAX_SKELETON_LINES = 4;
@@ -69,7 +69,7 @@ export default {
<gl-popover :target="target" :delay="200" boundary="viewport" placement="top">
<div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
<div class="gl-p-2 flex-shrink-1">
- <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" />
+ <user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-mr-3!" />
</div>
<div class="gl-p-2 gl-w-full gl-min-w-0">
<template v-if="userIsLoading">
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 199516b3eb3..15f84e48179 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -314,6 +314,7 @@ export default {
<local-storage-sync
storage-key="gl-web-ide-button-selected"
:value="selection"
+ as-string
@input="select"
/>
<gl-modal
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index 028d48e7e8a..20f178dfb7d 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -1,5 +1,10 @@
<script>
-import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlKeysetPagination,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlPagination,
+} from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
index 45452f2ea35..c5f41d81167 100644
--- a/app/assets/javascripts/vue_shared/mixins/timeago.js
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -1,4 +1,4 @@
-import { formatDate, getTimeago } from '../../lib/utils/datetime_utility';
+import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
/**
* Mixin with time ago methods used in some vue components
@@ -14,25 +14,5 @@ export default {
tooltipTitle(time) {
return formatDate(time);
},
-
- durationTimeFormatted(duration) {
- const date = new Date(duration * 1000);
-
- let hh = date.getUTCHours();
- let mm = date.getUTCMinutes();
- let ss = date.getSeconds();
-
- if (hh < 10) {
- hh = `0${hh}`;
- }
- if (mm < 10) {
- mm = `0${mm}`;
- }
- if (ss < 10) {
- ss = `0${ss}`;
- }
-
- return `${hh}:${mm}:${ss}`;
- },
},
};
diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js
index 616848639f1..bc1f8865261 100644
--- a/app/assets/javascripts/vue_shared/translate.js
+++ b/app/assets/javascripts/vue_shared/translate.js
@@ -1,4 +1,4 @@
-import { __, n__, s__, sprintf } from '../locale';
+import { __, n__, s__, sprintf } from '~/locale';
export default (Vue) => {
Vue.mixin({