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>2020-08-26 18:10:29 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-08-26 18:10:29 +0300
commitc82ca12a1c5a359325cb45aaf01b483d1fa0efcb (patch)
tree86bba20fccbaf79f3277ffcba125201492f3e92b /app/assets/javascripts
parentff579119e2ecf2608370a1f24c4d791d28f269d9 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue28
-rw-r--r--app/assets/javascripts/alert_management/components/system_notes/system_note.vue2
-rw-r--r--app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue50
-rw-r--r--app/assets/javascripts/blob/suggest_web_ide_ci/index.js20
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js6
-rw-r--r--app/assets/javascripts/import_projects/components/import_projects_table.vue16
-rw-r--r--app/assets/javascripts/import_projects/components/imported_project_table_row.vue59
-rw-r--r--app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue32
-rw-r--r--app/assets/javascripts/import_projects/components/provider_repo_table_row.vue48
-rw-r--r--app/assets/javascripts/import_projects/store/getters.js11
-rw-r--r--app/assets/javascripts/import_projects/store/mutations.js69
-rw-r--r--app/assets/javascripts/import_projects/utils.js11
-rw-r--r--app/assets/javascripts/tooltips/components/tooltips.vue70
-rw-r--r--app/assets/javascripts/tooltips/index.js58
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_button.vue2
15 files changed, 309 insertions, 173 deletions
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
index b54d39e67d4..c1ebd234088 100644
--- a/app/assets/javascripts/alert_management/components/alert_details.vue
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -43,16 +43,16 @@ export default {
tabsConfig: [
{
id: 'overview',
- title: s__('AlertManagement|Overview'),
- },
- {
- id: 'fullDetails',
title: s__('AlertManagement|Alert details'),
},
{
id: 'metrics',
title: s__('AlertManagement|Metrics'),
},
+ {
+ id: 'activity',
+ title: s__('AlertManagement|Activity feed'),
+ },
],
components: {
GlBadge,
@@ -331,18 +331,9 @@ export default {
</div>
<div class="gl-pl-2" data-testid="runbook">{{ alert.runbook }}</div>
</div>
- <template>
- <div v-if="alert.notes.nodes" class="issuable-discussion py-5">
- <ul class="notes main-notes-list timeline">
- <system-note v-for="note in alert.notes.nodes" :key="note.id" :note="note" />
- </ul>
- </div>
- </template>
- </gl-tab>
- <gl-tab :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title">
<gl-table
class="alert-management-details-table"
- :items="[{ key: 'Value', ...alert }]"
+ :items="[{ 'Full Alert Payload': 'Value', ...alert }]"
:show-empty="true"
:busy="loading"
stacked
@@ -355,9 +346,16 @@ export default {
</template>
</gl-table>
</gl-tab>
- <gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title">
+ <gl-tab :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">
+ <system-note v-for="note in alert.notes.nodes" :key="note.id" :note="note" />
+ </ul>
+ </div>
+ </gl-tab>
</gl-tabs>
<alert-sidebar
:alert="alert"
diff --git a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
index 7d0885126b8..0b206ce42f4 100644
--- a/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
+++ b/app/assets/javascripts/alert_management/components/system_notes/system_note.vue
@@ -32,7 +32,7 @@ export default {
</script>
<template>
- <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper">
+ <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-px-0!">
<div class="timeline-entry-inner">
<div class="timeline-icon" v-html="iconHtml"></div>
<div class="timeline-content">
diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue b/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue
new file mode 100644
index 00000000000..1308ca53e74
--- /dev/null
+++ b/app/assets/javascripts/blob/suggest_web_ide_ci/components/web_ide_alert.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlAlert, GlButton } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ },
+ props: {
+ dismissEndpoint: {
+ type: String,
+ required: true,
+ },
+ featureId: {
+ type: String,
+ required: true,
+ },
+ editPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showAlert: true,
+ };
+ },
+ methods: {
+ dismissAlert() {
+ this.showAlert = false;
+
+ return axios.post(this.dismissEndpoint, {
+ feature_name: this.featureId,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissAlert">
+ {{ __('The Web IDE offers advanced syntax highlighting capabilities and more.') }}
+ <div class="gl-mt-5">
+ <gl-button :href="editPath" category="primary" variant="info">{{
+ __('Open Web IDE')
+ }}</gl-button>
+ </div>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/blob/suggest_web_ide_ci/index.js b/app/assets/javascripts/blob/suggest_web_ide_ci/index.js
new file mode 100644
index 00000000000..eadf3cd6216
--- /dev/null
+++ b/app/assets/javascripts/blob/suggest_web_ide_ci/index.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import WebIdeAlert from './components/web_ide_alert.vue';
+
+export default el => {
+ const { dismissEndpoint, featureId, editPath } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(WebIdeAlert, {
+ props: {
+ dismissEndpoint,
+ featureId,
+ editPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 9b9ade28623..89a4b6ec3e3 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -7,12 +7,14 @@ import BlobFileDropzone from '../blob/blob_file_dropzone';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
+import initWebIdeAlert from '~/blob/suggest_web_ide_ci';
export default () => {
const editBlobForm = $('.js-edit-blob-form');
const uploadBlobForm = $('.js-upload-blob-form');
const deleteBlobForm = $('.js-delete-blob-form');
const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml');
+ const alertEl = document.getElementById('js-suggest-web-ide-ci');
if (editBlobForm.length) {
const urlRoot = editBlobForm.data('relativeUrlRoot');
@@ -80,4 +82,8 @@ export default () => {
});
}
}
+
+ if (alertEl) {
+ initWebIdeAlert(alertEl);
+ }
};
diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue
index 72fdaca7e24..5beb8d2edc5 100644
--- a/app/assets/javascripts/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue
@@ -4,20 +4,15 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
-import ImportedProjectTableRow from './imported_project_table_row.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue';
-import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue';
import PageQueryParamSync from './page_query_param_sync.vue';
-import { isProjectImportable } from '../utils';
const reposFetchThrottleDelay = 1000;
export default {
name: 'ImportProjectsTable',
components: {
- ImportedProjectTableRow,
ProviderRepoTableRow,
- IncompatibleRepoTableRow,
PageQueryParamSync,
GlLoadingIcon,
GlButton,
@@ -109,8 +104,6 @@ export default {
throttledFetchRepos: throttle(function fetch() {
this.fetchRepos();
}, reposFetchThrottleDelay),
-
- isProjectImportable,
},
};
</script>
@@ -165,18 +158,11 @@ export default {
</thead>
<tbody>
<template v-for="repo in repositories">
- <incompatible-repo-table-row
- v-if="repo.importSource.incompatible"
- :key="repo.importSource.id"
- :repo="repo"
- />
<provider-repo-table-row
- v-else-if="isProjectImportable(repo)"
- :key="repo.importSource.id"
+ :key="repo.importSource.providerLink"
:repo="repo"
:available-namespaces="availableNamespaces"
/>
- <imported-project-table-row v-else :key="repo.importSource.id" :project="repo" />
</template>
</tbody>
</table>
diff --git a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue
deleted file mode 100644
index 50e735b4478..00000000000
--- a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import ImportStatus from './import_status.vue';
-import { STATUSES } from '../constants';
-
-export default {
- name: 'ImportedProjectTableRow',
- components: {
- ImportStatus,
- GlIcon,
- },
- props: {
- project: {
- type: Object,
- required: true,
- },
- },
-
- computed: {
- displayFullPath() {
- return this.project.importedProject.fullPath.replace(/^\//, '');
- },
-
- isFinished() {
- return this.project.importStatus === STATUSES.FINISHED;
- },
- },
-};
-</script>
-
-<template>
- <tr class="import-row">
- <td>
- <a
- :href="project.importSource.providerLink"
- rel="noreferrer noopener"
- target="_blank"
- data-testid="providerLink"
- >{{ project.importSource.fullName }}
- <gl-icon v-if="project.importSource.providerLink" name="external-link" />
- </a>
- </td>
- <td data-testid="fullPath">{{ displayFullPath }}</td>
- <td>
- <import-status :status="project.importStatus" />
- </td>
- <td>
- <a
- v-if="isFinished"
- class="btn btn-default"
- data-testid="goToProject"
- :href="project.importedProject.fullPath"
- rel="noreferrer noopener"
- target="_blank"
- >{{ __('Go to project') }}
- </a>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue
deleted file mode 100644
index 3140585ccd7..00000000000
--- a/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-<script>
-import { GlIcon, GlBadge } from '@gitlab/ui';
-
-export default {
- components: {
- GlBadge,
- GlIcon,
- },
- props: {
- repo: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <tr class="import-row">
- <td>
- <a :href="repo.importSource.providerLink" rel="noreferrer noopener" target="_blank"
- >{{ repo.importSource.fullName }}
- <gl-icon v-if="repo.importSource.providerLink" name="external-link" />
- </a>
- </td>
- <td></td>
- <td></td>
- <td>
- <gl-badge variant="danger">{{ __('Incompatible project') }}</gl-badge>
- </td>
- </tr>
-</template>
diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
index d8cffc6a7d5..18971313dfe 100644
--- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
@@ -1,9 +1,11 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlBadge } from '@gitlab/ui';
import Select2Select from '~/vue_shared/components/select2_select.vue';
import { __ } from '~/locale';
import ImportStatus from './import_status.vue';
+import { STATUSES } from '../constants';
+import { isProjectImportable, isIncompatible, getImportStatus } from '../utils';
export default {
name: 'ProviderRepoTableRow',
@@ -11,6 +13,7 @@ export default {
Select2Select,
ImportStatus,
GlIcon,
+ GlBadge,
},
props: {
repo: {
@@ -27,6 +30,26 @@ export default {
...mapState(['ciCdOnly']),
...mapGetters(['getImportTarget']),
+ displayFullPath() {
+ return this.repo.importedProject.fullPath.replace(/^\//, '');
+ },
+
+ isFinished() {
+ return this.repo.importedProject?.importStatus === STATUSES.FINISHED;
+ },
+
+ isImportNotStarted() {
+ return isProjectImportable(this.repo);
+ },
+
+ isIncompatible() {
+ return isIncompatible(this.repo);
+ },
+
+ importStatus() {
+ return getImportStatus(this.repo);
+ },
+
importTarget() {
return this.getImportTarget(this.repo.importSource.id);
},
@@ -85,9 +108,9 @@ export default {
<gl-icon v-if="repo.importSource.providerLink" name="external-link" />
</a>
</td>
- <td class="d-flex flex-wrap flex-lg-nowrap">
- <template v-if="repo.target">{{ repo.target }}</template>
- <template v-else>
+ <td class="d-flex flex-wrap flex-lg-nowrap" data-testid="fullPath">
+ <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
+ <template v-else-if="isImportNotStarted">
<select2-select v-model="targetNamespaceSelect" :options="select2Options" />
<span class="px-2 import-slash-divider d-flex justify-content-center align-items-center"
>/</span
@@ -98,18 +121,31 @@ export default {
class="form-control import-project-name-input qa-project-path-field"
/>
</template>
+ <template v-else-if="repo.importedProject">{{ displayFullPath }}</template>
</td>
<td>
- <import-status :status="repo.importStatus" />
+ <import-status :status="importStatus" />
</td>
- <td>
+ <td data-testid="actions">
+ <a
+ v-if="isFinished"
+ class="btn btn-default"
+ :href="repo.importedProject.fullPath"
+ rel="noreferrer noopener"
+ target="_blank"
+ >{{ __('Go to project') }}
+ </a>
<button
+ v-if="isImportNotStarted"
type="button"
class="qa-import-button btn btn-default"
@click="fetchImport(repo.importSource.id)"
>
{{ importButtonText }}
</button>
+ <gl-badge v-else-if="isIncompatible" variant="danger">{{
+ __('Incompatible project')
+ }}</gl-badge>
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js
index 7d529c94d7d..fded478bae2 100644
--- a/app/assets/javascripts/import_projects/store/getters.js
+++ b/app/assets/javascripts/import_projects/store/getters.js
@@ -1,17 +1,18 @@
import { STATUSES } from '../constants';
+import { isProjectImportable, isIncompatible } from '../utils';
export const isLoading = state => state.isLoadingRepos || state.isLoadingNamespaces;
export const isImportingAnyRepo = state =>
state.repositories.some(repo =>
- [STATUSES.SCHEDULING, STATUSES.SCHEDULED, STATUSES.STARTED].includes(repo.importStatus),
+ [STATUSES.SCHEDULING, STATUSES.SCHEDULED, STATUSES.STARTED].includes(
+ repo.importedProject?.importStatus,
+ ),
);
-export const hasIncompatibleRepos = state =>
- state.repositories.some(repo => repo.importSource.incompatible);
+export const hasIncompatibleRepos = state => state.repositories.some(isIncompatible);
-export const hasImportableRepos = state =>
- state.repositories.some(repo => repo.importStatus === STATUSES.NONE);
+export const hasImportableRepos = state => state.repositories.some(isProjectImportable);
export const getImportTarget = state => repoId => {
if (state.customImportTargets[repoId]) {
diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js
index b3dbef896a6..ea719e11d86 100644
--- a/app/assets/javascripts/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_projects/store/mutations.js
@@ -11,37 +11,36 @@ export default {
state.isLoadingRepos = true;
},
- [types.RECEIVE_REPOS_SUCCESS](
- state,
- { importedProjects, providerRepos, incompatibleRepos = [] },
- ) {
- // Normalizing structure to support legacy backend format
- // See https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091 for details
-
+ [types.RECEIVE_REPOS_SUCCESS](state, repositories) {
state.isLoadingRepos = false;
- state.repositories = [
- ...importedProjects.map(({ importSource, providerLink, importStatus, ...project }) => ({
- importSource: {
- id: `finished-${project.id}`,
- fullName: importSource,
- sanitizedName: project.name,
- providerLink,
- },
- importStatus,
- importedProject: project,
- })),
- ...providerRepos.map(project => ({
- importSource: project,
- importStatus: STATUSES.NONE,
- importedProject: null,
- })),
- ...incompatibleRepos.map(project => ({
- importSource: { ...project, incompatible: true },
- importStatus: STATUSES.NONE,
- importedProject: null,
- })),
- ];
+ if (!Array.isArray(repositories)) {
+ // Legacy code path, will be removed when all importers will be switched to new pagination format
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091
+ state.repositories = [
+ ...repositories.importedProjects.map(importedProject => ({
+ importSource: {
+ id: importedProject.id,
+ fullName: importedProject.importSource,
+ sanitizedName: importedProject.name,
+ providerLink: importedProject.providerLink,
+ },
+ importedProject,
+ })),
+ ...repositories.providerRepos.map(project => ({
+ importSource: project,
+ importedProject: null,
+ })),
+ ...(repositories.incompatibleRepos ?? []).map(project => ({
+ importSource: { ...project, incompatible: true },
+ importedProject: null,
+ })),
+ ];
+
+ return;
+ }
+
+ state.repositories = repositories;
},
[types.RECEIVE_REPOS_ERROR](state) {
@@ -50,31 +49,27 @@ export default {
[types.REQUEST_IMPORT](state, { repoId, importTarget }) {
const existingRepo = state.repositories.find(r => r.importSource.id === repoId);
- existingRepo.importStatus = STATUSES.SCHEDULING;
existingRepo.importedProject = {
+ importStatus: STATUSES.SCHEDULING,
fullPath: `/${importTarget.targetNamespace}/${importTarget.newName}`,
};
},
[types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }) {
- const { importStatus, ...project } = importedProject;
-
const existingRepo = state.repositories.find(r => r.importSource.id === repoId);
- existingRepo.importStatus = importStatus;
- existingRepo.importedProject = project;
+ existingRepo.importedProject = importedProject;
},
[types.RECEIVE_IMPORT_ERROR](state, repoId) {
const existingRepo = state.repositories.find(r => r.importSource.id === repoId);
- existingRepo.importStatus = STATUSES.NONE;
existingRepo.importedProject = null;
},
[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) {
updatedProjects.forEach(updatedProject => {
const repo = state.repositories.find(p => p.importedProject?.id === updatedProject.id);
- if (repo) {
- repo.importStatus = updatedProject.importStatus;
+ if (repo?.importedProject) {
+ repo.importedProject.importStatus = updatedProject.importStatus;
}
});
},
diff --git a/app/assets/javascripts/import_projects/utils.js b/app/assets/javascripts/import_projects/utils.js
index 02128c72cb0..695b12cbcba 100644
--- a/app/assets/javascripts/import_projects/utils.js
+++ b/app/assets/javascripts/import_projects/utils.js
@@ -1,6 +1,13 @@
import { STATUSES } from './constants';
-// Will be expanded in future
+export function isIncompatible(project) {
+ return project.importSource.incompatible;
+}
+
+export function getImportStatus(project) {
+ return project.importedProject?.importStatus ?? STATUSES.NONE;
+}
+
export function isProjectImportable(project) {
- return project.importStatus === STATUSES.NONE && !project.importSource.incompatible;
+ return !isIncompatible(project) && getImportStatus(project) === STATUSES.NONE;
}
diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue
new file mode 100644
index 00000000000..4edfddd0f85
--- /dev/null
+++ b/app/assets/javascripts/tooltips/components/tooltips.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlTooltip, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+const getTooltipTitle = element => {
+ return element.getAttribute('title') || element.dataset.title;
+};
+
+const newTooltip = (element, config = {}) => {
+ const { placement, container, boundary, html, triggers } = element.dataset;
+ const title = getTooltipTitle(element);
+
+ return {
+ id: uniqueId('gl-tooltip'),
+ target: element,
+ title,
+ html,
+ placement,
+ container,
+ boundary,
+ triggers,
+ disabled: !title,
+ ...config,
+ };
+};
+
+export default {
+ components: {
+ GlTooltip,
+ },
+ directives: {
+ SafeHtml,
+ },
+ data() {
+ return {
+ tooltips: [],
+ };
+ },
+ methods: {
+ addTooltips(elements, config) {
+ const newTooltips = elements
+ .filter(element => !this.tooltipExists(element))
+ .map(element => newTooltip(element, config));
+
+ this.tooltips.push(...newTooltips);
+ },
+ tooltipExists(element) {
+ return this.tooltips.some(tooltip => tooltip.target === element);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-tooltip
+ v-for="(tooltip, index) in tooltips"
+ :id="tooltip.id"
+ :key="index"
+ :target="tooltip.target"
+ :triggers="tooltip.triggers"
+ :placement="tooltip.placement"
+ :container="tooltip.container"
+ :boundary="tooltip.boundary"
+ :disabled="tooltip.disabled"
+ >
+ <span v-if="tooltip.html" v-safe-html="tooltip.title"></span>
+ <span v-else>{{ tooltip.title }}</span>
+ </gl-tooltip>
+ </div>
+</template>
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
new file mode 100644
index 00000000000..985f3350a62
--- /dev/null
+++ b/app/assets/javascripts/tooltips/index.js
@@ -0,0 +1,58 @@
+import Vue from 'vue';
+import Tooltips from './components/tooltips.vue';
+
+let app;
+
+const EVENTS_MAP = {
+ hover: 'mouseenter',
+ click: 'click',
+ focus: 'focus',
+};
+
+const DEFAULT_TRIGGER = 'hover focus';
+
+const tooltipsApp = () => {
+ if (!app) {
+ app = new Vue({
+ render(h) {
+ return h(Tooltips, {
+ props: {
+ elements: this.elements,
+ },
+ ref: 'tooltips',
+ });
+ },
+ }).$mount();
+ }
+
+ return app;
+};
+
+const isTooltip = (node, selector) => node.matches && node.matches(selector);
+
+const addTooltips = (elements, config) => {
+ tooltipsApp().$refs.tooltips.addTooltips(Array.from(elements), config);
+};
+
+const handleTooltipEvent = (rootTarget, e, selector, config = {}) => {
+ for (let { target } = e; target && target !== rootTarget; target = target.parentNode) {
+ if (isTooltip(target, selector)) {
+ addTooltips([target], {
+ show: true,
+ ...config,
+ });
+ break;
+ }
+ }
+};
+
+export const initTooltips = (selector, config = {}) => {
+ const triggers = config?.triggers || DEFAULT_TRIGGER;
+ const events = triggers.split(' ').map(trigger => EVENTS_MAP[trigger]);
+
+ events.forEach(event => {
+ document.addEventListener(event, e => handleTooltipEvent(document, e, selector, config), true);
+ });
+
+ return tooltipsApp();
+};
diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue
index 540edc9f61c..29d4516bece 100644
--- a/app/assets/javascripts/vue_shared/components/toggle_button.vue
+++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue
@@ -73,7 +73,7 @@ export default {
'is-disabled': disabledInput,
'is-loading': isLoading,
}"
- @click="toggleFeature"
+ @click.prevent="toggleFeature"
>
<gl-loading-icon class="loading-icon" />
<span class="toggle-icon">