summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js67
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue123
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue51
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue137
-rw-r--r--app/assets/javascripts/vue_shared/components/incidents/utils.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/details_row.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/registry_search.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js57
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue134
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue91
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue150
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js42
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js109
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js116
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js63
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_heading.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js23
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js40
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js40
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_list_item.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js38
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js49
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue43
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue191
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue86
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue92
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue65
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue (renamed from app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue119
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue221
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue39
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue67
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue82
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue327
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js58
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js52
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js20
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js70
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js29
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql20
-rw-r--r--app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue175
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue13
71 files changed, 1886 insertions, 1813 deletions
diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
index a74e9d97143..ba4279fe3e3 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -1,11 +1,12 @@
<script>
-import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { reduce } from 'lodash';
import {
capitalizeFirstCharacter,
convertToSentenceCase,
splitCamelCase,
} from '~/lib/utils/text_utility';
+import { isSafeURL } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants';
@@ -30,6 +31,7 @@ const allowedFields = [
export default {
components: {
+ GlLink,
GlLoadingIcon,
GlTable,
},
@@ -94,6 +96,9 @@ export default {
isAllowed(fieldName) {
return allowedFields.includes(fieldName);
},
+ isValidLink(value) {
+ return typeof value === 'string' && isSafeURL(value);
+ },
},
};
</script>
@@ -109,5 +114,11 @@ export default {
<template #table-busy>
<gl-loading-icon size="lg" color="dark" class="gl-mt-5" />
</template>
+ <template #cell(value)="{ item: { value } }">
+ <span v-if="!isValidLink(value)">{{ value }}</span>
+ <gl-link v-else :href="value" target="_blank">
+ {{ value }}
+ </gl-link>
+ </template>
</gl-table>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue
index 1f293b2150f..16ca2df02c0 100644
--- a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue
@@ -22,7 +22,12 @@ export default {
</script>
<template>
- <gl-alert v-if="hasManagedPrometheus" variant="warning" class="my-2">
+ <gl-alert
+ v-if="hasManagedPrometheus"
+ variant="warning"
+ class="my-2"
+ data-testid="alerts-deprecation-warning"
+ >
<gl-sprintf :message="$options.i18n.alertsDeprecationText">
<template #link="{ content }">
<gl-link
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 08d3e163257..e6d9a38d1fb 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -173,7 +173,7 @@ export default {
v-for="awardList in groupedAwards"
:key="awardList.name"
v-gl-tooltip.viewport
- class="gl-mr-3"
+ class="gl-mr-3 gl-my-2"
:class="awardList.classes"
:title="awardList.title"
data-testid="award-button"
@@ -184,10 +184,10 @@ export default {
</template>
<span class="js-counter">{{ awardList.list.length }}</span>
</gl-button>
- <div v-if="canAwardEmoji" class="award-menu-holder">
+ <div v-if="canAwardEmoji" class="award-menu-holder gl-my-2">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
- :toggle-class="['add-reaction-button gl-relative!', { 'is-active': isMenuOpen }]"
+ :toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]"
@click="handleAward"
@shown="setIsMenuOpen(true)"
@hidden="setIsMenuOpen(false)"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 2cb1b6a195f..9775a9119c6 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -21,7 +21,7 @@ export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([
{ value: FILTER_CURRENT, text: __(FILTER_CURRENT) },
]);
-export const DEFAULT_LABELS = [{ value: 'No label', text: __('No label') }]; // eslint-disable-line @gitlab/require-i18n-strings
+export const DEFAULT_LABELS = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([
{ value: 'Upcoming', text: __('Upcoming') }, // eslint-disable-line @gitlab/require-i18n-strings
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 3e7feb91b27..5ab287150f2 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
@@ -12,7 +12,7 @@ import {
import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import { SortDirection } from './constants';
@@ -211,7 +211,9 @@ export default {
.catch((error) => {
if (error.name === 'RecentSearchesServiceError') return undefined;
- createFlash(__('An error occurred while parsing recent searches'));
+ createFlash({
+ message: __('An error occurred while parsing recent searches'),
+ });
// Gracefully fail to empty array
return [];
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
index e5c8d29e09b..37436de907f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
@@ -2,7 +2,7 @@ import { isEmpty, uniqWith, isEqual } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { queryToObject } from '~/lib/utils/url_utility';
-import { MAX_RECENT_TOKENS_SIZE } from './constants';
+import { MAX_RECENT_TOKENS_SIZE, FILTERED_SEARCH_TERM } from './constants';
/**
* Strips enclosing quotations from a string if it has one.
@@ -23,7 +23,7 @@ export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2');
export const uniqueTokens = (tokens) => {
const knownTokens = [];
return tokens.reduce((uniques, token) => {
- if (typeof token === 'object' && token.type !== 'filtered-search-term') {
+ if (typeof token === 'object' && token.type !== FILTERED_SEARCH_TERM) {
const tokenString = `${token.type}${token.value.operator}${token.value.data}`;
if (!knownTokens.includes(tokenString)) {
uniques.push(token);
@@ -86,21 +86,37 @@ export function processFilters(filters) {
}, {});
}
+function filteredSearchQueryParam(filter) {
+ return filter
+ .map(({ value }) => value)
+ .join(' ')
+ .trim();
+}
+
/**
* This function takes a filter object and maps it into a query object. Example filter:
- * { myFilterName: { value: 'foo', operator: '=' } }
+ * { myFilterName: { value: 'foo', operator: '=' }, search: [{ value: 'my' }, { value: 'search' }] }
* gets translated into:
- * { myFilterName: 'foo', 'not[myFilterName]': null }
+ * { myFilterName: 'foo', 'not[myFilterName]': null, search: 'my search' }
* @param {Object} filters
- * @param {Object.myFilterName} a single filter value or an array of filters
+ * @param {Object} filters.myFilterName a single filter value or an array of filters
+ * @param {Object} options
+ * @param {Object} [options.filteredSearchTermKey] if set, 'filtered-search-term' filters are assigned to this key, 'search' is suggested
* @return {Object} query object with both filter name and not-name with values
*/
-export function filterToQueryObject(filters = {}) {
+export function filterToQueryObject(filters = {}, options = {}) {
+ const { filteredSearchTermKey } = options;
+
return Object.keys(filters).reduce((memo, key) => {
const filter = filters[key];
+ if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM) {
+ return { ...memo, [filteredSearchTermKey]: filteredSearchQueryParam(filter) };
+ }
+
let selected;
let unselected;
+
if (Array.isArray(filter)) {
selected = filter.filter((item) => item.operator === '=').map((item) => item.value);
unselected = filter.filter((item) => item.operator === '!=').map((item) => item.value);
@@ -125,7 +141,7 @@ export function filterToQueryObject(filters = {}) {
* and returns the operator with it depending on the filter name
* @param {String} filterName from url
* @return {Object}
- * @return {Object.filterName} extracted filtern ame
+ * @return {Object.filterName} extracted filter name
* @return {Object.operator} `=` or `!=`
*/
function extractNameAndOperator(filterName) {
@@ -138,21 +154,52 @@ function extractNameAndOperator(filterName) {
}
/**
+ * Gathers search term as values
+ * @param {String|Array} value
+ * @returns {Array} List of search terms split by word
+ */
+function filteredSearchTermValue(value) {
+ const values = Array.isArray(value) ? value : [value];
+ return values
+ .filter((term) => term)
+ .join(' ')
+ .split(' ')
+ .map((term) => ({ value: term }));
+}
+
+/**
* This function takes a URL query string and maps it into a filter object. Example query string:
* '?myFilterName=foo'
* gets translated into:
* { myFilterName: { value: 'foo', operator: '=' } }
- * @param {String} query URL quert string, e.g. from `window.location.search`
+ * @param {String} query URL query string, e.g. from `window.location.search`
+ * @param {Object} options
+ * @param {Object} options
+ * @param {String} [options.filteredSearchTermKey] if set, a FILTERED_SEARCH_TERM filter is created to this parameter. `'search'` is suggested
+ * @param {String[]} [options.filterNamesAllowList] if set, only this list of filters names is mapped
+ * @param {Boolean} [options.legacySpacesDecode] if set, plus symbols (+) are not encoded as spaces. `false` is suggested
* @return {Object} filter object with filter names and their values
*/
-export function urlQueryToFilter(query = '') {
- const filters = queryToObject(query, { gatherArrays: true });
+export function urlQueryToFilter(query = '', options = {}) {
+ const { filteredSearchTermKey, filterNamesAllowList, legacySpacesDecode = true } = options;
+
+ const filters = queryToObject(query, { gatherArrays: true, legacySpacesDecode });
return Object.keys(filters).reduce((memo, key) => {
const value = filters[key];
if (!value) {
return memo;
}
+ if (key === filteredSearchTermKey) {
+ return {
+ ...memo,
+ [FILTERED_SEARCH_TERM]: filteredSearchTermValue(value),
+ };
+ }
+
const { filterName, operator } = extractNameAndOperator(key);
+ if (filterNamesAllowList && !filterNamesAllowList.includes(filterName)) {
+ return memo;
+ }
let previousValues = [];
if (Array.isArray(memo[filterName])) {
previousValues = memo[filterName];
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
index 4dfc61f1fff..f4317ba90a2 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
@@ -1,5 +1,5 @@
import Api from '~/api';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types';
@@ -24,7 +24,9 @@ export function fetchBranches({ commit, state }, search = '') {
.catch(({ response }) => {
const { status } = response;
commit(types.RECEIVE_BRANCHES_ERROR, status);
- createFlash(__('Failed to load branches. Please try again.'));
+ createFlash({
+ message: __('Failed to load branches. Please try again.'),
+ });
});
}
@@ -41,7 +43,9 @@ export const fetchMilestones = ({ commit, state }, search_title = '') => {
.catch(({ response }) => {
const { status } = response;
commit(types.RECEIVE_MILESTONES_ERROR, status);
- createFlash(__('Failed to load milestones. Please try again.'));
+ createFlash({
+ message: __('Failed to load milestones. Please try again.'),
+ });
});
};
@@ -57,7 +61,9 @@ export const fetchLabels = ({ commit, state }, search = '') => {
.catch(({ response }) => {
const { status } = response;
commit(types.RECEIVE_LABELS_ERROR, status);
- createFlash(__('Failed to load labels. Please try again.'));
+ createFlash({
+ message: __('Failed to load labels. Please try again.'),
+ });
});
};
@@ -80,7 +86,9 @@ function fetchUser(options = {}) {
.catch(({ response }) => {
const { status } = response;
commit(`RECEIVE_${action}_ERROR`, status);
- createFlash(errorMessage);
+ createFlash({
+ message: errorMessage,
+ });
});
}
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 aeb698a3adb..2e7b3e149b2 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
@@ -1,25 +1,18 @@
<script>
-import {
- GlFilteredSearchToken,
- GlAvatar,
- GlFilteredSearchSuggestion,
- GlDropdownDivider,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import { debounce } from 'lodash';
+import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
-import { DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants';
+import { DEFAULT_LABEL_ANY } from '../constants';
+
+import BaseToken from './base_token.vue';
export default {
components: {
- GlFilteredSearchToken,
+ BaseToken,
GlAvatar,
GlFilteredSearchSuggestion,
- GlDropdownDivider,
- GlLoadingIcon,
},
props: {
config: {
@@ -30,37 +23,28 @@ export default {
type: Object,
required: true,
},
+ active: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
authors: this.config.initialAuthors || [],
defaultAuthors: this.config.defaultAuthors || [DEFAULT_LABEL_ANY],
- loading: true,
+ preloadedAuthors: this.config.preloadedAuthors || [],
+ loading: false,
};
},
- computed: {
- currentValue() {
- return this.value.data.toLowerCase();
- },
- activeAuthor() {
- return this.authors.find((author) => author.username.toLowerCase() === this.currentValue);
- },
- activeAuthorAvatar() {
- return this.avatarUrl(this.activeAuthor);
+ methods: {
+ getActiveAuthor(authors, currentValue) {
+ return authors.find((author) => author.username.toLowerCase() === currentValue);
},
- },
- watch: {
- active: {
- immediate: true,
- handler(newValue) {
- if (!newValue && !this.authors.length) {
- this.fetchAuthorBySearchTerm(this.value.data);
- }
- },
+ getAvatarUrl(author) {
+ return author.avatarUrl || author.avatar_url;
},
- },
- methods: {
fetchAuthorBySearchTerm(searchTerm) {
+ this.loading = true;
const fetchPromise = this.config.fetchPath
? this.config.fetchAuthors(this.config.fetchPath, searchTerm)
: this.config.fetchAuthors(searchTerm);
@@ -72,63 +56,56 @@ export default {
// return response differently.
this.authors = Array.isArray(res) ? res : res.data;
})
- .catch(() => createFlash(__('There was a problem fetching users.')))
+ .catch(() =>
+ createFlash({
+ message: __('There was a problem fetching users.'),
+ }),
+ )
.finally(() => {
this.loading = false;
});
},
- avatarUrl(author) {
- return author.avatarUrl || author.avatar_url;
- },
- searchAuthors: debounce(function debouncedSearch({ data }) {
- this.fetchAuthorBySearchTerm(data);
- }, DEBOUNCE_DELAY),
},
};
</script>
<template>
- <gl-filtered-search-token
- :config="config"
- v-bind="{ ...$props, ...$attrs }"
- v-on="$listeners"
- @input="searchAuthors"
+ <base-token
+ :token-config="config"
+ :token-value="value"
+ :token-active="active"
+ :tokens-list-loading="loading"
+ :token-values="authors"
+ :fn-active-token-value="getActiveAuthor"
+ :default-token-values="defaultAuthors"
+ :preloaded-token-values="preloadedAuthors"
+ :recent-token-values-storage-key="config.recentTokenValuesStorageKey"
+ @fetch-token-values="fetchAuthorBySearchTerm"
>
- <template #view="{ inputValue }">
+ <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
<gl-avatar
- v-if="activeAuthor"
+ v-if="activeTokenValue"
:size="16"
- :src="activeAuthorAvatar"
+ :src="getAvatarUrl(activeTokenValue)"
shape="circle"
class="gl-mr-2"
/>
- <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
+ <span>{{ activeTokenValue ? activeTokenValue.name : inputValue }}</span>
</template>
- <template #suggestions>
+ <template #token-values-list="{ tokenValues }">
<gl-filtered-search-suggestion
- v-for="author in defaultAuthors"
- :key="author.value"
- :value="author.value"
+ v-for="author in tokenValues"
+ :key="author.username"
+ :value="author.username"
>
- {{ author.text }}
- </gl-filtered-search-suggestion>
- <gl-dropdown-divider v-if="defaultAuthors.length" />
- <gl-loading-icon v-if="loading" />
- <template v-else>
- <gl-filtered-search-suggestion
- v-for="author in authors"
- :key="author.username"
- :value="author.username"
- >
- <div class="d-flex">
- <gl-avatar :size="32" :src="avatarUrl(author)" />
- <div>
- <div>{{ author.name }}</div>
- <div>@{{ author.username }}</div>
- </div>
+ <div class="gl-display-flex">
+ <gl-avatar :size="32" :src="getAvatarUrl(author)" />
+ <div>
+ <div>{{ author.name }}</div>
+ <div>@{{ author.username }}</div>
</div>
- </gl-filtered-search-suggestion>
- </template>
+ </div>
+ </gl-filtered-search-suggestion>
</template>
- </gl-filtered-search-token>
+ </base-token>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index 6ebc5431012..fb6b9e4bc0d 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -48,6 +48,11 @@ export default {
required: false,
default: () => [],
},
+ preloadedTokenValues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
recentTokenValuesStorageKey: {
type: String,
required: false,
@@ -78,7 +83,10 @@ export default {
return Boolean(this.recentTokenValuesStorageKey);
},
recentTokenIds() {
- return this.recentTokenValues.map((tokenValue) => tokenValue.id || tokenValue.name);
+ return this.recentTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]);
+ },
+ preloadedTokenIds() {
+ return this.preloadedTokenValues.map((tokenValue) => tokenValue[this.valueIdentifier]);
},
currentTokenValue() {
if (this.fnCurrentTokenValue) {
@@ -98,7 +106,9 @@ export default {
return this.searchKey
? this.tokenValues
: this.tokenValues.filter(
- (tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]),
+ (tokenValue) =>
+ !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]) &&
+ !this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]),
);
},
},
@@ -120,7 +130,15 @@ export default {
}, DEBOUNCE_DELAY);
},
handleTokenValueSelected(activeTokenValue) {
- if (this.isRecentTokenValuesEnabled) {
+ // Make sure that;
+ // 1. Recently used values feature is enabled
+ // 2. User has actually selected a value
+ // 3. Selected value is not part of preloaded list.
+ if (
+ this.isRecentTokenValuesEnabled &&
+ activeTokenValue &&
+ !this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier])
+ ) {
setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue);
}
},
@@ -158,6 +176,11 @@ export default {
<slot name="token-values-list" :token-values="recentTokenValues"></slot>
<gl-dropdown-divider />
</template>
+ <slot
+ v-if="preloadedTokenValues.length"
+ name="token-values-list"
+ :token-values="preloadedTokenValues"
+ ></slot>
<gl-loading-icon v-if="tokensListLoading" />
<template v-else>
<slot name="token-values-list" :token-values="availableTokenValues"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
index f2f4787d80b..9ba7f3d1a1d 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -7,7 +7,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants';
@@ -65,7 +65,11 @@ export default {
.then((res) => {
this.emojis = Array.isArray(res) ? res : res.data;
})
- .catch(() => createFlash(__('There was a problem fetching emojis.')))
+ .catch(() =>
+ createFlash({
+ message: __('There was a problem fetching emojis.'),
+ }),
+ )
.finally(() => {
this.loading = false;
});
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
index 1450807b11d..d21fa9a344a 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
@@ -11,6 +11,7 @@ import { __ } from '~/locale';
import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants';
export default {
+ separator: '::&',
components: {
GlDropdownDivider,
GlFilteredSearchToken,
@@ -34,17 +35,35 @@ export default {
};
},
computed: {
+ idProperty() {
+ return this.config.idProperty || 'iid';
+ },
currentValue() {
- return Number(this.value.data);
+ const epicIid = Number(this.value.data);
+ if (epicIid) {
+ return epicIid;
+ }
+ return this.value.data;
},
defaultEpics() {
return this.config.defaultEpics || DEFAULT_NONE_ANY;
},
- idProperty() {
- return this.config.idProperty || 'id';
- },
activeEpic() {
- return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
+ if (this.currentValue && this.epics.length) {
+ // Check if current value is an epic ID.
+ if (typeof this.currentValue === 'number') {
+ return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
+ }
+
+ // Current value is a string.
+ const [groupPath, idProperty] = this.currentValue?.split('::&');
+ return this.epics.find(
+ (epic) =>
+ epic.group_full_path === groupPath &&
+ epic[this.idProperty] === parseInt(idProperty, 10),
+ );
+ }
+ return null;
},
},
watch: {
@@ -58,10 +77,10 @@ export default {
},
},
methods: {
- fetchEpicsBySearchTerm(searchTerm = '') {
+ fetchEpicsBySearchTerm({ epicPath = '', search = '' }) {
this.loading = true;
this.config
- .fetchEpics(searchTerm)
+ .fetchEpics({ epicPath, search })
.then((response) => {
this.epics = Array.isArray(response) ? response : response.data;
})
@@ -71,11 +90,21 @@ export default {
});
},
searchEpics: debounce(function debouncedSearch({ data }) {
- this.fetchEpicsBySearchTerm(data);
+ let epicPath = this.activeEpic?.web_url;
+
+ // When user visits the page with token value already included in filters
+ // We don't have any information about selected token except for its
+ // group path and iid joined by separator, so we need to manually
+ // compose epic path from it.
+ if (data.includes(this.$options.separator)) {
+ const [groupPath, epicIid] = data.split(this.$options.separator);
+ epicPath = `/groups/${groupPath}/-/epics/${epicIid}`;
+ }
+ this.fetchEpicsBySearchTerm({ epicPath, search: data });
}, DEBOUNCE_DELAY),
getEpicDisplayText(epic) {
- return `${epic.title}::&${epic[this.idProperty]}`;
+ return `${epic.title}${this.$options.separator}${epic.iid}`;
},
},
};
@@ -104,8 +133,8 @@ export default {
<template v-else>
<gl-filtered-search-suggestion
v-for="epic in epics"
- :key="epic[idProperty]"
- :value="String(epic[idProperty])"
+ :key="epic.id"
+ :value="`${epic.group_full_path}::&${epic[idProperty]}`"
>
{{ epic.title }}
</gl-filtered-search-suggestion>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 76b005772ec..20b8cbfe933 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -1,27 +1,20 @@
<script>
-import {
- GlToken,
- GlFilteredSearchToken,
- GlFilteredSearchSuggestion,
- GlDropdownDivider,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import { debounce } from 'lodash';
+import { GlToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import { DEFAULT_LABELS, DEBOUNCE_DELAY } from '../constants';
+import { DEFAULT_LABELS } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
+import BaseToken from './base_token.vue';
+
export default {
components: {
+ BaseToken,
GlToken,
- GlFilteredSearchToken,
GlFilteredSearchSuggestion,
- GlDropdownDivider,
- GlLoadingIcon,
},
props: {
config: {
@@ -32,43 +25,24 @@ export default {
type: Object,
required: true,
},
+ active: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
labels: this.config.initialLabels || [],
defaultLabels: this.config.defaultLabels || DEFAULT_LABELS,
- loading: true,
+ loading: false,
};
},
- computed: {
- currentValue() {
- return this.value.data.toLowerCase();
- },
- activeLabel() {
- return this.labels.find(
- (label) => this.getLabelName(label).toLowerCase() === stripQuotes(this.currentValue),
+ methods: {
+ getActiveLabel(labels, currentValue) {
+ return labels.find(
+ (label) => this.getLabelName(label).toLowerCase() === stripQuotes(currentValue),
);
},
- containerStyle() {
- if (this.activeLabel) {
- const { color, textColor } = convertObjectPropsToCamelCase(this.activeLabel);
-
- return { backgroundColor: color, color: textColor };
- }
- return {};
- },
- },
- watch: {
- active: {
- immediate: true,
- handler(newValue) {
- if (!newValue && !this.labels.length) {
- this.fetchLabelBySearchTerm(this.value.data);
- }
- },
- },
- },
- methods: {
/**
* There's an inconsistency between private and public API
* for labels where label name is included in a different
@@ -84,6 +58,16 @@ export default {
getLabelName(label) {
return label.name || label.title;
},
+ getContainerStyle(activeLabel) {
+ if (activeLabel) {
+ const { color: backgroundColor, textColor: color } = convertObjectPropsToCamelCase(
+ activeLabel,
+ );
+
+ return { backgroundColor, color };
+ }
+ return {};
+ },
fetchLabelBySearchTerm(searchTerm) {
this.loading = true;
this.config
@@ -94,55 +78,56 @@ export default {
// return response differently.
this.labels = Array.isArray(res) ? res : res.data;
})
- .catch(() => createFlash(__('There was a problem fetching labels.')))
+ .catch(() =>
+ createFlash({
+ message: __('There was a problem fetching labels.'),
+ }),
+ )
.finally(() => {
this.loading = false;
});
},
- searchLabels: debounce(function debouncedSearch({ data }) {
- if (!this.loading) this.fetchLabelBySearchTerm(data);
- }, DEBOUNCE_DELAY),
},
};
</script>
<template>
- <gl-filtered-search-token
- :config="config"
- v-bind="{ ...$props, ...$attrs }"
- v-on="$listeners"
- @input="searchLabels"
+ <base-token
+ :token-config="config"
+ :token-value="value"
+ :token-active="active"
+ :tokens-list-loading="loading"
+ :token-values="labels"
+ :fn-active-token-value="getActiveLabel"
+ :default-token-values="defaultLabels"
+ :recent-token-values-storage-key="config.recentTokenValuesStorageKey"
+ @fetch-token-values="fetchLabelBySearchTerm"
>
- <template #view-token="{ inputValue, cssClasses, listeners }">
- <gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners"
- >~{{ activeLabel ? getLabelName(activeLabel) : inputValue }}</gl-token
+ <template
+ #view-token="{ viewTokenProps: { inputValue, cssClasses, listeners, activeTokenValue } }"
+ >
+ <gl-token
+ variant="search-value"
+ :class="cssClasses"
+ :style="getContainerStyle(activeTokenValue)"
+ v-on="listeners"
+ >~{{ activeTokenValue ? getLabelName(activeTokenValue) : inputValue }}</gl-token
>
</template>
- <template #suggestions>
+ <template #token-values-list="{ tokenValues }">
<gl-filtered-search-suggestion
- v-for="label in defaultLabels"
- :key="label.value"
- :value="label.value"
+ v-for="label in tokenValues"
+ :key="label.id"
+ :value="getLabelName(label)"
>
- {{ label.text }}
+ <div class="gl-display-flex gl-align-items-center">
+ <span
+ :style="{ backgroundColor: label.color }"
+ class="gl-display-inline-block mr-2 p-2"
+ ></span>
+ <div>{{ getLabelName(label) }}</div>
+ </div>
</gl-filtered-search-suggestion>
- <gl-dropdown-divider v-if="defaultLabels.length" />
- <gl-loading-icon v-if="loading" />
- <template v-else>
- <gl-filtered-search-suggestion
- v-for="label in labels"
- :key="label.id"
- :value="getLabelName(label)"
- >
- <div class="gl-display-flex gl-align-items-center">
- <span
- :style="{ backgroundColor: label.color }"
- class="gl-display-inline-block mr-2 p-2"
- ></span>
- <div>{{ getLabelName(label) }}</div>
- </div>
- </gl-filtered-search-suggestion>
- </template>
</template>
- </gl-filtered-search-token>
+ </base-token>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/incidents/utils.js b/app/assets/javascripts/vue_shared/components/incidents/utils.js
new file mode 100644
index 00000000000..bcb578a6ba6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/incidents/utils.js
@@ -0,0 +1,3 @@
+import { noop } from 'lodash';
+
+export const isValidSlaDueAt = noop;
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
index 3006ba83f98..b2f077f5329 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
@@ -60,7 +60,7 @@ export default {
},
methods: {
avatarUrlTitle(assignee) {
- return sprintf(__('Avatar for %{assigneeName}'), {
+ return sprintf(__('Assigned to %{assigneeName}'), {
assigneeName: assignee.name,
});
},
diff --git a/app/assets/javascripts/vue_shared/components/registry/details_row.vue b/app/assets/javascripts/vue_shared/components/registry/details_row.vue
index 2e245fadead..72e06b45561 100644
--- a/app/assets/javascripts/vue_shared/components/registry/details_row.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/details_row.vue
@@ -8,7 +8,8 @@ export default {
props: {
icon: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
padding: {
type: String,
@@ -34,7 +35,7 @@ export default {
class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-word-break-all"
:class="[padding, borderClass]"
>
- <gl-icon :name="icon" class="gl-mr-4" />
+ <gl-icon v-if="icon" :name="icon" class="gl-mr-4" />
<span>
<slot></slot>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index b9e916bc199..933a215112b 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -62,7 +62,7 @@ export default {
<slot name="left-action"></slot>
</div>
<div
- class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-fill-1"
+ class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1"
>
<div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1">
<div
@@ -81,7 +81,7 @@ export default {
</div>
<div
v-if="$slots['left-secondary']"
- class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-fill-1"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1"
>
<slot name="left-secondary"></slot>
</div>
@@ -114,7 +114,7 @@ export default {
<div class="gl-w-7"></div>
<div
v-if="isDetailsShown"
- class="gl-display-flex gl-flex-direction-column gl-flex-fill-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3"
+ class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3"
>
<div
v-for="(row, detailIndex) in detailsSlots"
diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
index 0825c3a76ea..767a108dde5 100644
--- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
@@ -109,7 +109,7 @@ export default {
<div class="gl-display-flex gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100">
<gl-filtered-search
v-model="internalFilter"
- class="gl-mr-4 gl-flex-fill-1"
+ class="gl-mr-4 gl-flex-grow-1"
:placeholder="__('Filter results')"
:available-tokens="tokens"
@submit="submitSearch"
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
deleted file mode 100644
index cbb30baa488..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import { __ } from '~/locale';
-
-export const CUSTOM_EVENTS = {
- openAddImageModal: 'gl_openAddImageModal',
- openInsertVideoModal: 'gl_openInsertVideoModal',
-};
-
-export const YOUTUBE_URL = 'https://www.youtube.com';
-
-export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`;
-
-export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL];
-
-/* eslint-disable @gitlab/require-i18n-strings */
-export const TOOLBAR_ITEM_CONFIGS = [
- { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
- { icon: 'bold', command: 'Bold', tooltip: __('Add bold text') },
- { icon: 'italic', command: 'Italic', tooltip: __('Add italic text') },
- { icon: 'strikethrough', command: 'Strike', tooltip: __('Add strikethrough text') },
- { isDivider: true },
- { icon: 'quote', command: 'Blockquote', tooltip: __('Insert a quote') },
- { icon: 'link', event: 'openPopupAddLink', tooltip: __('Add a link') },
- { isDivider: true },
- { icon: 'list-bulleted', command: 'UL', tooltip: __('Add a bullet list') },
- { icon: 'list-numbered', command: 'OL', tooltip: __('Add a numbered list') },
- { icon: 'list-task', command: 'Task', tooltip: __('Add a task list') },
- { icon: 'list-indent', command: 'Indent', tooltip: __('Indent') },
- { icon: 'list-outdent', command: 'Outdent', tooltip: __('Outdent') },
- { isDivider: true },
- { icon: 'dash', command: 'HR', tooltip: __('Add a line') },
- { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
- { icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
- { icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') },
- { isDivider: true },
- { icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
- { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
-];
-
-export const EDITOR_TYPES = {
- markdown: 'markdown',
- wysiwyg: 'wysiwyg',
-};
-
-export const EDITOR_HEIGHT = '100%';
-
-export const EDITOR_PREVIEW_STYLE = 'horizontal';
-
-export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 };
-
-export const MAX_FILE_SIZE = 2097152; // 2Mb
-
-export const VIDEO_ATTRIBUTES = {
- width: '560',
- height: '315',
- frameBorder: '0',
- allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
-};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
deleted file mode 100644
index 82060d2e4ad..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
+++ /dev/null
@@ -1,134 +0,0 @@
-<script>
-import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
-import { isSafeURL, joinPaths } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import { IMAGE_TABS } from '../../constants';
-import UploadImageTab from './upload_image_tab.vue';
-
-export default {
- components: {
- UploadImageTab,
- GlModal,
- GlFormGroup,
- GlFormInput,
- GlTabs,
- GlTab,
- },
- props: {
- imageRoot: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- file: null,
- urlError: null,
- imageUrl: null,
- description: null,
- tabIndex: IMAGE_TABS.UPLOAD_TAB,
- uploadImageTab: null,
- };
- },
- modalTitle: __('Image details'),
- okTitle: __('Insert image'),
- urlTabTitle: __('Link to an image'),
- urlLabel: __('Image URL'),
- descriptionLabel: __('Description'),
- uploadTabTitle: __('Upload an image'),
- computed: {
- altText() {
- return this.description;
- },
- },
- methods: {
- show() {
- this.file = null;
- this.urlError = null;
- this.imageUrl = null;
- this.description = null;
- this.tabIndex = IMAGE_TABS.UPLOAD_TAB;
-
- this.$refs.modal.show();
- },
- onOk(event) {
- if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
- this.submitFile(event);
- return;
- }
- this.submitURL(event);
- },
- setFile(file) {
- this.file = file;
- },
- submitFile(event) {
- const { file, altText } = this;
- const { uploadImageTab } = this.$refs;
-
- uploadImageTab.validateFile();
-
- if (uploadImageTab.fileError) {
- event.preventDefault();
- return;
- }
-
- const imageUrl = joinPaths(this.imageRoot, file.name);
-
- this.$emit('addImage', { imageUrl, file, altText: altText || file.name });
- },
- submitURL(event) {
- if (!this.validateUrl()) {
- event.preventDefault();
- return;
- }
-
- const { imageUrl, altText } = this;
-
- this.$emit('addImage', { imageUrl, altText: altText || imageUrl });
- },
- validateUrl() {
- if (!isSafeURL(this.imageUrl)) {
- this.urlError = __('Please provide a valid URL');
- this.$refs.urlInput.$el.focus();
- return false;
- }
-
- return true;
- },
- },
-};
-</script>
-<template>
- <gl-modal
- ref="modal"
- modal-id="add-image-modal"
- :title="$options.modalTitle"
- :ok-title="$options.okTitle"
- @ok="onOk"
- >
- <gl-tabs v-model="tabIndex">
- <!-- Upload file Tab -->
- <gl-tab :title="$options.uploadTabTitle">
- <upload-image-tab ref="uploadImageTab" @input="setFile" />
- </gl-tab>
-
- <!-- By URL Tab -->
- <gl-tab :title="$options.urlTabTitle">
- <gl-form-group
- class="gl-mt-5 gl-mb-3"
- :label="$options.urlLabel"
- label-for="url-input"
- :state="!Boolean(urlError)"
- :invalid-feedback="urlError"
- >
- <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
- </gl-form-group>
- </gl-tab>
- </gl-tabs>
-
- <!-- Description Input -->
- <gl-form-group :label="$options.descriptionLabel" label-for="description-input">
- <gl-form-input id="description-input" ref="descriptionInput" v-model="description" />
- </gl-form-group>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue
deleted file mode 100644
index 9baa7f286d7..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import { GlFormGroup } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { MAX_FILE_SIZE } from '../../constants';
-
-export default {
- components: {
- GlFormGroup,
- },
- data() {
- return {
- file: null,
- fileError: null,
- };
- },
- fileLabel: __('Select file'),
- methods: {
- onInput(event) {
- [this.file] = event.target.files;
-
- this.validateFile();
-
- if (!this.fileError) {
- this.$emit('input', this.file);
- }
- },
- validateFile() {
- this.fileError = null;
-
- if (!this.file) {
- this.fileError = __('Please choose a file');
- } else if (this.file.size > MAX_FILE_SIZE) {
- this.fileError = __('Maximum file size is 2MB. Please select a smaller file.');
- }
- },
- },
-};
-</script>
-<template>
- <gl-form-group
- class="gl-mt-5 gl-mb-3"
- :label="$options.fileLabel"
- label-for="file-input"
- :state="!Boolean(fileError)"
- :invalid-feedback="fileError"
- >
- <input
- id="file-input"
- ref="fileInput"
- class="gl-mt-3 gl-mb-2"
- type="file"
- accept="image/*"
- @input="onInput"
- />
- </gl-form-group>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue
deleted file mode 100644
index 99bb2080610..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue
+++ /dev/null
@@ -1,91 +0,0 @@
-<script>
-import { GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
-import { isSafeURL } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import { YOUTUBE_URL, YOUTUBE_EMBED_URL } from '../constants';
-
-export default {
- components: {
- GlModal,
- GlFormGroup,
- GlFormInput,
- GlSprintf,
- },
- data() {
- return {
- url: null,
- urlError: null,
- description: __(
- 'If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}',
- ),
- };
- },
- modalTitle: __('Insert a video'),
- okTitle: __('Insert video'),
- label: __('YouTube URL or ID'),
- methods: {
- show() {
- this.urlError = null;
- this.url = null;
-
- this.$refs.modal.show();
- },
- onPrimary(event) {
- this.submitURL(event);
- },
- submitURL(event) {
- const url = this.generateUrl();
-
- if (!url) {
- event.preventDefault();
- return;
- }
-
- this.$emit('insertVideo', url);
- },
- generateUrl() {
- let { url } = this;
- const reYouTubeId = /^[A-z0-9]*$/;
- const reYouTubeUrl = RegExp(`${YOUTUBE_URL}/(embed/|watch\\?v=)([A-z0-9]+)`);
-
- if (reYouTubeId.test(url)) {
- url = `${YOUTUBE_EMBED_URL}/${url}`;
- } else if (reYouTubeUrl.test(url)) {
- url = `${YOUTUBE_EMBED_URL}/${reYouTubeUrl.exec(url)[2]}`;
- }
-
- if (!isSafeURL(url) || !reYouTubeUrl.test(url)) {
- this.urlError = __('Please provide a valid YouTube URL or ID');
- this.$refs.urlInput.$el.focus();
- return null;
- }
-
- return url;
- },
- },
-};
-</script>
-<template>
- <gl-modal
- ref="modal"
- size="sm"
- modal-id="insert-video-modal"
- :title="$options.modalTitle"
- :ok-title="$options.okTitle"
- @primary="onPrimary"
- >
- <gl-form-group
- :label="$options.label"
- label-for="video-modal-url-input"
- :state="!Boolean(urlError)"
- :invalid-feedback="urlError"
- >
- <gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" />
- <gl-sprintf slot="description" :message="description" class="text-gl-muted">
- <template #id>
- <strong>{{ __('0t1DgySidms') }}</strong>
- </template>
- </gl-sprintf>
- </gl-form-group>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
deleted file mode 100644
index 8988dab85d2..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
+++ /dev/null
@@ -1,150 +0,0 @@
-<script>
-import 'codemirror/lib/codemirror.css';
-import '@toast-ui/editor/dist/toastui-editor.css';
-
-import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants';
-import AddImageModal from './modals/add_image/add_image_modal.vue';
-import InsertVideoModal from './modals/insert_video_modal.vue';
-
-import {
- registerHTMLToMarkdownRenderer,
- getEditorOptions,
- addCustomEventListener,
- removeCustomEventListener,
- addImage,
- getMarkdown,
- insertVideo,
-} from './services/editor_service';
-
-export default {
- components: {
- ToastEditor: () =>
- import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
- (toast) => toast.Editor,
- ),
- AddImageModal,
- InsertVideoModal,
- },
- props: {
- content: {
- type: String,
- required: true,
- },
- options: {
- type: Object,
- required: false,
- default: () => null,
- },
- initialEditType: {
- type: String,
- required: false,
- default: EDITOR_TYPES.wysiwyg,
- },
- height: {
- type: String,
- required: false,
- default: EDITOR_HEIGHT,
- },
- previewStyle: {
- type: String,
- required: false,
- default: EDITOR_PREVIEW_STYLE,
- },
- imageRoot: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- editorApi: null,
- previousMode: null,
- };
- },
- computed: {
- editorInstance() {
- return this.$refs.editor;
- },
- customEventListeners() {
- return [
- { event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal },
- { event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal },
- ];
- },
- },
- created() {
- this.editorOptions = getEditorOptions(this.options);
- },
- beforeDestroy() {
- this.removeListeners();
- },
- methods: {
- addListeners(editorApi) {
- this.customEventListeners.forEach(({ event, listener }) => {
- addCustomEventListener(editorApi, event, listener);
- });
-
- editorApi.eventManager.listen('changeMode', this.onChangeMode);
- },
- removeListeners() {
- this.customEventListeners.forEach(({ event, listener }) => {
- removeCustomEventListener(this.editorApi, event, listener);
- });
-
- this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
- },
- resetInitialValue(newVal) {
- this.editorInstance.invoke('setMarkdown', newVal);
- },
- onContentChanged() {
- this.$emit('input', getMarkdown(this.editorInstance));
- },
- onLoad(editorApi) {
- this.editorApi = editorApi;
-
- registerHTMLToMarkdownRenderer(editorApi);
-
- this.addListeners(editorApi);
-
- this.$emit('load', { formattedMarkdown: editorApi.getMarkdown() });
- },
- onOpenAddImageModal() {
- this.$refs.addImageModal.show();
- },
- onAddImage({ imageUrl, altText, file }) {
- const image = { imageUrl, altText };
-
- if (file) {
- this.$emit('uploadImage', { file, imageUrl });
- }
-
- addImage(this.editorInstance, image, file);
- },
- onOpenInsertVideoModal() {
- this.$refs.insertVideoModal.show();
- },
- onInsertVideo(url) {
- insertVideo(this.editorInstance, url);
- },
- onChangeMode(newMode) {
- this.$emit('modeChange', newMode);
- },
- },
-};
-</script>
-<template>
- <div>
- <toast-editor
- ref="editor"
- :initial-value="content"
- :options="editorOptions"
- :preview-style="previewStyle"
- :initial-edit-type="initialEditType"
- :height="height"
- @change="onContentChanged"
- @load="onLoad"
- />
- <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
- <insert-video-modal ref="insertVideoModal" @insertVideo="onInsertVideo" />
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
deleted file mode 100644
index 6ffd280e005..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { union, mapValues } from 'lodash';
-import renderAttributeDefinition from './renderers/render_attribute_definition';
-import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
-import renderHeading from './renderers/render_heading';
-import renderBlockHtml from './renderers/render_html_block';
-import renderIdentifierInstanceText from './renderers/render_identifier_instance_text';
-import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
-import renderListItem from './renderers/render_list_item';
-import renderSoftbreak from './renderers/render_softbreak';
-
-const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
-const htmlBlockRenderers = [renderBlockHtml];
-const headingRenderers = [renderHeading];
-const paragraphRenderers = [renderIdentifierParagraph, renderBlockHtml];
-const textRenderers = [renderIdentifierInstanceText, renderAttributeDefinition];
-const listItemRenderers = [renderListItem];
-const softbreakRenderers = [renderSoftbreak];
-
-const executeRenderer = (renderers, node, context) => {
- const availableRenderer = renderers.find((renderer) => renderer.canRender(node, context));
-
- return availableRenderer ? availableRenderer.render(node, context) : context.origin();
-};
-
-const buildCustomHTMLRenderer = (customRenderers) => {
- const renderersByType = {
- ...customRenderers,
- htmlBlock: union(htmlBlockRenderers, customRenderers?.htmlBlock),
- htmlInline: union(htmlInlineRenderers, customRenderers?.htmlInline),
- heading: union(headingRenderers, customRenderers?.heading),
- item: union(listItemRenderers, customRenderers?.listItem),
- paragraph: union(paragraphRenderers, customRenderers?.paragraph),
- text: union(textRenderers, customRenderers?.text),
- softbreak: union(softbreakRenderers, customRenderers?.softbreak),
- };
-
- return mapValues(renderersByType, (renderers) => {
- return (node, context) => executeRenderer(renderers, node, context);
- });
-};
-
-export default buildCustomHTMLRenderer;
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
deleted file mode 100644
index 273e0a59963..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
+++ /dev/null
@@ -1,109 +0,0 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-import { defaults, repeat } from 'lodash';
-
-const DEFAULTS = {
- subListIndentSpaces: 4,
- unorderedListBulletChar: '-',
- incrementListMarker: false,
- strong: '*',
- emphasis: '_',
-};
-
-const countIndentSpaces = (text) => {
- const matches = text.match(/^\s+/m);
-
- return matches ? matches[0].length : 0;
-};
-
-const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => {
- const {
- subListIndentSpaces,
- unorderedListBulletChar,
- incrementListMarker,
- strong,
- emphasis,
- } = defaults(formattingPreferences, DEFAULTS);
- const sublistNode = 'LI OL, LI UL';
- const unorderedListItemNode = 'UL LI';
- const orderedListItemNode = 'OL LI';
- const emphasisNode = 'EM, I';
- const strongNode = 'STRONG, B';
- const headingNode = 'H1, H2, H3, H4, H5, H6';
- const preCodeNode = 'PRE CODE';
-
- return {
- TEXT_NODE(node) {
- return baseRenderer.getSpaceControlled(
- baseRenderer.trim(baseRenderer.getSpaceCollapsedText(node.nodeValue)),
- node,
- );
- },
- /*
- * This converter overwrites the default indented list converter
- * to allow us to parameterize the number of indent spaces for
- * sublists.
- *
- * See the original implementation in
- * https://github.com/nhn/tui.editor/blob/master/libs/to-mark/src/renderer.basic.js#L161
- */
- [sublistNode](node, subContent) {
- const baseResult = baseRenderer.convert(node, subContent);
- // Default to 1 to prevent possible divide by 0
- const firstLevelIndentSpacesCount = countIndentSpaces(baseResult) || 1;
- const reindentedList = baseResult
- .split('\n')
- .map((line) => {
- const itemIndentSpacesCount = countIndentSpaces(line);
- const nestingLevel = Math.ceil(itemIndentSpacesCount / firstLevelIndentSpacesCount);
- const indentSpaces = repeat(' ', subListIndentSpaces * nestingLevel);
-
- return line.replace(/^ +/, indentSpaces);
- })
- .join('\n');
-
- return reindentedList;
- },
- [unorderedListItemNode](node, subContent) {
- const baseResult = baseRenderer.convert(node, subContent);
- const formatted = baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`);
- const { attributeDefinition } = node.dataset;
-
- return attributeDefinition ? `${formatted.trimRight()}\n${attributeDefinition}\n` : formatted;
- },
- [orderedListItemNode](node, subContent) {
- const baseResult = baseRenderer.convert(node, subContent);
-
- return incrementListMarker ? baseResult : baseResult.replace(/^(\s*)\d+?\./, '$11.');
- },
- [emphasisNode](node, subContent) {
- const result = baseRenderer.convert(node, subContent);
-
- return result.replace(/(^[*_]{1}|[*_]{1}$)/g, emphasis);
- },
- [strongNode](node, subContent) {
- const result = baseRenderer.convert(node, subContent);
- const strongSyntax = repeat(strong, 2);
-
- return result.replace(/^[*_]{2}/, strongSyntax).replace(/[*_]{2}$/, strongSyntax);
- },
- [headingNode](node, subContent) {
- const result = baseRenderer.convert(node, subContent);
- const { attributeDefinition } = node.dataset;
-
- return attributeDefinition ? `${result.trimRight()}\n${attributeDefinition}\n\n` : result;
- },
- [preCodeNode](node, subContent) {
- const isReferenceDefinition = Boolean(node.dataset.sseReferenceDefinition);
-
- return isReferenceDefinition
- ? `\n\n${node.innerText}\n\n`
- : baseRenderer.convert(node, subContent);
- },
- IMG(node) {
- const { originalSrc } = node.dataset;
- return `![${node.alt}](${originalSrc || node.src})`;
- },
- };
-};
-
-export default buildHTMLToMarkdownRender;
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
deleted file mode 100644
index 026a4069d9b..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import { defaults } from 'lodash';
-import Vue from 'vue';
-import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants';
-import ToolbarItem from '../toolbar_item.vue';
-import buildCustomHTMLRenderer from './build_custom_renderer';
-import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
-import sanitizeHTML from './sanitize_html';
-
-const buildWrapper = (propsData) => {
- const instance = new Vue({
- render(createElement) {
- return createElement(ToolbarItem, propsData);
- },
- });
-
- instance.$mount();
- return instance.$el;
-};
-
-const buildVideoIframe = (src) => {
- const wrapper = document.createElement('figure');
- const iframe = document.createElement('iframe');
- const videoAttributes = { ...VIDEO_ATTRIBUTES, src };
- const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container'];
- const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full'];
-
- wrapper.setAttribute('contenteditable', 'false');
- wrapper.classList.add(...wrapperClasses);
- iframe.classList.add(...iframeClasses);
- Object.assign(iframe, videoAttributes);
-
- wrapper.appendChild(iframe);
-
- return wrapper;
-};
-
-const buildImg = (alt, originalSrc, file) => {
- const img = document.createElement('img');
- const src = file ? URL.createObjectURL(file) : originalSrc;
- const attributes = { alt, src };
-
- if (file) {
- img.dataset.originalSrc = originalSrc;
- }
-
- Object.assign(img, attributes);
-
- return img;
-};
-
-export const generateToolbarItem = (config) => {
- const { icon, classes, event, command, tooltip, isDivider } = config;
-
- if (isDivider) {
- return 'divider';
- }
-
- return {
- type: 'button',
- options: {
- el: buildWrapper({ props: { icon, tooltip }, class: classes }),
- event,
- command,
- },
- };
-};
-
-export const addCustomEventListener = (editorApi, event, handler) => {
- editorApi.eventManager.addEventType(event);
- editorApi.eventManager.listen(event, handler);
-};
-
-export const removeCustomEventListener = (editorApi, event, handler) =>
- editorApi.eventManager.removeEventHandler(event, handler);
-
-export const addImage = ({ editor }, { altText, imageUrl }, file) => {
- if (editor.isWysiwygMode()) {
- const img = buildImg(altText, imageUrl, file);
- editor.getSquire().insertElement(img);
- } else {
- editor.insertText(`![${altText}](${imageUrl})`);
- }
-};
-
-export const insertVideo = ({ editor }, url) => {
- const videoIframe = buildVideoIframe(url);
-
- if (editor.isWysiwygMode()) {
- editor.getSquire().insertElement(videoIframe);
- } else {
- editor.insertText(videoIframe.outerHTML);
- }
-};
-
-export const getMarkdown = (editorInstance) => editorInstance.invoke('getMarkdown');
-
-/**
- * This function allow us to extend Toast UI HTML to Markdown renderer. It is
- * a temporary measure because Toast UI does not provide an API
- * to achieve this goal.
- */
-export const registerHTMLToMarkdownRenderer = (editorApi) => {
- const { renderer } = editorApi.toMarkOptions;
-
- Object.assign(editorApi.toMarkOptions, {
- renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)),
- });
-};
-
-export const getEditorOptions = (externalOptions) => {
- return defaults({
- customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers),
- toolbarItems: TOOLBAR_ITEM_CONFIGS.map((toolbarItem) => generateToolbarItem(toolbarItem)),
- customHTMLSanitizer: (html) => sanitizeHTML(html),
- });
-};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
deleted file mode 100644
index 638e5fd6f60..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
+++ /dev/null
@@ -1,63 +0,0 @@
-const buildToken = (type, tagName, props) => {
- return { type, tagName, ...props };
-};
-
-const TAG_TYPES = {
- block: 'div',
- inline: 'a',
-};
-
-// Open helpers (singular and multiple)
-
-const buildUneditableOpenToken = (tagType = TAG_TYPES.block) =>
- buildToken('openTag', tagType, {
- attributes: { contenteditable: false },
- classNames: [
- 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
- ],
- });
-
-export const buildUneditableOpenTokens = (token, tagType = TAG_TYPES.block) => {
- return [buildUneditableOpenToken(tagType), token];
-};
-
-// Close helpers (singular and multiple)
-
-export const buildUneditableCloseToken = (tagType = TAG_TYPES.block) =>
- buildToken('closeTag', tagType);
-
-export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => {
- return [token, buildUneditableCloseToken(tagType)];
-};
-
-// Complete helpers (open plus close)
-
-export const buildTextToken = (content) => buildToken('text', null, { content });
-
-export const buildUneditableBlockTokens = (token) => {
- return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()];
-};
-
-export const buildUneditableInlineTokens = (token) => {
- return [
- ...buildUneditableOpenTokens(token, TAG_TYPES.inline),
- buildUneditableCloseToken(TAG_TYPES.inline),
- ];
-};
-
-export const buildUneditableHtmlAsTextTokens = (node) => {
- /*
- Toast UI internally appends ' data-tomark-pass ' attribute flags so it can target certain
- nested nodes for internal use during Markdown <=> WYSIWYG conversions. In our case, we want
- to prevent HTML being rendered completely in WYSIWYG mode and thus we use a `text` vs. `html`
- type when building the token. However, in doing so, we need to strip out the ` data-tomark-pass `
- to prevent their persistence within the `text` content as the user did not intend these as edits.
-
- https://github.com/nhn/tui.editor/blob/cc54ec224fc3a4b6e5a2b19a71650959f41adc0e/apps/editor/src/js/convertor.js#L72
- */
- const regex = / data-tomark-pass /gm;
- const content = node.literal.replace(regex, '');
- const htmlAsTextToken = buildToken('text', null, { content });
-
- return [buildUneditableOpenToken(), htmlAsTextToken, buildUneditableCloseToken()];
-};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition.js
deleted file mode 100644
index bd419447a48..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { isAttributeDefinition } from './render_utils';
-
-const canRender = ({ literal }) => isAttributeDefinition(literal);
-
-const render = () => ({ type: 'html', content: '<!-- sse-attribute-definition -->' });
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js
deleted file mode 100644
index 0e122f598e5..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import { renderUneditableLeaf as render } from './render_utils';
-
-const embeddedRubyRegex = /(^<%.+%>$)/;
-
-const canRender = ({ literal }) => {
- return embeddedRubyRegex.test(literal);
-};
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
deleted file mode 100644
index 572f6e3cf9d..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { buildUneditableInlineTokens } from './build_uneditable_token';
-
-const fontAwesomeRegexOpen = /<i class="fa.+>/;
-
-const canRender = ({ literal }) => {
- return fontAwesomeRegexOpen.test(literal);
-};
-
-const render = (_, { origin }) => buildUneditableInlineTokens(origin());
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_heading.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_heading.js
deleted file mode 100644
index 71026fd0d65..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_heading.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import {
- renderWithAttributeDefinitions as render,
- willAlwaysRender as canRender,
-} from './render_utils';
-
-export default { render, canRender };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
deleted file mode 100644
index 710b807275b..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { getURLOrigin } from '~/lib/utils/url_utility';
-import { ALLOWED_VIDEO_ORIGINS } from '../../constants';
-import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
-
-const isVideoFrame = (html) => {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- const {
- children: { length },
- } = doc;
- const iframe = doc.querySelector('iframe');
- const origin = iframe && getURLOrigin(iframe.getAttribute('src'));
-
- return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin);
-};
-
-const canRender = ({ type, literal }) => {
- return type === 'htmlBlock' && !isVideoFrame(literal);
-};
-
-const render = (node) => buildUneditableHtmlAsTextTokens(node);
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js
deleted file mode 100644
index d7716543b53..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token';
-
-/*
-Use case examples:
-- Majority: two bracket pairs, back-to-back, each with content (including spaces)
- - `[environment terraform plans][terraform]`
- - `[an issue labelled `~"master:broken"`][broken-master-issues]`
-- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces)
- - `[this link][]`
- - `[this link]`
-
-Regexp notes:
- - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces)
- - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces)
- - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`)
- - Each of the three parts is non-captured, but the match as a whole is captured
-*/
-const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g;
-
-const isIdentifierInstance = (literal) => {
- // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448)
- identifierInstanceRegex.lastIndex = 0;
- return identifierInstanceRegex.test(literal);
-};
-
-const canRender = ({ literal }) => isIdentifierInstance(literal);
-
-const tokenize = (text) => {
- const matches = text.split(identifierInstanceRegex);
- const tokens = matches.map((match) => {
- const token = buildTextToken(match);
- return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token;
- });
-
- return tokens.flat();
-};
-
-const render = (_, { origin }) => tokenize(origin().content);
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
deleted file mode 100644
index 4829f0f2243..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const identifierRegex = /(^\[.+\]: .+)/;
-
-const isIdentifier = (text) => {
- return identifierRegex.test(text);
-};
-
-const canRender = (node, context) => {
- return isIdentifier(context.getChildrenText(node));
-};
-
-const getReferenceDefinitions = (node, definitions = '') => {
- if (!node) {
- return definitions;
- }
-
- const definition = node.type === 'text' ? node.literal : '\n';
-
- return getReferenceDefinitions(node.next, `${definitions}${definition}`);
-};
-
-const render = (node, { skipChildren }) => {
- const content = getReferenceDefinitions(node.firstChild);
-
- skipChildren();
-
- return [
- {
- type: 'openTag',
- tagName: 'pre',
- classNames: ['code-block', 'language-markdown'],
- attributes: { 'data-sse-reference-definition': true },
- },
- { type: 'openTag', tagName: 'code' },
- { type: 'text', content },
- { type: 'closeTag', tagName: 'code' },
- { type: 'closeTag', tagName: 'pre' },
- ];
-};
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_list_item.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_list_item.js
deleted file mode 100644
index 71026fd0d65..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_list_item.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import {
- renderWithAttributeDefinitions as render,
- willAlwaysRender as canRender,
-} from './render_utils';
-
-export default { render, canRender };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js
deleted file mode 100644
index c004e839821..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const canRender = (node) => ['emph', 'strong'].includes(node.parent?.type);
-const render = () => ({
- type: 'text',
- content: ' ',
-});
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js
deleted file mode 100644
index eff5dbf59f2..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import {
- buildUneditableBlockTokens,
- buildUneditableOpenTokens,
- buildUneditableCloseToken,
-} from './build_uneditable_token';
-
-export const renderUneditableLeaf = (_, { origin }) => buildUneditableBlockTokens(origin());
-
-export const renderUneditableBranch = (_, { entering, origin }) =>
- entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
-
-const attributeDefinitionRegexp = /(^{:.+}$)/;
-
-export const isAttributeDefinition = (text) => attributeDefinitionRegexp.test(text);
-
-const findAttributeDefinition = (node) => {
- const literal =
- node?.next?.firstChild?.literal || node?.firstChild?.firstChild?.next?.next?.literal; // for headings // for list items;
-
- return isAttributeDefinition(literal) ? literal : null;
-};
-
-export const renderWithAttributeDefinitions = (node, { origin }) => {
- const attributes = findAttributeDefinition(node);
- const token = origin();
-
- if (token.type === 'openTag' && attributes) {
- Object.assign(token, {
- attributes: {
- 'data-attribute-definition': attributes,
- },
- });
- }
-
- return token;
-};
-
-export const willAlwaysRender = () => true;
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js
deleted file mode 100644
index 486d88466b7..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/sanitize_html.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import createSanitizer from 'dompurify';
-import { getURLOrigin } from '~/lib/utils/url_utility';
-import { ALLOWED_VIDEO_ORIGINS } from '../constants';
-
-const sanitizer = createSanitizer(window);
-const ADD_TAGS = ['iframe'];
-
-sanitizer.addHook('uponSanitizeElement', (node) => {
- if (node.tagName !== 'IFRAME') {
- return;
- }
-
- const origin = getURLOrigin(node.getAttribute('src'));
-
- if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) {
- node.remove();
- }
-});
-
-const sanitize = (content) => sanitizer.sanitize(content, { ADD_TAGS });
-
-export default sanitize;
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
deleted file mode 100644
index 85a67c087bb..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- icon: {
- type: String,
- required: true,
- },
- tooltip: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-<template>
- <button
- v-gl-tooltip="{ title: tooltip }"
- :aria-label="tooltip"
- class="p-0 gl-display-flex toolbar-button"
- >
- <gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" />
- </button>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
new file mode 100644
index 00000000000..46361c6eb32
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
@@ -0,0 +1,49 @@
+import { s__, sprintf } from '~/locale';
+
+export const EXPERIMENT_NAME = 'ci_runner_templates';
+
+export const README_URL =
+ 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md';
+
+export const CF_BASE_URL =
+ 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?';
+
+export const TEMPLATES_BASE_URL = 'https://gl-public-templates.s3.amazonaws.com/cfn/experimental/';
+
+export const EASY_BUTTONS = [
+ {
+ stackName: 'linux-docker-nonspot',
+ templateName:
+ 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml',
+ description: s__(
+ 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. Default choice for Linux Docker executor.',
+ ),
+ },
+ {
+ stackName: 'linux-docker-spotonly',
+ templateName: 'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-spotonly.cf.yml',
+ description: sprintf(
+ s__(
+ 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. %{percentage} spot.',
+ ),
+ { percentage: '100%' },
+ ),
+ },
+ {
+ stackName: 'win2019-shell-non-spot',
+ templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml',
+ description: s__(
+ 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor.',
+ ),
+ },
+ {
+ stackName: 'win2019-shell-spot',
+ templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-spotonly.cf.yml',
+ description: sprintf(
+ s__(
+ 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. %{percentage} spot.',
+ ),
+ { percentage: '100%' },
+ ),
+ },
+];
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue
new file mode 100644
index 00000000000..e3e3b9abc3c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import RunnerAwsDeploymentsModal from './runner_aws_deployments_modal.vue';
+
+export default {
+ components: {
+ GlButton,
+ RunnerAwsDeploymentsModal,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ modalId: 'runner-aws-deployments-modal',
+ i18n: {
+ buttonText: s__('Runners|Deploy GitLab Runner in AWS'),
+ },
+ data() {
+ return {
+ opened: false,
+ };
+ },
+ methods: {
+ onClick() {
+ this.opened = true;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ class="gl-mt-4"
+ data-testid="show-modal-button"
+ variant="confirm"
+ @click="onClick"
+ >
+ {{ $options.i18n.buttonText }}
+ </gl-button>
+ <runner-aws-deployments-modal v-if="opened" :modal-id="$options.modalId" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
new file mode 100644
index 00000000000..f21dea468cb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { getBaseURL, objectToQuery } from '~/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
+import {
+ EXPERIMENT_NAME,
+ README_URL,
+ CF_BASE_URL,
+ TEMPLATES_BASE_URL,
+ EASY_BUTTONS,
+} from './constants';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ easyButtonUrl(easyButton) {
+ const params = {
+ templateURL: TEMPLATES_BASE_URL + easyButton.templateName,
+ stackName: easyButton.stackName,
+ param_3GITLABRunnerInstanceURL: getBaseURL(),
+ };
+ return CF_BASE_URL + objectToQuery(params);
+ },
+ trackCiRunnerTemplatesClick(stackName) {
+ const tracking = new ExperimentTracking(EXPERIMENT_NAME);
+ tracking.event(`template_clicked_${stackName}`);
+ },
+ },
+ i18n: {
+ title: s__('Runners|Deploy GitLab Runner in AWS'),
+ instructions: s__(
+ 'Runners|For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet.',
+ ),
+ dont_see_what_you_are_looking_for: s__(
+ "Rnners|Don't see what you are looking for? See the full list of options, including a fully customizable option, %{linkStart}here%{linkEnd}.",
+ ),
+ note: s__(
+ 'Runners|If you do not select an AWS VPC, the runner will deploy to the Default VPC in the AWS Region you select. Please consult with your AWS administrator to understand if there are any security risks to deploying into the Default VPC in any given region in your AWS account.',
+ ),
+ },
+ closeButton: {
+ text: __('Cancel'),
+ attributes: [{ variant: 'default' }],
+ },
+ readmeUrl: README_URL,
+ easyButtons: EASY_BUTTONS,
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.i18n.title"
+ :action-secondary="$options.closeButton"
+ size="sm"
+ >
+ <p>{{ $options.i18n.instructions }}</p>
+ <ul class="gl-list-style-none gl-p-0 gl-mb-0">
+ <li v-for="easyButton in $options.easyButtons" :key="easyButton.templateName">
+ <gl-link
+ :href="easyButtonUrl(easyButton)"
+ target="_blank"
+ class="gl-display-flex gl-font-weight-bold"
+ @click="trackCiRunnerTemplatesClick(easyButton.stackName)"
+ >
+ <img
+ :title="easyButton.stackName"
+ :alt="easyButton.stackName"
+ src="/assets/aws-cloud-formation.png"
+ width="46"
+ height="46"
+ class="gl-mt-2 gl-mr-5 gl-mb-6"
+ />
+ {{ easyButton.description }}
+ </gl-link>
+ </li>
+ </ul>
+ <p>
+ <gl-sprintf :message="$options.i18n.dont_see_what_you_are_looking_for">
+ <template #link="{ content }">
+ <gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-font-sm gl-mb-0">{{ $options.i18n.note }}</p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index 1f70644eb2c..580e1668f41 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -225,7 +225,7 @@ export default {
<template v-if="!instructionsEmpty">
<div class="gl-display-flex">
<pre
- class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
+ class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line"
data-testid="binary-instructions"
>{{ instructions.installInstructions }}</pre
>
@@ -241,7 +241,7 @@ export default {
<h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5>
<div class="gl-display-flex">
<pre
- class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
+ class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line"
data-testid="register-command"
>{{ instructions.registerInstructions }}</pre
>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
deleted file mode 100644
index 88c4d132d61..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ /dev/null
@@ -1,191 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import $ from 'jquery';
-import LabelsSelect from '~/labels_select';
-import { __ } from '~/locale';
-import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
-
-import { DropdownVariant } from '../labels_select_vue/constants';
-import DropdownButton from './dropdown_button.vue';
-import DropdownCreateLabel from './dropdown_create_label.vue';
-import DropdownFooter from './dropdown_footer.vue';
-import DropdownHeader from './dropdown_header.vue';
-import DropdownSearchInput from './dropdown_search_input.vue';
-import DropdownTitle from './dropdown_title.vue';
-import DropdownValue from './dropdown_value.vue';
-import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
-
-export default {
- DropdownVariant,
- components: {
- DropdownTitle,
- DropdownValue,
- DropdownValueCollapsed,
- DropdownButton,
- DropdownHiddenInput,
- DropdownHeader,
- DropdownSearchInput,
- DropdownFooter,
- DropdownCreateLabel,
- GlLoadingIcon,
- },
- props: {
- showCreate: {
- type: Boolean,
- required: false,
- default: false,
- },
- isProject: {
- type: Boolean,
- required: false,
- default: false,
- },
- abilityName: {
- type: String,
- required: true,
- },
- context: {
- type: Object,
- required: true,
- },
- namespace: {
- type: String,
- required: false,
- default: '',
- },
- updatePath: {
- type: String,
- required: false,
- default: '',
- },
- labelsPath: {
- type: String,
- required: true,
- },
- labelsWebUrl: {
- type: String,
- required: false,
- default: '',
- },
- labelFilterBasePath: {
- type: String,
- required: false,
- default: '',
- },
- canEdit: {
- type: Boolean,
- required: false,
- default: false,
- },
- enableScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
- variant: {
- type: String,
- required: false,
- default: DropdownVariant.Sidebar,
- },
- },
- computed: {
- hiddenInputName() {
- return this.showCreate ? `${this.abilityName}[label_names][]` : 'label_id[]';
- },
- createLabelTitle() {
- if (this.isProject) {
- return __('Create project label');
- }
-
- return __('Create group label');
- },
- manageLabelsTitle() {
- if (this.isProject) {
- return __('Manage project labels');
- }
-
- return __('Manage group labels');
- },
- },
- mounted() {
- this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, {
- handleClick: this.handleClick,
- });
- $(this.$refs.dropdown).on('hidden.gl.dropdown', this.handleDropdownHidden);
- },
- methods: {
- handleClick(label) {
- this.$emit('onLabelClick', label);
- },
- handleCollapsedValueClick() {
- this.$emit('toggleCollapse');
- },
- handleDropdownHidden() {
- this.$emit('onDropdownClose');
- },
- },
-};
-</script>
-
-<template>
- <div class="block labels js-labels-block">
- <dropdown-value-collapsed
- v-if="showCreate && variant === $options.DropdownVariant.Sidebar"
- :labels="context.labels"
- @onValueClick="handleCollapsedValueClick"
- />
- <dropdown-title :can-edit="canEdit" />
- <dropdown-value
- :labels="context.labels"
- :label-filter-base-path="labelFilterBasePath"
- :enable-scoped-labels="enableScopedLabels"
- >
- <slot></slot>
- </dropdown-value>
- <div v-if="canEdit" class="selectbox js-selectbox" style="display: none">
- <dropdown-hidden-input
- v-for="label in context.labels"
- :key="label.id"
- :name="hiddenInputName"
- :value="label.id"
- />
- <div ref="dropdown" class="dropdown">
- <dropdown-button
- :ability-name="abilityName"
- :field-name="hiddenInputName"
- :update-path="updatePath"
- :labels-path="labelsPath"
- :namespace="namespace"
- :labels="context.labels"
- :show-extra-options="!showCreate || variant !== $options.DropdownVariant.Sidebar"
- :enable-scoped-labels="enableScopedLabels"
- />
- <div
- class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable"
- >
- <div class="dropdown-page-one">
- <dropdown-header v-if="showCreate && variant === $options.DropdownVariant.Sidebar" />
- <dropdown-search-input />
- <div class="dropdown-content" data-qa-selector="labels_dropdown_content"></div>
- <div class="dropdown-loading">
- <gl-loading-icon
- class="gl-display-flex gl-justify-content-center gl-align-items-center gl-h-full"
- />
- </div>
- <dropdown-footer
- v-if="showCreate"
- :labels-web-url="labelsWebUrl"
- :create-label-title="createLabelTitle"
- :manage-labels-title="manageLabelsTitle"
- />
- </div>
- <dropdown-create-label
- v-if="showCreate"
- :is-project="isProject"
- :header-title="createLabelTitle"
- />
- </div>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
deleted file mode 100644
index 94cf1f84ec3..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ /dev/null
@@ -1,86 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import { __, s__, sprintf } from '~/locale';
-
-export default {
- components: {
- GlIcon,
- },
- props: {
- abilityName: {
- type: String,
- required: true,
- },
- fieldName: {
- type: String,
- required: true,
- },
- updatePath: {
- type: String,
- required: true,
- },
- labelsPath: {
- type: String,
- required: true,
- },
- namespace: {
- type: String,
- required: true,
- },
- labels: {
- type: Array,
- required: true,
- },
- showExtraOptions: {
- type: Boolean,
- required: true,
- },
- enableScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- dropdownToggleText() {
- if (this.labels.length === 0) {
- return __('Label');
- }
-
- if (this.labels.length > 1) {
- return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
- firstLabelName: this.labels[0].title,
- remainingLabelCount: this.labels.length - 1,
- });
- }
-
- return this.labels[0].title;
- },
- },
-};
-</script>
-
-<template>
- <!-- eslint-disable @gitlab/vue-no-data-toggle -->
- <button
- ref="dropdownButton"
- :class="{ 'js-extra-options': showExtraOptions }"
- :data-ability-name="abilityName"
- :data-field-name="fieldName"
- :data-issue-update="updatePath"
- :data-labels="labelsPath"
- :data-namespace-path="namespace"
- :data-show-any="showExtraOptions"
- :data-scoped-labels="enableScopedLabels"
- type="button"
- class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
- data-toggle="dropdown"
- >
- <span class="dropdown-toggle-text"> {{ dropdownToggleText }} </span>
- <gl-icon
- name="chevron-down"
- class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
- :size="16"
- />
- </button>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue
deleted file mode 100644
index 795f16f4efc..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- headerTitle: {
- type: String,
- required: false,
- default: () => __('Create new label'),
- },
- },
- created() {
- const rawLabelsColors = gon.suggested_label_colors;
- this.suggestedColors = Object.keys(rawLabelsColors).map((colorCode) => ({
- colorCode,
- title: rawLabelsColors[colorCode],
- }));
- },
-};
-</script>
-
-<template>
- <div class="dropdown-page-two dropdown-new-label">
- <div
- class="dropdown-title gl-display-flex gl-justify-content-space-between gl-align-items-center"
- >
- <gl-button
- :aria-label="__('Go back')"
- category="tertiary"
- class="dropdown-menu-back"
- icon="arrow-left"
- size="small"
- />
- {{ headerTitle }}
- <gl-button
- :aria-label="__('Close')"
- category="tertiary"
- class="dropdown-menu-close"
- icon="close"
- size="small"
- />
- </div>
- <div class="dropdown-content">
- <div class="dropdown-labels-error js-label-error"></div>
- <input
- id="new_label_name"
- :placeholder="__('Name new label')"
- type="text"
- class="default-dropdown-input"
- />
- <div class="suggest-colors suggest-colors-dropdown">
- <a
- v-for="(color, index) in suggestedColors"
- :key="index"
- v-gl-tooltip
- :data-color="color.colorCode"
- :style="{
- backgroundColor: color.colorCode,
- }"
- :title="color.title"
- href="#"
- >
- &nbsp;
- </a>
- </div>
- <div class="dropdown-label-color-input">
- <div class="dropdown-label-color-preview js-dropdown-label-color-preview"></div>
- <input
- id="new_label_color"
- :placeholder="__('Assign custom color like #FF0000')"
- type="text"
- class="default-dropdown-input"
- />
- </div>
- <div class="clearfix">
- <gl-button category="secondary" class="float-left js-new-label-btn disabled">
- {{ __('Create') }}
- </gl-button>
- <gl-button category="secondary" class="float-right js-cancel-label-btn">
- {{ __('Cancel') }}
- </gl-button>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue
deleted file mode 100644
index ebbd8d119b5..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue
+++ /dev/null
@@ -1,37 +0,0 @@
-<script>
-import { __ } from '~/locale';
-
-export default {
- props: {
- labelsWebUrl: {
- type: String,
- required: true,
- },
- createLabelTitle: {
- type: String,
- required: false,
- default: () => __('Create new label'),
- },
- manageLabelsTitle: {
- type: String,
- required: false,
- default: () => __('Manage labels'),
- },
- },
-};
-</script>
-
-<template>
- <div class="dropdown-footer">
- <ul class="dropdown-footer-list">
- <li>
- <a href="#" class="dropdown-toggle-page"> {{ createLabelTitle }} </a>
- </li>
- <li>
- <a :href="labelsWebUrl" data-is-link="true" class="dropdown-external-link">
- {{ manageLabelsTitle }}
- </a>
- </li>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
deleted file mode 100644
index 4f505b9e678..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
+++ /dev/null
@@ -1,22 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- },
-};
-</script>
-
-<template>
- <div class="dropdown-title gl-display-flex gl-justify-content-center">
- <span class="gl-ml-auto">{{ __('Assign labels') }}</span>
- <button
- :aria-label="__('Close')"
- type="button"
- class="dropdown-title-button dropdown-menu-close gl-ml-auto"
- >
- <gl-icon name="close" class="dropdown-menu-close-icon" />
- </button>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
deleted file mode 100644
index 6222dfc5853..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- },
-};
-</script>
-
-<template>
- <div class="dropdown-input">
- <input
- :placeholder="__('Search')"
- autocomplete="off"
- class="dropdown-input-field"
- type="search"
- />
- <gl-icon
- name="search"
- class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-300 gl-pointer-events-none"
- />
- <gl-icon
- name="close"
- class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-500"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
deleted file mode 100644
index 91cf5d6bef5..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlLoadingIcon,
- },
- props: {
- canEdit: {
- type: Boolean,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div class="title hide-collapsed gl-mb-3">
- {{ __('Labels') }}
- <template v-if="canEdit">
- <gl-loading-icon inline class="align-text-top block-loading" />
- <button
- type="button"
- class="edit-link btn btn-blank float-right js-sidebar-dropdown-toggle"
- data-qa-selector="labels_edit_button"
- >
- {{ __('Edit') }}
- </button>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
deleted file mode 100644
index 71d7069dd57..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-import { GlLabel } from '@gitlab/ui';
-import { isScopedLabel } from '~/lib/utils/common_utils';
-
-export default {
- components: {
- GlLabel,
- },
- props: {
- labels: {
- type: Array,
- required: true,
- },
- labelFilterBasePath: {
- type: String,
- required: true,
- },
- enableScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- isEmpty() {
- return this.labels.length === 0;
- },
- },
- methods: {
- labelFilterUrl(label) {
- return `${this.labelFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
- },
- scopedLabelsDescription({ description = '' }) {
- return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`;
- },
- showScopedLabels(label) {
- return this.enableScopedLabels && isScopedLabel(label);
- },
- },
-};
-</script>
-
-<template>
- <div
- :class="{
- 'has-labels': !isEmpty,
- }"
- class="hide-collapsed value issuable-show-labels js-value"
- >
- <span v-if="isEmpty" class="text-secondary">
- <slot>{{ __('None') }}</slot>
- </span>
-
- <template v-for="label in labels" v-else>
- <gl-label
- :key="label.id"
- :target="labelFilterUrl(label)"
- :background-color="label.color"
- :title="label.title"
- :description="label.description"
- :scoped="showScopedLabels(label)"
- />
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
index 5d1663bc1fd..813de528c0b 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
@@ -23,17 +23,18 @@ export default {
</script>
<template>
- <div class="title hide-collapsed gl-mb-3">
+ <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900">
{{ __('Labels') }}
<template v-if="allowLabelEdit">
<gl-loading-icon v-show="labelsSelectInProgress" inline />
<gl-button
variant="link"
- class="float-right js-sidebar-dropdown-toggle"
+ class="float-right gl-text-gray-900! gl-hover-text-blue-800! js-sidebar-dropdown-toggle"
data-qa-selector="labels_edit_button"
@click="toggleDropdownContents"
- >{{ __('Edit') }}</gl-button
>
+ {{ __('Edit') }}
+ </gl-button>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
index 122250d1ce7..122250d1ce7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index a4462895f6a..87af3ffc52c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -5,13 +5,12 @@ import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
-
import { DropdownVariant } from './constants';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
+import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import labelsSelectModule from './store';
Vue.use(Vuex);
@@ -61,6 +60,11 @@ export default {
required: false,
default: () => [],
},
+ hideCollapsedView: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
labelsSelectInProgress: {
type: Boolean,
required: false,
@@ -294,6 +298,7 @@ export default {
>
<template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed
+ v-if="!hideCollapsedView"
ref="dropdownButtonCollapsed"
:labels="selectedLabels"
@onValueClick="handleCollapsedValueClick"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
new file mode 100644
index 00000000000..00c54313292
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
@@ -0,0 +1,5 @@
+export const DropdownVariant = {
+ Sidebar: 'sidebar',
+ Standalone: 'standalone',
+ Embedded: 'embedded',
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue
new file mode 100644
index 00000000000..60111210f5d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ computed: {
+ ...mapGetters([
+ 'dropdownButtonText',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents']),
+ handleButtonClick(e) {
+ if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
+ this.toggleDropdownContents();
+ }
+
+ if (this.isDropdownVariantStandalone) {
+ e.stopPropagation();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ class="labels-select-dropdown-button js-dropdown-button w-100 text-left"
+ @click="handleButtonClick"
+ >
+ <span class="dropdown-toggle-text gl-pointer-events-none flex-fill">
+ {{ dropdownButtonText }}
+ </span>
+ <gl-icon name="chevron-down" class="gl-pointer-events-none float-right" />
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
new file mode 100644
index 00000000000..d80b66fd9be
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -0,0 +1,44 @@
+<script>
+import { mapGetters, mapState } from 'vuex';
+
+import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
+import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
+
+export default {
+ components: {
+ DropdownContentsLabelsView,
+ DropdownContentsCreateView,
+ },
+ props: {
+ renderOnTop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState(['showDropdownContentsCreateView']),
+ ...mapGetters(['isDropdownVariantSidebar']),
+ dropdownContentsView() {
+ if (this.showDropdownContentsCreateView) {
+ return 'dropdown-contents-create-view';
+ }
+ return 'dropdown-contents-labels-view';
+ },
+ directionStyle() {
+ const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem';
+ return this.renderOnTop ? { bottom } : {};
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute"
+ data-qa-selector="labels_dropdown_content"
+ :style="directionStyle"
+ >
+ <component :is="dropdownContentsView" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
new file mode 100644
index 00000000000..f8cc981ba3d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+
+export default {
+ components: {
+ GlButton,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ data() {
+ return {
+ labelTitle: '',
+ selectedColor: '',
+ };
+ },
+ computed: {
+ ...mapState(['labelsCreateTitle', 'labelCreateInProgress']),
+ disableCreate() {
+ return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress;
+ },
+ suggestedColors() {
+ const colorsMap = gon.suggested_label_colors;
+ return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
+ },
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents', 'toggleDropdownContentsCreateView', 'createLabel']),
+ getColorCode(color) {
+ return Object.keys(color).pop();
+ },
+ getColorName(color) {
+ return Object.values(color).pop();
+ },
+ handleColorClick(color) {
+ this.selectedColor = this.getColorCode(color);
+ },
+ handleCreateClick() {
+ this.createLabel({
+ title: this.labelTitle,
+ color: this.selectedColor,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="labels-select-contents-create js-labels-create">
+ <div class="dropdown-title d-flex align-items-center pt-0 pb-2">
+ <gl-button
+ :aria-label="__('Go back')"
+ variant="link"
+ size="small"
+ class="js-btn-back dropdown-header-button p-0"
+ icon="arrow-left"
+ @click="toggleDropdownContentsCreateView"
+ />
+ <span class="flex-grow-1">{{ labelsCreateTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button p-0"
+ icon="close"
+ @click="toggleDropdownContents"
+ />
+ </div>
+ <div class="dropdown-input">
+ <gl-form-input
+ v-model.trim="labelTitle"
+ :placeholder="__('Name new label')"
+ :autofocus="true"
+ />
+ </div>
+ <div class="dropdown-content px-2">
+ <div class="suggest-colors suggest-colors-dropdown mt-0 mb-2">
+ <gl-link
+ v-for="(color, index) in suggestedColors"
+ :key="index"
+ v-gl-tooltip:tooltipcontainer
+ :style="{ backgroundColor: getColorCode(color) }"
+ :title="getColorName(color)"
+ @click.prevent="handleColorClick(color)"
+ />
+ </div>
+ <div class="color-input-container gl-display-flex">
+ <span
+ class="dropdown-label-color-preview position-relative position-relative d-inline-block"
+ :style="{ backgroundColor: selectedColor }"
+ ></span>
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ :placeholder="__('Use custom color #FF0000')"
+ />
+ </div>
+ </div>
+ <div class="dropdown-actions clearfix pt-2 px-2">
+ <gl-button
+ :disabled="disableCreate"
+ category="primary"
+ variant="success"
+ class="float-left d-flex align-items-center"
+ @click="handleCreateClick"
+ >
+ <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
+ {{ __('Create') }}
+ </gl-button>
+ <gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
new file mode 100644
index 00000000000..86788a84260
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -0,0 +1,221 @@
+<script>
+import {
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ GlButton,
+ GlSearchBoxByType,
+ GlLink,
+} from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { mapState, mapGetters, mapActions } from 'vuex';
+
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+
+import LabelItem from './label_item.vue';
+
+export default {
+ components: {
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ GlButton,
+ GlSearchBoxByType,
+ GlLink,
+ LabelItem,
+ },
+ data() {
+ return {
+ searchKey: '',
+ currentHighlightItem: -1,
+ };
+ },
+ computed: {
+ ...mapState([
+ 'allowLabelCreate',
+ 'allowMultiselect',
+ 'labelsManagePath',
+ 'labels',
+ 'labelsFetchInProgress',
+ 'labelsListTitle',
+ 'footerCreateLabelTitle',
+ 'footerManageLabelTitle',
+ ]),
+ ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
+ visibleLabels() {
+ if (this.searchKey) {
+ return fuzzaldrinPlus.filter(this.labels, this.searchKey, {
+ key: ['title'],
+ });
+ }
+ return this.labels;
+ },
+ showNoMatchingResultsMessage() {
+ return Boolean(this.searchKey) && this.visibleLabels.length === 0;
+ },
+ },
+ watch: {
+ searchKey(value) {
+ // When there is search string present
+ // and there are matching results,
+ // highlight first item by default.
+ if (value && this.visibleLabels.length) {
+ this.currentHighlightItem = 0;
+ }
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'toggleDropdownContents',
+ 'toggleDropdownContentsCreateView',
+ 'fetchLabels',
+ 'receiveLabelsSuccess',
+ 'updateSelectedLabels',
+ 'toggleDropdownContents',
+ ]),
+ isLabelSelected(label) {
+ return this.selectedLabelsList.includes(label.id);
+ },
+ /**
+ * This method scrolls item from dropdown into
+ * the view if it is off the viewable area of the
+ * container.
+ */
+ scrollIntoViewIfNeeded() {
+ const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused');
+
+ if (highlightedLabel) {
+ const container = this.$refs.labelsListContainer.getBoundingClientRect();
+ const label = highlightedLabel.getBoundingClientRect();
+
+ if (label.bottom > container.bottom) {
+ this.$refs.labelsListContainer.scrollTop += label.bottom - container.bottom;
+ } else if (label.top < container.top) {
+ this.$refs.labelsListContainer.scrollTop -= container.top - label.top;
+ }
+ }
+ },
+ handleComponentAppear() {
+ // We can avoid putting `catch` block here
+ // as failure is handled within actions.js already.
+ return this.fetchLabels().then(() => {
+ this.$refs.searchInput.focusInput();
+ });
+ },
+ /**
+ * We want to remove loaded labels to ensure component
+ * fetches fresh set of labels every time when shown.
+ */
+ handleComponentDisappear() {
+ this.receiveLabelsSuccess([]);
+ },
+ handleCreateLabelClick() {
+ this.receiveLabelsSuccess([]);
+ this.toggleDropdownContentsCreateView();
+ },
+ /**
+ * This method enables keyboard navigation support for
+ * the dropdown.
+ */
+ handleKeyDown(e) {
+ if (e.keyCode === UP_KEY_CODE && this.currentHighlightItem > 0) {
+ this.currentHighlightItem -= 1;
+ } else if (
+ e.keyCode === DOWN_KEY_CODE &&
+ this.currentHighlightItem < this.visibleLabels.length - 1
+ ) {
+ this.currentHighlightItem += 1;
+ } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
+ this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]);
+ this.searchKey = '';
+ } else if (e.keyCode === ESC_KEY_CODE) {
+ this.toggleDropdownContents();
+ }
+
+ if (e.keyCode !== ESC_KEY_CODE) {
+ // Scroll the list only after highlighting
+ // styles are rendered completely.
+ this.$nextTick(() => {
+ this.scrollIntoViewIfNeeded();
+ });
+ }
+ },
+ handleLabelClick(label) {
+ this.updateSelectedLabels([label]);
+ if (!this.allowMultiselect) this.toggleDropdownContents();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
+ <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-title"
+ >
+ <span class="flex-grow-1">{{ labelsListTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button gl-p-0!"
+ icon="close"
+ @click="toggleDropdownContents"
+ />
+ </div>
+ <div class="dropdown-input" @click.stop="() => {}">
+ <gl-search-box-by-type
+ ref="searchInput"
+ v-model="searchKey"
+ :disabled="labelsFetchInProgress"
+ data-qa-selector="dropdown_input_field"
+ />
+ </div>
+ <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content">
+ <gl-loading-icon
+ v-if="labelsFetchInProgress"
+ class="labels-fetch-loading gl-align-items-center w-100 h-100"
+ size="md"
+ />
+ <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word">
+ <label-item
+ v-for="(label, index) in visibleLabels"
+ :key="label.id"
+ :label="label"
+ :is-label-set="label.set"
+ :highlight="index === currentHighlightItem"
+ @clickLabel="handleLabelClick(label)"
+ />
+ <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center">
+ {{ __('No matching results') }}
+ </li>
+ </ul>
+ </div>
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-footer"
+ data-testid="dropdown-footer"
+ >
+ <ul class="list-unstyled">
+ <li v-if="allowLabelCreate">
+ <gl-link
+ class="gl-display-flex w-100 flex-row text-break-word label-item"
+ @click="handleCreateLabelClick"
+ >
+ {{ footerCreateLabelTitle }}
+ </gl-link>
+ </li>
+ <li>
+ <gl-link
+ :href="labelsManagePath"
+ class="gl-display-flex flex-row text-break-word label-item"
+ >
+ {{ footerManageLabelTitle }}
+ </gl-link>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue
new file mode 100644
index 00000000000..5d1663bc1fd
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+
+export default {
+ components: {
+ GlButton,
+ GlLoadingIcon,
+ },
+ props: {
+ labelsSelectInProgress: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['allowLabelEdit', 'labelsFetchInProgress']),
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents']),
+ },
+};
+</script>
+
+<template>
+ <div class="title hide-collapsed gl-mb-3">
+ {{ __('Labels') }}
+ <template v-if="allowLabelEdit">
+ <gl-loading-icon v-show="labelsSelectInProgress" inline />
+ <gl-button
+ variant="link"
+ class="float-right js-sidebar-dropdown-toggle"
+ data-qa-selector="labels_edit_button"
+ @click="toggleDropdownContents"
+ >{{ __('Edit') }}</gl-button
+ >
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
new file mode 100644
index 00000000000..46ccb9470e5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlLabel } from '@gitlab/ui';
+import { mapState } from 'vuex';
+
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ GlLabel,
+ },
+ props: {
+ disableLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'selectedLabels',
+ 'allowLabelRemove',
+ 'allowScopedLabels',
+ 'labelsFilterBasePath',
+ 'labelsFilterParam',
+ ]),
+ },
+ methods: {
+ labelFilterUrl(label) {
+ return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent(
+ label.title,
+ )}`;
+ },
+ scopedLabel(label) {
+ return this.allowScopedLabels && isScopedLabel(label);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{
+ 'has-labels': selectedLabels.length,
+ }"
+ class="hide-collapsed value issuable-show-labels js-value"
+ >
+ <span v-if="!selectedLabels.length" class="text-secondary">
+ <slot></slot>
+ </span>
+ <template v-for="label in selectedLabels" v-else>
+ <gl-label
+ :key="label.id"
+ data-qa-selector="selected_label_content"
+ :data-qa-label-name="label.title"
+ :title="label.title"
+ :description="label.description"
+ :background-color="label.color"
+ :target="labelFilterUrl(label)"
+ :scoped="scopedLabel(label)"
+ :show-close-button="allowLabelRemove"
+ :disabled="disableLabels"
+ tooltip-placement="top"
+ @close="$emit('onLabelRemove', label.id)"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
new file mode 100644
index 00000000000..e8fdf4bb0c2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+
+export default {
+ functional: true,
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ isLabelSet: {
+ type: Boolean,
+ required: true,
+ },
+ highlight: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ render(h, { props, listeners }) {
+ const { label, highlight, isLabelSet } = props;
+
+ const labelColorBox = h('span', {
+ class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3',
+ style: {
+ backgroundColor: label.color,
+ },
+ attrs: {
+ 'data-testid': 'label-color-box',
+ },
+ });
+
+ const checkedIcon = h(GlIcon, {
+ class: {
+ 'gl-mr-3 gl-flex-shrink-0': true,
+ hidden: !isLabelSet,
+ },
+ props: {
+ name: 'mobile-issue-close',
+ },
+ });
+
+ const noIcon = h('span', {
+ class: {
+ 'gl-mr-5 gl-pr-3': true,
+ hidden: isLabelSet,
+ },
+ attrs: {
+ 'data-testid': 'no-icon',
+ },
+ });
+
+ const labelTitle = h('span', label.title);
+
+ const labelLink = h(
+ GlLink,
+ {
+ class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal',
+ on: {
+ click: () => {
+ listeners.clickLabel(label);
+ },
+ },
+ },
+ [noIcon, checkedIcon, labelColorBox, labelTitle],
+ );
+
+ return h(
+ 'li',
+ {
+ class: {
+ 'gl-display-block': true,
+ 'gl-text-left': true,
+ 'is-focused': highlight,
+ },
+ },
+ [labelLink],
+ );
+ },
+};
+</script>
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
new file mode 100644
index 00000000000..bf30e3cfac5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -0,0 +1,327 @@
+<script>
+import $ from 'jquery';
+import Vue from 'vue';
+import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
+import { isInViewport } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+
+import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue';
+
+import { DropdownVariant } from './constants';
+import DropdownButton from './dropdown_button.vue';
+import DropdownContents from './dropdown_contents.vue';
+import DropdownTitle from './dropdown_title.vue';
+import DropdownValue from './dropdown_value.vue';
+import labelsSelectModule from './store';
+
+Vue.use(Vuex);
+
+export default {
+ store: new Vuex.Store(labelsSelectModule()),
+ components: {
+ DropdownTitle,
+ DropdownValue,
+ DropdownButton,
+ DropdownContents,
+ DropdownValueCollapsed,
+ },
+ props: {
+ allowLabelRemove: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowLabelEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowLabelCreate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowMultiselect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowScopedLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: DropdownVariant.Sidebar,
+ },
+ selectedLabels: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ labelsSelectInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ labelsFetchPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsManagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsFilterBasePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsFilterParam: {
+ type: String,
+ required: false,
+ default: 'label_name',
+ },
+ dropdownButtonText: {
+ type: String,
+ required: false,
+ default: __('Label'),
+ },
+ labelsListTitle: {
+ type: String,
+ required: false,
+ default: __('Assign labels'),
+ },
+ labelsCreateTitle: {
+ type: String,
+ required: false,
+ default: __('Create group label'),
+ },
+ footerCreateLabelTitle: {
+ type: String,
+ required: false,
+ default: __('Create group label'),
+ },
+ footerManageLabelTitle: {
+ type: String,
+ required: false,
+ default: __('Manage group labels'),
+ },
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ contentIsOnViewport: true,
+ };
+ },
+ computed: {
+ ...mapState(['showDropdownButton', 'showDropdownContents']),
+ ...mapGetters([
+ 'isDropdownVariantSidebar',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
+ dropdownButtonVisible() {
+ return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
+ },
+ },
+ watch: {
+ selectedLabels(selectedLabels) {
+ this.setInitialState({
+ selectedLabels,
+ });
+ },
+ showDropdownContents(showDropdownContents) {
+ this.setContentIsOnViewport(showDropdownContents);
+ },
+ isEditing(newVal) {
+ if (newVal) {
+ this.toggleDropdownContents();
+ }
+ },
+ },
+ mounted() {
+ this.setInitialState({
+ variant: this.variant,
+ allowLabelRemove: this.allowLabelRemove,
+ allowLabelEdit: this.allowLabelEdit,
+ allowLabelCreate: this.allowLabelCreate,
+ allowMultiselect: this.allowMultiselect,
+ allowScopedLabels: this.allowScopedLabels,
+ dropdownButtonText: this.dropdownButtonText,
+ selectedLabels: this.selectedLabels,
+ labelsFetchPath: this.labelsFetchPath,
+ labelsManagePath: this.labelsManagePath,
+ labelsFilterBasePath: this.labelsFilterBasePath,
+ labelsFilterParam: this.labelsFilterParam,
+ labelsListTitle: this.labelsListTitle,
+ labelsCreateTitle: this.labelsCreateTitle,
+ footerCreateLabelTitle: this.footerCreateLabelTitle,
+ footerManageLabelTitle: this.footerManageLabelTitle,
+ });
+
+ this.$store.subscribeAction({
+ after: this.handleVuexActionDispatch,
+ });
+
+ document.addEventListener('mousedown', this.handleDocumentMousedown);
+ document.addEventListener('click', this.handleDocumentClick);
+ },
+ beforeDestroy() {
+ document.removeEventListener('mousedown', this.handleDocumentMousedown);
+ document.removeEventListener('click', this.handleDocumentClick);
+ },
+ methods: {
+ ...mapActions(['setInitialState', 'toggleDropdownContents']),
+ /**
+ * This method differentiates between
+ * dispatched actions and calls necessary method.
+ */
+ handleVuexActionDispatch(action, state) {
+ if (
+ action.type === 'toggleDropdownContents' &&
+ !state.showDropdownButton &&
+ !state.showDropdownContents
+ ) {
+ let filterFn = (label) => label.touched;
+ if (this.isDropdownVariantEmbedded) {
+ filterFn = (label) => label.set;
+ }
+ this.handleDropdownClose(state.labels.filter(filterFn));
+ }
+ },
+ /**
+ * This method stores a mousedown event's target.
+ * Required by the click listener because the click
+ * event itself has no reference to this element.
+ */
+ handleDocumentMousedown({ target }) {
+ this.mousedownTarget = target;
+ },
+ /**
+ * This method listens for document-wide click event
+ * and toggle dropdown if user clicks anywhere outside
+ * the dropdown while dropdown is visible.
+ */
+ handleDocumentClick({ target }) {
+ // We also perform the toggle exception check for the
+ // last mousedown event's target to avoid hiding the
+ // box when the mousedown happened inside the box and
+ // only the mouseup did not.
+ if (
+ this.showDropdownContents &&
+ !this.preventDropdownToggleOnClick(target) &&
+ !this.preventDropdownToggleOnClick(this.mousedownTarget)
+ ) {
+ this.toggleDropdownContents();
+ }
+ },
+ /**
+ * This method checks whether a given click target
+ * should prevent the dropdown from being toggled.
+ */
+ preventDropdownToggleOnClick(target) {
+ // This approach of element detection is needed
+ // as the dropdown wrapper is not using `GlDropdown` as
+ // it will also require us to use `BDropdownForm`
+ // which is yet to be implemented in GitLab UI.
+ const hasExceptionClass = [
+ 'js-dropdown-button',
+ 'js-btn-cancel-create',
+ 'js-sidebar-dropdown-toggle',
+ ].some(
+ (className) =>
+ target?.classList.contains(className) ||
+ target?.parentElement?.classList.contains(className),
+ );
+
+ const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
+ (className) => $(target).parents(className).length,
+ );
+
+ const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
+
+ const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target);
+
+ return (
+ hasExceptionClass ||
+ hasExceptionParent ||
+ isInDropdownButtonCollapsed ||
+ isInDropdownContents
+ );
+ },
+ handleDropdownClose(labels) {
+ // Only emit label updates if there are any labels to update
+ // on UI.
+ if (labels.length) this.$emit('updateSelectedLabels', labels);
+ this.$emit('onDropdownClose');
+ },
+ handleCollapsedValueClick() {
+ this.$emit('toggleCollapse');
+ },
+ setContentIsOnViewport(showDropdownContents) {
+ if (!showDropdownContents) {
+ this.contentIsOnViewport = true;
+
+ return;
+ }
+
+ this.$nextTick(() => {
+ if (this.$refs.dropdownContents) {
+ this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el);
+ }
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="labels-select-wrapper position-relative"
+ :class="{
+ 'is-standalone': isDropdownVariantStandalone,
+ 'is-embedded': isDropdownVariantEmbedded,
+ }"
+ >
+ <template v-if="isDropdownVariantSidebar">
+ <dropdown-value-collapsed
+ ref="dropdownButtonCollapsed"
+ :labels="selectedLabels"
+ @onValueClick="handleCollapsedValueClick"
+ />
+ <dropdown-title
+ :allow-label-edit="allowLabelEdit"
+ :labels-select-in-progress="labelsSelectInProgress"
+ />
+ <dropdown-value
+ :disable-labels="labelsSelectInProgress"
+ @onLabelRemove="$emit('onLabelRemove', $event)"
+ >
+ <slot></slot>
+ </dropdown-value>
+ <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
+ <dropdown-contents
+ v-show="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ :render-on-top="!contentIsOnViewport"
+ />
+ </template>
+ <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
+ <dropdown-button v-show="dropdownButtonVisible" />
+ <dropdown-contents
+ v-if="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ :render-on-top="!contentIsOnViewport"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
new file mode 100644
index 00000000000..89f96ab916b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
@@ -0,0 +1,58 @@
+import { deprecatedCreateFlash as flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import * as types from './mutation_types';
+
+export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props);
+
+export const toggleDropdownButton = ({ commit }) => commit(types.TOGGLE_DROPDOWN_BUTTON);
+export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS);
+
+export const toggleDropdownContentsCreateView = ({ commit }) =>
+ commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW);
+
+export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS);
+export const receiveLabelsSuccess = ({ commit }, labels) =>
+ commit(types.RECEIVE_SET_LABELS_SUCCESS, labels);
+export const receiveLabelsFailure = ({ commit }) => {
+ commit(types.RECEIVE_SET_LABELS_FAILURE);
+ flash(__('Error fetching labels.'));
+};
+export const fetchLabels = ({ state, dispatch }) => {
+ dispatch('requestLabels');
+ return axios
+ .get(state.labelsFetchPath)
+ .then(({ data }) => {
+ dispatch('receiveLabelsSuccess', data);
+ })
+ .catch(() => dispatch('receiveLabelsFailure'));
+};
+
+export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LABEL);
+export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS);
+export const receiveCreateLabelFailure = ({ commit }) => {
+ commit(types.RECEIVE_CREATE_LABEL_FAILURE);
+ flash(__('Error creating label.'));
+};
+export const createLabel = ({ state, dispatch }, label) => {
+ dispatch('requestCreateLabel');
+ axios
+ .post(state.labelsManagePath, {
+ label,
+ })
+ .then(({ data }) => {
+ if (data.id) {
+ dispatch('receiveCreateLabelSuccess');
+ dispatch('toggleDropdownContentsCreateView');
+ } else {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Error Creating Label');
+ }
+ })
+ .catch(() => {
+ dispatch('receiveCreateLabelFailure');
+ });
+};
+
+export const updateSelectedLabels = ({ commit }, labels) =>
+ commit(types.UPDATE_SELECTED_LABELS, { labels });
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js
new file mode 100644
index 00000000000..d14f96720b7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js
@@ -0,0 +1,52 @@
+import { __, s__, sprintf } from '~/locale';
+import { DropdownVariant } from '../constants';
+
+/**
+ * Returns string representing current labels
+ * selection on dropdown button.
+ *
+ * @param {object} state
+ */
+export const dropdownButtonText = (state, getters) => {
+ const selectedLabels = getters.isDropdownVariantSidebar
+ ? state.labels.filter((label) => label.set)
+ : state.selectedLabels;
+
+ if (!selectedLabels.length) {
+ return state.dropdownButtonText || __('Label');
+ } else if (selectedLabels.length > 1) {
+ return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
+ firstLabelName: selectedLabels[0].title,
+ remainingLabelCount: selectedLabels.length - 1,
+ });
+ }
+ return selectedLabels[0].title;
+};
+
+/**
+ * Returns array containing only label IDs from
+ * selectedLabels array.
+ * @param {object} state
+ */
+export const selectedLabelsList = (state) => state.selectedLabels.map((label) => label.id);
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `sidebar`
+ * @param {object} state
+ */
+export const isDropdownVariantSidebar = (state) => state.variant === DropdownVariant.Sidebar;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `standalone`
+ * @param {object} state
+ */
+export const isDropdownVariantStandalone = (state) => state.variant === DropdownVariant.Standalone;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `embedded`
+ * @param {object} state
+ */
+export const isDropdownVariantEmbedded = (state) => state.variant === DropdownVariant.Embedded;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js
new file mode 100644
index 00000000000..5f61cb732c8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js
@@ -0,0 +1,12 @@
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import state from './state';
+
+export default () => ({
+ namespaced: true,
+ state: state(),
+ actions,
+ getters,
+ mutations,
+});
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
new file mode 100644
index 00000000000..2e044dc3b3c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
@@ -0,0 +1,20 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+
+export const REQUEST_LABELS = 'REQUEST_LABELS';
+export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
+export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
+
+export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS';
+export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS';
+export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE';
+
+export const REQUEST_CREATE_LABEL = 'REQUEST_CREATE_LABEL';
+export const RECEIVE_CREATE_LABEL_SUCCESS = 'RECEIVE_CREATE_LABEL_SUCCESS';
+export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
+
+export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
+export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
+
+export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
+
+export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
new file mode 100644
index 00000000000..55716e1105e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
@@ -0,0 +1,70 @@
+import { DropdownVariant } from '../constants';
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, props) {
+ Object.assign(state, { ...props });
+ },
+
+ [types.TOGGLE_DROPDOWN_BUTTON](state) {
+ state.showDropdownButton = !state.showDropdownButton;
+ },
+
+ [types.TOGGLE_DROPDOWN_CONTENTS](state) {
+ if (state.variant === DropdownVariant.Sidebar) {
+ state.showDropdownButton = !state.showDropdownButton;
+ }
+ state.showDropdownContents = !state.showDropdownContents;
+ // Ensure that Create View is hidden by default
+ // when dropdown contents are revealed.
+ if (state.showDropdownContents) {
+ state.showDropdownContentsCreateView = false;
+ }
+ },
+
+ [types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) {
+ state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView;
+ },
+
+ [types.REQUEST_LABELS](state) {
+ state.labelsFetchInProgress = true;
+ },
+ [types.RECEIVE_SET_LABELS_SUCCESS](state, labels) {
+ // Iterate over every label and add a `set` prop
+ // to determine whether it is already a part of
+ // selectedLabels array.
+ const selectedLabelIds = state.selectedLabels.map((label) => label.id);
+ state.labelsFetchInProgress = false;
+ state.labels = labels.reduce((allLabels, label) => {
+ allLabels.push({
+ ...label,
+ set: selectedLabelIds.includes(label.id),
+ });
+ return allLabels;
+ }, []);
+ },
+ [types.RECEIVE_SET_LABELS_FAILURE](state) {
+ state.labelsFetchInProgress = false;
+ },
+
+ [types.REQUEST_CREATE_LABEL](state) {
+ state.labelCreateInProgress = true;
+ },
+ [types.RECEIVE_CREATE_LABEL_SUCCESS](state) {
+ state.labelCreateInProgress = false;
+ },
+ [types.RECEIVE_CREATE_LABEL_FAILURE](state) {
+ state.labelCreateInProgress = false;
+ },
+
+ [types.UPDATE_SELECTED_LABELS](state, { labels }) {
+ // Find the label to update from all the labels
+ // and change `set` prop value to represent their current state.
+ const labelId = labels.pop()?.id;
+ const candidateLabel = state.labels.find((label) => labelId === label.id);
+ if (candidateLabel) {
+ candidateLabel.touched = true;
+ candidateLabel.set = !candidateLabel.set;
+ }
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
new file mode 100644
index 00000000000..d66cfed4163
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
@@ -0,0 +1,29 @@
+export default () => ({
+ // Initial Data
+ labels: [],
+ selectedLabels: [],
+ labelsListTitle: '',
+ labelsCreateTitle: '',
+ footerCreateLabelTitle: '',
+ footerManageLabelTitle: '',
+ dropdownButtonText: '',
+
+ // Paths
+ namespace: '',
+ labelsFetchPath: '',
+ labelsFilterBasePath: '',
+
+ // UI Flags
+ variant: '',
+ allowLabelRemove: false,
+ allowLabelCreate: false,
+ allowLabelEdit: false,
+ allowScopedLabels: false,
+ allowMultiselect: false,
+ showDropdownButton: false,
+ showDropdownContents: false,
+ showDropdownContentsCreateView: false,
+ labelsFetchInProgress: false,
+ labelCreateInProgress: false,
+ selectedLabelsUpdated: false,
+});
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
new file mode 100644
index 00000000000..d99fc125012
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
@@ -0,0 +1,20 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query alertAssignees(
+ $domain: AlertManagementDomainFilter = threat_monitoring
+ $fullPath: ID!
+ $iid: String!
+) {
+ workspace: project(fullPath: $fullPath) {
+ issuable: alertManagementAlert(domain: $domain, iid: $iid) {
+ iid
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue
new file mode 100644
index 00000000000..121c3bd94ef
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_callout_dismisser.vue
@@ -0,0 +1,175 @@
+<script>
+import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
+import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.query.graphql';
+
+/**
+ * A renderless component for querying/dismissing UserCallouts via GraphQL.
+ *
+ * Simplest example usage:
+ *
+ * <user-callout-dismisser feature-name="my_user_callout">
+ * <template #default="{ dismiss, shouldShowCallout }">
+ * <my-callout-component
+ * v-if="shouldShowCallout"
+ * @close="dismiss"
+ * />
+ * </template>
+ * </user-callout-dismisser>
+ *
+ * If you don't want the asynchronous query to run when the component is
+ * created, and know by some other means whether the user callout has already
+ * been dismissed, you can use the `skipQuery` prop, and a regular `v-if`
+ * directive:
+ *
+ * <user-callout-dismisser
+ * v-if="userCalloutIsNotDismissed"
+ * feature-name="my_user_callout"
+ * skip-query
+ * >
+ * <template #default="{ dismiss, shouldShowCallout }">
+ * <my-callout-component
+ * v-if="shouldShowCallout"
+ * @close="dismiss"
+ * />
+ * </template>
+ * </user-callout-dismisser>
+ *
+ * The component exposes various scoped slot props on the default slot,
+ * allowing for granular rendering behaviors based on the state of the initial
+ * query and user-initiated mutation:
+ *
+ * - dismiss: Function
+ * - Triggers mutation to dismiss the user callout.
+ * - isAnonUser: boolean
+ * - Whether the current user is anonymous or not (i.e., whether or not
+ * they're logged in).
+ * - isDismissed: boolean
+ * - Whether the given user callout has been dismissed or not.
+ * - isLoadingMutation: boolean
+ * - Whether the mutation is loading.
+ * - isLoadingQuery: boolean
+ * - Whether the initial query is loading.
+ * - mutationError: string[] | null
+ * - The mutation's errors, if any; otherwise `null`.
+ * - queryError: Error | null
+ * - The query's error, if any; otherwise `null`.
+ * - shouldShowCallout: boolean
+ * - A combination of the above which should cover 95% of use cases: `true`
+ * if the query has loaded without error, and the user is logged in, and
+ * the callout has not been dismissed yet; `false` otherwise.
+ */
+export default {
+ name: 'UserCalloutDismisser',
+ props: {
+ featureName: {
+ type: String,
+ required: true,
+ },
+ skipQuery: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ currentUser: null,
+ isDismissedLocal: false,
+ isLoadingMutation: false,
+ mutationError: null,
+ queryError: null,
+ };
+ },
+ apollo: {
+ currentUser: {
+ query: getUserCalloutsQuery,
+ update(data) {
+ return data?.currentUser;
+ },
+ error(err) {
+ this.queryError = err;
+ },
+ skip() {
+ return this.skipQuery;
+ },
+ },
+ },
+ computed: {
+ featureNameEnumValue() {
+ return this.featureName.toUpperCase();
+ },
+ isLoadingQuery() {
+ return this.$apollo.queries.currentUser.loading;
+ },
+ isAnonUser() {
+ return !(this.skipQuery || this.queryError || this.isLoadingQuery || this.currentUser);
+ },
+ isDismissedRemote() {
+ const callouts = this.currentUser?.callouts?.nodes ?? [];
+
+ return callouts.some(({ featureName }) => featureName === this.featureNameEnumValue);
+ },
+ isDismissed() {
+ return this.isDismissedLocal || this.isDismissedRemote;
+ },
+ slotProps() {
+ const {
+ dismiss,
+ isAnonUser,
+ isDismissed,
+ isLoadingMutation,
+ isLoadingQuery,
+ mutationError,
+ queryError,
+ shouldShowCallout,
+ } = this;
+
+ return {
+ dismiss,
+ isAnonUser,
+ isDismissed,
+ isLoadingMutation,
+ isLoadingQuery,
+ mutationError,
+ queryError,
+ shouldShowCallout,
+ };
+ },
+ shouldShowCallout() {
+ return !(this.isLoadingQuery || this.isDismissed || this.queryError || this.isAnonUser);
+ },
+ },
+ methods: {
+ async dismiss() {
+ this.isLoadingMutation = true;
+ this.isDismissedLocal = true;
+
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: dismissUserCalloutMutation,
+ variables: {
+ input: {
+ featureName: this.featureName,
+ },
+ },
+ });
+
+ const errors = data?.userCalloutCreate?.errors ?? [];
+ if (errors.length > 0) {
+ this.onDismissalError(errors);
+ }
+ } catch (err) {
+ this.onDismissalError([err.message]);
+ } finally {
+ this.isLoadingMutation = false;
+ }
+ },
+ onDismissalError(errors) {
+ this.mutationError = errors;
+ },
+ },
+ render() {
+ return this.$scopedSlots.default(this.slotProps);
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index 3116d2fbf32..04e44aa2ed1 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -60,6 +60,11 @@ export default {
required: false,
default: 'issue',
},
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -74,6 +79,9 @@ export default {
query() {
return participantsQueries[this.issuableType].query;
},
+ skip() {
+ return Boolean(participantsQueries[this.issuableType].skipQuery) || !this.isEditing;
+ },
variables() {
return {
iid: this.iid,
@@ -99,10 +107,13 @@ export default {
first: 20,
};
},
+ skip() {
+ return !this.isEditing;
+ },
update(data) {
// TODO Remove null filter (BE fix required)
// https://gitlab.com/gitlab-org/gitlab/-/issues/329750
- return data.workspace?.users?.nodes.filter((x) => x).map(({ user }) => user) || [];
+ return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || [];
},
debounce: ASSIGNEES_DEBOUNCE_DELAY,
error({ graphQLErrors }) {