summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/components
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-07-20 12:26:25 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-07-20 12:26:25 +0000
commita09983ae35713f5a2bbb100981116d31ce99826e (patch)
tree2ee2af7bd104d57086db360a7e6d8c9d5d43667a /app/assets/javascripts/vue_shared/components
parent18c5ab32b738c0b6ecb4d0df3994000482f34bd8 (diff)
downloadgitlab-ce-a09983ae35713f5a2bbb100981116d31ce99826e.tar.gz
Add latest changes from gitlab-org/gitlab@13-2-stable-ee
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue65
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue (renamed from app/assets/javascripts/vue_shared/components/issue/issue_warning.vue)48
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/remove_member_modal.vue78
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/constants.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue95
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue147
-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/add_image_modal.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue75
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js68
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js53
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js (renamed from app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js)16
-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_embedded_ruby_text.js13
-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_html_block.js9
-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.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js27
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue98
50 files changed, 1080 insertions, 311 deletions
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 9f6f3d2d63a..d6f591ccca1 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -261,7 +261,7 @@ export default {
</li>
</template>
<li v-else class="dropdown-menu-empty-item">
- <div class="append-right-default prepend-left-default gl-mt-3 gl-mb-3">
+ <div class="gl-mr-3 gl-ml-3 gl-mt-3 gl-mb-3">
<template v-if="loading">
{{ __('Loading...') }}
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
index 590501a975a..79c62cd9938 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -88,7 +88,7 @@ export default {
>
</span>
</strong>
- <span class="diff-changed-file-path prepend-top-5">
+ <span class="diff-changed-file-path gl-mt-2">
<span
v-for="(char, charIndex) in pathWithEllipsis.split('')"
:key="charIndex + char"
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index b084ebdf774..7484486d6b4 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import getIconForFile from './file_icon/file_icon_map';
+import { FILE_SYMLINK_MODE } from '../constants';
/* This is a re-usable vue component for rendering a svg sprite
icon
@@ -24,6 +25,11 @@ export default {
type: String,
required: true,
},
+ fileMode: {
+ type: String,
+ required: false,
+ default: '',
+ },
folder: {
type: Boolean,
@@ -60,8 +66,12 @@ export default {
},
},
computed: {
+ isSymlink() {
+ return this.fileMode === FILE_SYMLINK_MODE;
+ },
spriteHref() {
const iconName = this.submodule ? 'folder-git' : getIconForFile(this.fileName) || 'file';
+
return `${gon.sprite_file_icons}#${iconName}`;
},
folderIconName() {
@@ -75,13 +85,11 @@ export default {
</script>
<template>
<span>
- <svg v-if="!loading && !folder" :class="[iconSizeClass, cssClasses]">
- <use v-bind="{ 'xlink:href': spriteHref }" /></svg
- ><gl-icon
- v-if="!loading && folder"
- :name="folderIconName"
- :size="size"
- class="folder-icon"
- /><gl-loading-icon v-if="loading" :inline="true" />
+ <gl-loading-icon v-if="loading" :inline="true" />
+ <gl-icon v-else-if="isSymlink" name="symlink" :size="size" />
+ <svg v-else-if="!folder" :class="[iconSizeClass, cssClasses]">
+ <use v-bind="{ 'xlink:href': spriteHref }" />
+ </svg>
+ <gl-icon v-else :name="folderIconName" :size="size" class="folder-icon" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 0cc96309a92..0952e37e46e 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -118,7 +118,12 @@ export default {
@mouseleave="$emit('mouseleave', $event)"
>
<div class="file-row-name-container">
- <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated">
+ <span
+ ref="textOutput"
+ :style="levelIndentation"
+ class="file-row-name str-truncated"
+ data-qa-selector="file_name_content"
+ >
<file-icon
class="file-row-icon"
:class="{ 'text-secondary': file.type === 'tree' }"
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 a858ffdbed5..04090213218 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
@@ -83,6 +83,7 @@ export default {
return {
initialRender: true,
recentSearchesPromise: null,
+ recentSearches: [],
filterValue: this.initialFilterValue,
selectedSortOption,
selectedSortDirection,
@@ -98,6 +99,15 @@ export default {
{},
);
},
+ tokenTitles() {
+ return this.tokens.reduce(
+ (tokenSymbols, token) => ({
+ ...tokenSymbols,
+ [token.type]: token.title,
+ }),
+ {},
+ );
+ },
sortDirectionIcon() {
return this.selectedSortDirection === SortDirection.ascending
? 'sort-lowest'
@@ -112,11 +122,10 @@ export default {
watch: {
/**
* GlFilteredSearch currently doesn't emit any event when
- * search field is cleared, but we still want our parent
- * component to know that filters were cleared and do
- * necessary data refetch, so this watcher is basically
- * a dirty hack/workaround to identify if filter input
- * was cleared. :(
+ * tokens are manually removed from search field so we'd
+ * never know when user actually clears all the tokens.
+ * This watcher listens for updates to `filterValue` on
+ * such instances. :(
*/
filterValue(value) {
const [firstVal] = value;
@@ -172,11 +181,9 @@ export default {
this.recentSearchesStore.state.recentSearches.concat(searches),
);
this.recentSearchesService.save(resultantSearches);
+ this.recentSearches = resultantSearches;
});
},
- getRecentSearches() {
- return this.recentSearchesStore?.state.recentSearches;
- },
handleSortOptionClick(sortBy) {
this.selectedSortOption = sortBy;
this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
@@ -188,26 +195,22 @@ export default {
: SortDirection.ascending;
this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
},
+ handleHistoryItemSelected(filters) {
+ this.$emit('onFilter', filters);
+ },
+ handleClearHistory() {
+ const resultantSearches = this.recentSearchesStore.setRecentSearches([]);
+ this.recentSearchesService.save(resultantSearches);
+ this.recentSearches = [];
+ },
handleFilterSubmit(filters) {
if (this.recentSearchesStorageKey) {
this.recentSearchesPromise
.then(() => {
if (filters.length) {
- const searchTokens = filters.map(filter => {
- // check filter was plain text search
- if (typeof filter === 'string') {
- return filter;
- }
- // filter was a token.
- return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${
- filter.value.data
- }`;
- });
-
- const resultantSearches = this.recentSearchesStore.addRecentSearch(
- searchTokens.join(' '),
- );
+ const resultantSearches = this.recentSearchesStore.addRecentSearch(filters);
this.recentSearchesService.save(resultantSearches);
+ this.recentSearches = resultantSearches;
}
})
.catch(() => {
@@ -226,10 +229,24 @@ export default {
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
- :history-items="getRecentSearches()"
+ :history-items="recentSearches"
class="flex-grow-1"
+ @history-item-selected="handleHistoryItemSelected"
+ @clear-history="handleClearHistory"
@submit="handleFilterSubmit"
- />
+ >
+ <template #history-item="{ historyItem }">
+ <template v-for="(token, index) in historyItem">
+ <span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span>
+ <span v-else :key="`${token.type}-${token.value.data}`" class="gl-px-1">
+ <span v-if="tokenTitles[token.type]"
+ >{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span
+ >
+ <strong>{{ tokenSymbols[token.type] }}{{ token.value.data }}</strong>
+ </span>
+ </template>
+ </template>
+ </gl-filtered-search>
<gl-button-group class="sort-dropdown-container d-flex">
<gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
<gl-dropdown-item
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 412bfa5aa7f..d50649d2581 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
@@ -46,6 +46,16 @@ export default {
return this.authors.find(author => author.username.toLowerCase() === this.currentValue);
},
},
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.authors.length) {
+ this.fetchAuthorBySearchTerm(this.value.data);
+ }
+ },
+ },
+ },
methods: {
fetchAuthorBySearchTerm(searchTerm) {
const fetchPromise = this.config.fetchPath
@@ -89,9 +99,9 @@ export default {
<span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
</template>
<template #suggestions>
- <gl-filtered-search-suggestion :value="$options.anyAuthor">{{
- __('Any')
- }}</gl-filtered-search-suggestion>
+ <gl-filtered-search-suggestion :value="$options.anyAuthor">
+ {{ __('Any') }}
+ </gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index a7fba5e760b..0ef4f1eda27 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -3,18 +3,19 @@ import { escape } from 'lodash';
import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
/**
* Creates the HTML template for each row of the mentions dropdown.
*
- * @param original An object from the array returned from the `autocomplete_sources/members` API
- * @returns {string} An HTML template
+ * @param original - An object from the array returned from the `autocomplete_sources/members` API
+ * @returns {string} - An HTML template
*/
function menuItemTemplate({ original }) {
const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
- gl-display-inline-flex gl-align-items-center gl-justify-content-center`;
+ gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
const avatarTag = original.avatar_url
? `<img
@@ -48,6 +49,7 @@ export default {
},
data() {
return {
+ assignees: undefined,
members: undefined,
};
},
@@ -76,19 +78,37 @@ export default {
*/
getMembers(inputText, processValues) {
if (this.members) {
- processValues(this.members);
+ processValues(this.getFilteredMembers());
} else if (this.dataSources.members) {
axios
.get(this.dataSources.members)
.then(response => {
this.members = response.data;
- processValues(response.data);
+ processValues(this.getFilteredMembers());
})
.catch(() => {});
} else {
processValues([]);
}
},
+ getFilteredMembers() {
+ const fullText = this.$slots.default[0].elm.value;
+
+ if (!this.assignees) {
+ this.assignees =
+ SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
+ }
+
+ if (fullText.startsWith('/assign @')) {
+ return this.members.filter(member => !this.assignees.includes(member.username));
+ }
+
+ if (fullText.startsWith('/unassign @')) {
+ return this.members.filter(member => this.assignees.includes(member.username));
+ }
+
+ return this.members;
+ },
},
render(createElement) {
return createElement('div', this.$slots.default);
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
index df6fadf10cd..e14f6a04d3c 100644
--- a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
@@ -52,6 +52,14 @@ export default {
// $root.$emit is a workaround because other b-modal approaches don't work yet with gl-modal
this.$root.$emit('bv::hide::modal', this.modalId);
},
+ cancel() {
+ this.$emit('cancel');
+ this.syncHide();
+ },
+ ok() {
+ this.$emit('ok');
+ this.syncHide();
+ },
},
};
</script>
@@ -65,5 +73,6 @@ export default {
@hidden="syncHide"
>
<slot></slot>
+ <slot slot="modal-footer" name="modal-footer" :ok="ok" :cancel="cancel"></slot>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index 63de1e009fd..caf13bc898b 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -82,7 +82,7 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
- class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
+ class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
/>
<a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a>
diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue
index 3508c557289..59ce632c4a2 100644
--- a/app/assets/javascripts/vue_shared/components/loading_button.vue
+++ b/app/assets/javascripts/vue_shared/components/loading_button.vue
@@ -47,7 +47,7 @@ export default {
v-if="loading"
:inline="true"
:class="{
- 'append-right-5': label,
+ 'gl-mr-2': label,
}"
class="js-loading-button-icon"
/>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 0e05f4a4622..f954b8eb4f4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -4,21 +4,25 @@ import '~/behaviors/markdown/render_gfm';
import { unescape } from 'lodash';
import { __, sprintf } from '~/locale';
import { stripHtml } from '~/lib/utils/text_utility';
-import Flash from '../../../flash';
-import GLForm from '../../../gl_form';
-import markdownHeader from './header.vue';
-import markdownToolbar from './toolbar.vue';
-import icon from '../icon.vue';
+import Flash from '~/flash';
+import GLForm from '~/gl_form';
+import MarkdownHeader from './header.vue';
+import MarkdownToolbar from './toolbar.vue';
+import Icon from '../icon.vue';
+import GlMentions from '~/vue_shared/components/gl_mentions.vue';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import axios from '~/lib/utils/axios_utils';
export default {
components: {
- markdownHeader,
- markdownToolbar,
- icon,
+ GlMentions,
+ MarkdownHeader,
+ MarkdownToolbar,
+ Icon,
Suggestions,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
isSubmitting: {
type: Boolean,
@@ -159,12 +163,10 @@ export default {
},
},
mounted() {
- /*
- GLForm class handles all the toolbar buttons
- */
+ // GLForm class handles all the toolbar buttons
return new GLForm($(this.$refs['gl-form']), {
emojis: this.enableAutocomplete,
- members: this.enableAutocomplete,
+ members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
issues: this.enableAutocomplete,
mergeRequests: this.enableAutocomplete,
epics: this.enableAutocomplete,
@@ -229,7 +231,7 @@ export default {
<template>
<div
ref="gl-form"
- :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
+ :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
class="js-vue-markdown-field md-area position-relative"
>
<markdown-header
@@ -243,7 +245,10 @@ export default {
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
- <slot name="textarea"></slot>
+ <gl-mentions v-if="glFeatures.tributeAutocomplete">
+ <slot name="textarea"></slot>
+ </gl-mentions>
+ <slot v-else name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-700"
href="#"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index aa1abb5adb6..049f5e71849 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -89,14 +89,13 @@ export default {
<div class="md-header">
<ul class="nav-links clearfix">
<li :class="{ active: !previewMarkdown }" class="md-header-tab">
- <button class="js-write-link" tabindex="-1" type="button" @click="writeMarkdownTab($event)">
+ <button class="js-write-link" type="button" @click="writeMarkdownTab($event)">
{{ __('Write') }}
</button>
</li>
<li :class="{ active: previewMarkdown }" class="md-header-tab">
<button
class="js-preview-link js-md-preview-button"
- tabindex="-1"
type="button"
@click="previewMarkdownTab($event)"
>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index 6dac448d5de..13c42d35b04 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -68,6 +68,7 @@ export default {
:is-applying-batch="suggestion.is_applying_batch"
:batch-suggestions-count="batchSuggestionsCount"
:help-page-path="helpPagePath"
+ :inapplicable-reason="suggestion.inapplicable_reason"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index e26ff51e01e..4de80e9b4c2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -38,6 +38,11 @@ export default {
type: String,
required: true,
},
+ inapplicableReason: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -52,14 +57,7 @@ export default {
return this.isApplyingSingle || this.isApplyingBatch;
},
tooltipMessage() {
- return this.canApply
- ? __('This also resolves the discussion')
- : __("Can't apply as this line has changed or the suggestion already matches its content.");
- },
- tooltipMessageBatch() {
- return !this.canBeBatched
- ? __("Suggestions that change line count can't be added to batches, yet.")
- : this.tooltipMessage;
+ return this.canApply ? __('This also resolves this thread') : this.inapplicableReason;
},
isDisableButton() {
return this.isApplying || !this.canApply;
@@ -129,15 +127,14 @@ export default {
</gl-deprecated-button>
</div>
<div v-else class="d-flex align-items-center">
- <span v-if="canBeBatched" v-gl-tooltip.viewport="tooltipMessageBatch" tabindex="0">
- <gl-deprecated-button
- class="btn-inverted js-add-to-batch-btn btn-grouped"
- :disabled="isDisableButton"
- @click="addSuggestionToBatch"
- >
- {{ __('Add suggestion to batch') }}
- </gl-deprecated-button>
- </span>
+ <gl-deprecated-button
+ v-if="canBeBatched && !isDisableButton"
+ class="btn-inverted js-add-to-batch-btn btn-grouped"
+ :disabled="isDisableButton"
+ @click="addSuggestionToBatch"
+ >
+ {{ __('Add suggestion to batch') }}
+ </gl-deprecated-button>
<span v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
<gl-deprecated-button
class="btn-inverted js-apply-btn btn-grouped"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 330785c9319..5d47aed9643 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -61,7 +61,7 @@ export default {
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
<template>
- <gl-icon name="media" :size="16" />
+ <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" />
</template>
<span class="attaching-file-message"></span>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
@@ -71,7 +71,7 @@ export default {
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
<template>
- <gl-icon name="media" :size="16" />
+ <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" />
</template>
</span>
<span class="uploading-error-message"></span>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index 94f78c0c085..f37dd9e171c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -64,7 +64,6 @@ export default {
:aria-label="buttonTitle"
type="button"
class="toolbar-btn js-md"
- tabindex="-1"
data-container="body"
@click="() => $emit('click')"
>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
index cb3cd18e5a7..f986b105f20 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -8,6 +8,12 @@ function buildDocsLinkStart(path) {
return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`;
}
+const NoteableTypeText = {
+ Issue: __('issue'),
+ Epic: __('epic'),
+ MergeRequest: __('merge request'),
+};
+
export default {
components: {
icon,
@@ -24,12 +30,18 @@ export default {
default: false,
required: false,
},
- lockedIssueDocsPath: {
+ noteableType: {
+ type: String,
+ required: false,
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ default: 'Issue',
+ },
+ lockedNoteableDocsPath: {
type: String,
required: false,
default: '',
},
- confidentialIssueDocsPath: {
+ confidentialNoteableDocsPath: {
type: String,
required: false,
default: '',
@@ -45,19 +57,33 @@ export default {
isLockedAndConfidential() {
return this.isConfidential && this.isLocked;
},
+ noteableTypeText() {
+ return NoteableTypeText[this.noteableType];
+ },
confidentialAndLockedDiscussionText() {
return sprintf(
__(
- 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
+ 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
),
{
- confidentialLinkStart: buildDocsLinkStart(this.confidentialIssueDocsPath),
- lockedLinkStart: buildDocsLinkStart(this.lockedIssueDocsPath),
+ noteableTypeText: this.noteableTypeText,
+ confidentialLinkStart: buildDocsLinkStart(this.confidentialNoteableDocsPath),
+ lockedLinkStart: buildDocsLinkStart(this.lockedNoteableDocsPath),
linkEnd: '</a>',
},
false,
);
},
+ confidentialContextText() {
+ return sprintf(__('This is a confidential %{noteableTypeText}.'), {
+ noteableTypeText: this.noteableTypeText,
+ });
+ },
+ lockedContextText() {
+ return sprintf(__('This %{noteableTypeText} is locked.'), {
+ noteableTypeText: this.noteableTypeText,
+ });
+ },
},
};
</script>
@@ -73,19 +99,15 @@ export default {
</span>
<span v-else-if="isConfidential" ref="confidential">
- {{ __('This is a confidential issue.') }}
+ {{ confidentialContextText }}
{{ __('People without permission will never get a notification.') }}
- <gl-link :href="confidentialIssueDocsPath" target="_blank">
- {{ __('Learn more') }}
- </gl-link>
+ <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link>
</span>
<span v-else-if="isLocked" ref="locked">
- {{ __('This issue is locked.') }}
+ {{ lockedContextText }}
{{ __('Only project members can comment.') }}
- <gl-link :href="lockedIssueDocsPath" target="_blank">
- {{ __('Learn more') }}
- </gl-link>
+ <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link>
</span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index b6271a95008..fe57d4f29ca 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -122,7 +122,7 @@ export default {
></div>
<div v-if="hasMoreCommits" class="flex-list">
<div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded">
- <icon :name="toggleIcon" :size="8" class="append-right-5" />
+ <icon :name="toggleIcon" :size="8" class="gl-mr-2" />
<span>{{ __('Toggle commit list') }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
index 29a4a90a59f..5f2a66ee0b7 100644
--- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
@@ -20,7 +20,7 @@ export default {
Here is an example `change` method:
change(pagenum) {
- gl.utils.visitUrl(`?page=${pagenum}`);
+ visitUrl(`?page=${pagenum}`);
},
*/
change: {
@@ -64,7 +64,7 @@ export default {
<template>
<gl-pagination
v-if="showPagination"
- class="justify-content-center prepend-top-default"
+ class="justify-content-center gl-mt-3"
v-bind="$attrs"
:value="pageInfo.page"
:per-page="pageInfo.perPage"
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 3d52f4176db..e053a9ddaa6 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -8,30 +8,25 @@ import { truncateNamespace } from '~/lib/utils/text_utility';
export default {
name: 'ProjectListItem',
- components: {
- Icon,
- ProjectAvatar,
- GlDeprecatedButton,
- },
+ components: { Icon, ProjectAvatar, GlDeprecatedButton },
props: {
project: {
type: Object,
required: true,
- validator: p => Number.isFinite(p.id) && isString(p.name) && isString(p.name_with_namespace),
- },
- selected: {
- type: Boolean,
- required: true,
- },
- matcher: {
- type: String,
- required: false,
- default: '',
+ validator: p =>
+ (Number.isFinite(p.id) || isString(p.id)) &&
+ isString(p.name) &&
+ (isString(p.name_with_namespace) || isString(p.nameWithNamespace)),
},
+ selected: { type: Boolean, required: true },
+ matcher: { type: String, required: false, default: '' },
},
computed: {
+ projectNameWithNamespace() {
+ return this.project.nameWithNamespace || this.project.name_with_namespace;
+ },
truncatedNamespace() {
- return truncateNamespace(this.project.name_with_namespace);
+ return truncateNamespace(this.projectNameWithNamespace);
},
highlightedProjectName() {
return highlight(this.project.name, this.matcher);
@@ -50,7 +45,7 @@ export default {
@click="onClick"
>
<icon
- class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon"
+ class="gl-ml-3 gl-mr-3 flex-shrink-0 position-top-0 js-selected-icon"
:class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }"
name="mobile-issue-close"
/>
@@ -58,7 +53,7 @@ export default {
<div class="d-flex flex-wrap project-namespace-name-container">
<div
v-if="truncatedNamespace"
- :title="project.name_with_namespace"
+ :title="projectNameWithNamespace"
class="text-secondary text-truncate js-project-namespace"
>
{{ truncatedNamespace }}
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
index 15a5ce85046..0b91588a006 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -41,7 +41,8 @@ export default {
},
totalResults: {
type: Number,
- required: true,
+ required: false,
+ default: 0,
},
},
data() {
@@ -87,6 +88,7 @@ export default {
type="search"
class="mb-3"
autofocus
+ data-qa-selector="project_search_field"
@input="onInput"
/>
<div class="d-flex flex-column">
@@ -106,6 +108,7 @@ export default {
:project="project"
:matcher="searchQuery"
class="js-project-list-item"
+ data-qa-selector="project_list_item"
@click="projectClicked(project)"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
new file mode 100644
index 00000000000..88d1b15aee3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
@@ -0,0 +1,78 @@
+<script>
+import { GlFormCheckbox, GlModal } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import csrf from '~/lib/utils/csrf';
+import { __ } from '~/locale';
+
+export default {
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ csrf,
+ components: {
+ GlFormCheckbox,
+ GlModal,
+ },
+ data() {
+ return {
+ modalData: {},
+ };
+ },
+ computed: {
+ isAccessRequest() {
+ return parseBoolean(this.modalData.isAccessRequest);
+ },
+ actionText() {
+ return this.isAccessRequest ? __('Deny access request') : __('Remove member');
+ },
+ actionPrimary() {
+ return {
+ text: this.actionText,
+ attributes: {
+ variant: 'danger',
+ },
+ };
+ },
+ },
+ mounted() {
+ document.addEventListener('click', this.handleClick);
+ },
+ beforeDestroy() {
+ document.removeEventListener('click', this.handleClick);
+ },
+ methods: {
+ handleClick(event) {
+ const removeButton = event.target.closest('.js-remove-member-button');
+ if (removeButton) {
+ this.modalData = removeButton.dataset;
+ this.$refs.modal.show();
+ }
+ },
+ submitForm() {
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="remove-member-modal"
+ :action-cancel="$options.actionCancel"
+ :action-primary="actionPrimary"
+ :title="actionText"
+ data-qa-selector="remove_member_modal_content"
+ @primary="submitForm"
+ >
+ <form ref="form" :action="modalData.memberPath" method="post">
+ <p data-testid="modal-message">{{ modalData.message }}</p>
+
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ <gl-form-checkbox v-if="!isAccessRequest" name="unassign_issuables">
+ {{ __('Also unassign this user from related issues and merge requests') }}
+ </gl-form-checkbox>
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js b/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js
new file mode 100644
index 00000000000..edc5ffb7b77
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/resizable_chart/constants.js
@@ -0,0 +1,6 @@
+export const DEFAULT_RX = 0.4;
+export const DEFAULT_BAR_WIDTH = 6;
+export const DEFAULT_LABEL_WIDTH = 4;
+export const DEFAULT_LABEL_HEIGHT = 5;
+export const BAR_HEIGHTS = [5, 7, 9, 14, 21, 35, 50, 80];
+export const GRID_YS = [30, 60, 90];
diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue
new file mode 100644
index 00000000000..306fa61780f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/resizable_chart/skeleton_loader.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+import {
+ DEFAULT_RX,
+ DEFAULT_BAR_WIDTH,
+ DEFAULT_LABEL_WIDTH,
+ DEFAULT_LABEL_HEIGHT,
+ BAR_HEIGHTS,
+ GRID_YS,
+} from './constants';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+ props: {
+ barWidth: {
+ type: Number,
+ default: DEFAULT_BAR_WIDTH,
+ required: false,
+ },
+ labelWidth: {
+ type: Number,
+ default: DEFAULT_LABEL_WIDTH,
+ required: false,
+ },
+ labelHeight: {
+ type: Number,
+ default: DEFAULT_LABEL_HEIGHT,
+ required: false,
+ },
+ rx: {
+ type: Number,
+ default: DEFAULT_RX,
+ required: false,
+ },
+ // skeleton-loader will generate a unique key if not defined
+ uniqueKey: {
+ type: String,
+ default: undefined,
+ required: false,
+ },
+ },
+ computed: {
+ labelCentering() {
+ return (this.barWidth - this.labelWidth) / 2;
+ },
+ },
+ methods: {
+ getBarXPosition(index) {
+ const numberOfBars = this.$options.BAR_HEIGHTS.length;
+ const numberOfSpaces = numberOfBars + 1;
+ const spaceBetweenBars = (100 - numberOfSpaces * this.barWidth) / numberOfBars;
+
+ return (0.5 + index) * (this.barWidth + spaceBetweenBars);
+ },
+ },
+ BAR_HEIGHTS,
+ GRID_YS,
+};
+</script>
+<template>
+ <gl-skeleton-loader :unique-key="uniqueKey">
+ <rect
+ v-for="(y, index) in $options.GRID_YS"
+ :key="`grid-${index}`"
+ data-testid="skeleton-chart-grid"
+ x="0"
+ :y="`${y}%`"
+ width="100%"
+ height="1px"
+ />
+ <rect
+ v-for="(height, index) in $options.BAR_HEIGHTS"
+ :key="`bar-${index}`"
+ data-testid="skeleton-chart-bar"
+ :x="`${getBarXPosition(index)}%`"
+ :y="`${90 - height}%`"
+ :width="`${barWidth}%`"
+ :height="`${height}%`"
+ :rx="`${rx}%`"
+ />
+ <rect
+ v-for="(height, index) in $options.BAR_HEIGHTS"
+ :key="`label-${index}`"
+ data-testid="skeleton-chart-label"
+ :x="`${labelCentering + getBarXPosition(index)}%`"
+ :y="`${100 - labelHeight}%`"
+ :width="`${labelWidth}%`"
+ :height="`${labelHeight}%`"
+ :rx="`${rx}%`"
+ />
+ </gl-skeleton-loader>
+</template>
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
index 1566c2c784b..dd1da847001 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -1,5 +1,6 @@
import { __ } from '~/locale';
-import { generateToolbarItem } from './editor_service';
+import { generateToolbarItem } from './services/editor_service';
+import buildCustomHTMLRenderer from './services/build_custom_renderer';
export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal',
@@ -31,6 +32,7 @@ const TOOLBAR_ITEM_CONFIGS = [
export const EDITOR_OPTIONS = {
toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)),
+ customHTMLRenderer: buildCustomHTMLRenderer(),
};
export const EDITOR_TYPES = {
@@ -41,3 +43,7 @@ export const EDITOR_TYPES = {
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
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
new file mode 100644
index 00000000000..0a444b2295d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
@@ -0,0 +1,147 @@
+<script>
+import { isSafeURL } from '~/lib/utils/url_utility';
+import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
+import { __ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { IMAGE_TABS } from '../../constants';
+import UploadImageTab from './upload_image_tab.vue';
+
+export default {
+ components: {
+ UploadImageTab,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlTabs,
+ GlTab,
+ },
+ mixins: [glFeatureFlagMixin()],
+ 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'),
+ urlTabTitle: __('By URL'),
+ urlLabel: __('Image URL'),
+ descriptionLabel: __('Description'),
+ uploadTabTitle: __('Upload file'),
+ 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.glFeatures.sseImageUploads && 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 = `${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-if="glFeatures.sseImageUploads" 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>
+
+ <gl-form-group
+ v-else
+ 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>
+
+ <!-- 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
new file mode 100644
index 00000000000..739f8b502c9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue
@@ -0,0 +1,56 @@
+<script>
+import { __ } from '~/locale';
+import { GlFormGroup } from '@gitlab/ui';
+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/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
deleted file mode 100644
index 40063065926..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
+++ /dev/null
@@ -1,74 +0,0 @@
-<script>
-import { isSafeURL } from '~/lib/utils/url_utility';
-import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlModal,
- GlFormGroup,
- GlFormInput,
- },
- data() {
- return {
- error: null,
- imageUrl: null,
- altText: null,
- modalTitle: __('Image Details'),
- okTitle: __('Insert'),
- urlLabel: __('Image URL'),
- descriptionLabel: __('Description'),
- };
- },
- methods: {
- show() {
- this.error = null;
- this.imageUrl = null;
- this.altText = null;
-
- this.$refs.modal.show();
- },
- onOk(event) {
- if (!this.isValid()) {
- event.preventDefault();
- return;
- }
-
- const { imageUrl, altText } = this;
-
- this.$emit('addImage', { imageUrl, altText: altText || __('image') });
- },
- isValid() {
- if (!isSafeURL(this.imageUrl)) {
- this.error = __('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="modalTitle"
- :ok-title="okTitle"
- @ok="onOk"
- >
- <gl-form-group
- :label="urlLabel"
- label-for="url-input"
- :state="!Boolean(error)"
- :invalid-feedback="error"
- >
- <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
- </gl-form-group>
-
- <gl-form-group :label="descriptionLabel" label-for="description-input">
- <gl-form-input id="description-input" ref="descriptionInput" v-model="altText" />
- </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
index 5c310fc059b..baeb98bec75 100644
--- 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
@@ -2,7 +2,7 @@
import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
-import AddImageModal from './modals/add_image_modal.vue';
+import AddImageModal from './modals/add_image/add_image_modal.vue';
import {
EDITOR_OPTIONS,
EDITOR_TYPES,
@@ -12,11 +12,12 @@ import {
} from './constants';
import {
+ registerHTMLToMarkdownRenderer,
addCustomEventListener,
removeCustomEventListener,
addImage,
getMarkdown,
-} from './editor_service';
+} from './services/editor_service';
export default {
components: {
@@ -27,7 +28,7 @@ export default {
AddImageModal,
},
props: {
- value: {
+ content: {
type: String,
required: true,
},
@@ -51,6 +52,11 @@ export default {
required: false,
default: EDITOR_PREVIEW_STYLE,
},
+ imageRoot: {
+ type: String,
+ required: true,
+ validator: prop => prop.endsWith('/'),
+ },
},
data() {
return {
@@ -66,51 +72,48 @@ export default {
return this.$refs.editor;
},
},
- watch: {
- value(newVal) {
- const isSameMode = this.previousMode === this.editorApi.currentMode;
- if (!isSameMode) {
- /*
- The ToastUI Editor consumes its content via the `initial-value` prop and then internally
- manages changes. If we desire the `v-model` to work as expected, we need to manually call
- `setMarkdown`. However, if we do this in each v-model change we'll continually prevent
- the editor from internally managing changes. Thus we use the `previousMode` flag as
- confirmation to actually update its internals. This is initially designed so that front
- matter is excluded from editing in wysiwyg mode, but included in markdown mode.
- */
- this.editorInstance.invoke('setMarkdown', newVal);
- this.previousMode = this.editorApi.currentMode;
- }
- },
- },
beforeDestroy() {
- removeCustomEventListener(
- this.editorApi,
- CUSTOM_EVENTS.openAddImageModal,
- this.onOpenAddImageModal,
- );
-
- this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
+ this.removeListeners();
},
methods: {
+ addListeners(editorApi) {
+ addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal);
+
+ editorApi.eventManager.listen('changeMode', this.onChangeMode);
+ },
+ removeListeners() {
+ removeCustomEventListener(
+ this.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ this.onOpenAddImageModal,
+ );
+
+ 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;
- addCustomEventListener(
- this.editorApi,
- CUSTOM_EVENTS.openAddImageModal,
- this.onOpenAddImageModal,
- );
+ registerHTMLToMarkdownRenderer(editorApi);
- this.editorApi.eventManager.listen('changeMode', this.onChangeMode);
+ this.addListeners(editorApi);
},
onOpenAddImageModal() {
this.$refs.addImageModal.show();
},
- onAddImage(image) {
+ onAddImage({ imageUrl, altText, file }) {
+ const image = { imageUrl, altText };
+
+ if (file) {
+ this.$emit('uploadImage', { file, imageUrl });
+ // TODO - ensure that the actual repo URL for the image is used in Markdown mode
+ }
+
addImage(this.editorInstance, image);
},
onChangeMode(newMode) {
@@ -123,7 +126,7 @@ export default {
<div>
<toast-editor
ref="editor"
- :initial-value="value"
+ :initial-value="content"
:options="editorOptions"
:preview-style="previewStyle"
:initial-edit-type="initialEditType"
@@ -131,6 +134,6 @@ export default {
@change="onContentChanged"
@load="onLoad"
/>
- <add-image-modal ref="addImageModal" @addImage="onAddImage" />
+ <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
</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
new file mode 100644
index 00000000000..70d29b5b3df
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
@@ -0,0 +1,68 @@
+import renderBlockHtml from './renderers/render_html_block';
+import renderKramdownList from './renderers/render_kramdown_list';
+import renderKramdownText from './renderers/render_kramdown_text';
+import renderIdentifierInstanceText from './renderers/render_identifier_instance_text';
+import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
+import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text';
+import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
+
+const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
+const htmlBlockRenderers = [renderBlockHtml];
+const listRenderers = [renderKramdownList];
+const paragraphRenderers = [renderIdentifierParagraph];
+const textRenderers = [renderKramdownText, renderEmbeddedRubyText, renderIdentifierInstanceText];
+
+const executeRenderer = (renderers, node, context) => {
+ const availableRenderer = renderers.find(renderer => renderer.canRender(node, context));
+
+ return availableRenderer ? availableRenderer.render(node, context) : context.origin();
+};
+
+const buildCustomRendererFunctions = (customRenderers, defaults) => {
+ const customTypes = Object.keys(customRenderers).filter(type => !defaults[type]);
+ const customEntries = customTypes.map(type => {
+ const fn = (node, context) => executeRenderer(customRenderers[type], node, context);
+ return [type, fn];
+ });
+
+ return Object.fromEntries(customEntries);
+};
+
+const buildCustomHTMLRenderer = (
+ customRenderers = { htmlBlock: [], htmlInline: [], list: [], paragraph: [], text: [] },
+) => {
+ const defaults = {
+ htmlBlock(node, context) {
+ const allHtmlBlockRenderers = [...customRenderers.htmlBlock, ...htmlBlockRenderers];
+
+ return executeRenderer(allHtmlBlockRenderers, node, context);
+ },
+ htmlInline(node, context) {
+ const allHtmlInlineRenderers = [...customRenderers.htmlInline, ...htmlInlineRenderers];
+
+ return executeRenderer(allHtmlInlineRenderers, node, context);
+ },
+ list(node, context) {
+ const allListRenderers = [...customRenderers.list, ...listRenderers];
+
+ return executeRenderer(allListRenderers, node, context);
+ },
+ paragraph(node, context) {
+ const allParagraphRenderers = [...customRenderers.paragraph, ...paragraphRenderers];
+
+ return executeRenderer(allParagraphRenderers, node, context);
+ },
+ text(node, context) {
+ const allTextRenderers = [...customRenderers.text, ...textRenderers];
+
+ return executeRenderer(allTextRenderers, node, context);
+ },
+ };
+
+ return {
+ ...buildCustomRendererFunctions(customRenderers, defaults),
+ ...defaults,
+ };
+};
+
+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
new file mode 100644
index 00000000000..ed04765c871
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
@@ -0,0 +1,53 @@
+import { defaults, repeat } from 'lodash';
+
+const DEFAULTS = {
+ subListIndentSpaces: 4,
+};
+
+const countIndentSpaces = text => {
+ const matches = text.match(/^\s+/m);
+
+ return matches ? matches[0].length : 0;
+};
+
+const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => {
+ const { subListIndentSpaces } = defaults(formattingPreferences, DEFAULTS);
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ const sublistNode = 'LI OL, LI UL';
+
+ 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;
+ },
+ };
+};
+
+export default buildHTMLToMarkdownRender;
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
index 278cd50a947..6436dcaae64 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
-import ToolbarItem from './toolbar_item.vue';
+import ToolbarItem from '../toolbar_item.vue';
+import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
const buildWrapper = propsData => {
const instance = new Vue({
@@ -40,3 +41,16 @@ export const removeCustomEventListener = (editorApi, event, handler) =>
export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
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)),
+ });
+};
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
new file mode 100644
index 00000000000..d96cadafdbb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
@@ -0,0 +1,63 @@
+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 buildUneditableTokens = 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_embedded_ruby_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js
new file mode 100644
index 00000000000..494057fc75b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js
@@ -0,0 +1,13 @@
+import { buildUneditableTokens } from './build_uneditable_token';
+
+const embeddedRubyRegex = /(^<%.+%>$)/;
+
+const canRender = ({ literal }) => {
+ return embeddedRubyRegex.test(literal);
+};
+
+const render = (_, { origin }) => {
+ return buildUneditableTokens(origin());
+};
+
+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
new file mode 100644
index 00000000000..572f6e3cf9d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
@@ -0,0 +1,11 @@
+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_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
new file mode 100644
index 00000000000..b179ca61dba
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js
@@ -0,0 +1,9 @@
+import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
+
+const canRender = ({ type }) => {
+ return type === 'htmlBlock';
+};
+
+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
new file mode 100644
index 00000000000..a9c3dfcd728
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js
@@ -0,0 +1,40 @@
+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
new file mode 100644
index 00000000000..f5b4502ea3c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
@@ -0,0 +1,16 @@
+import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token';
+
+const identifierRegex = /(^\[.+\]: .+)/;
+
+const isIdentifier = text => {
+ return identifierRegex.test(text);
+};
+
+const canRender = (node, context) => {
+ return isIdentifier(context.getChildrenText(node));
+};
+
+const render = (_, { entering, origin }) =>
+ entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js
new file mode 100644
index 00000000000..491a26c81d0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js
@@ -0,0 +1,27 @@
+import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token';
+
+const isKramdownTOC = ({ type, literal }) => type === 'text' && literal === 'TOC';
+
+const canRender = node => {
+ let targetNode = node;
+ while (targetNode !== null) {
+ const { firstChild } = targetNode;
+ const isLeaf = firstChild === null;
+ if (isLeaf) {
+ if (isKramdownTOC(targetNode)) {
+ return true;
+ }
+
+ break;
+ }
+
+ targetNode = targetNode.firstChild;
+ }
+
+ return false;
+};
+
+const render = (_, { entering, origin }) =>
+ entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js
new file mode 100644
index 00000000000..01384699e4f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js
@@ -0,0 +1,13 @@
+import { buildUneditableTokens } from './build_uneditable_token';
+
+const kramdownRegex = /(^{:.+}$)/;
+
+const canRender = ({ literal }) => {
+ return kramdownRegex.test(literal);
+};
+
+const render = (_, { origin }) => {
+ return buildUneditableTokens(origin());
+};
+
+export default { canRender, render };
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
index 30f7e6a5980..1be5284fa9c 100644
--- 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
@@ -1,7 +1,11 @@
<script>
import { __, s__, sprintf } from '~/locale';
+import { GlIcon } from '@gitlab/ui';
export default {
+ components: {
+ GlIcon,
+ },
props: {
abilityName: {
type: String,
@@ -72,6 +76,10 @@ export default {
data-toggle="dropdown"
>
<span class="dropdown-toggle-text"> {{ dropdownToggleText }} </span>
- <i aria-hidden="true" class="fa fa-chevron-down" data-hidden="true"> </i>
+ <gl-icon
+ name="chevron-down"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-700"
+ :size="16"
+ />
</button>
</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
index bf51fa3dc38..f0a846c4924 100644
--- 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
@@ -1,5 +1,11 @@
<script>
-export default {};
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+};
</script>
<template>
@@ -10,13 +16,13 @@ export default {};
class="dropdown-input-field"
type="search"
/>
- <i aria-hidden="true" class="fa fa-search dropdown-input-search" data-hidden="true"> </i>
- <i
- aria-hidden="true"
- class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
- data-hidden="true"
- role="button"
- >
- </i>
+ <gl-icon
+ name="search"
+ class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-500 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-700"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
index e94e7d46f85..746e38e98e8 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
@@ -1,6 +1,7 @@
export const DropdownVariant = {
Sidebar: 'sidebar',
Standalone: 'standalone',
+ Embedded: 'embedded',
};
export const LIST_BUFFER_SIZE = 5;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
index f45c14f8344..cf77aa37d14 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
@@ -8,12 +8,16 @@ export default {
GlIcon,
},
computed: {
- ...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']),
+ ...mapGetters([
+ 'dropdownButtonText',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
},
methods: {
...mapActions(['toggleDropdownContents']),
handleButtonClick(e) {
- if (this.isDropdownVariantStandalone) {
+ if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
this.toggleDropdownContents();
e.stopPropagation();
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
index ba8d8391952..94671f8a109 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
@@ -88,12 +88,16 @@ export default {
@click.prevent="handleColorClick(color)"
/>
</div>
- <div class="color-input-container d-flex">
+ <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" :placeholder="__('Use custom color #FF0000')" />
+ <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">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index af16088b6b9..ef506d00d9a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -36,7 +36,7 @@ export default {
'footerCreateLabelTitle',
'footerManageLabelTitle',
]),
- ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']),
+ ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
visibleLabels() {
if (this.searchKey) {
return this.labels.filter(label =>
@@ -126,16 +126,19 @@ export default {
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
<gl-loading-icon
v-if="labelsFetchInProgress"
- class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100"
+ class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100"
size="md"
/>
- <div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2">
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ >
<span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="small"
- class="dropdown-header-button p-0"
+ class="dropdown-header-button gl-p-0!"
icon="close"
@click="toggleDropdownContents"
/>
@@ -165,17 +168,21 @@ export default {
</li>
</smart-virtual-list>
</div>
- <div v-if="isDropdownVariantSidebar" class="dropdown-footer">
+ <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer">
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<gl-link
- class="d-flex w-100 flex-row text-break-word label-item"
+ class="gl-display-flex w-100 flex-row text-break-word label-item"
@click="toggleDropdownContentsCreateView"
- >{{ footerCreateLabelTitle }}</gl-link
>
+ {{ footerCreateLabelTitle }}
+ </gl-link>
</li>
<li>
- <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">
+ <gl-link
+ :href="labelsManagePath"
+ class="gl-display-flex flex-row text-break-word label-item"
+ >
{{ footerManageLabelTitle }}
</gl-link>
</li>
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 f38b66fdfdf..258a87e62b9 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
@@ -74,6 +74,11 @@ export default {
required: false,
default: '',
},
+ dropdownButtonText: {
+ type: String,
+ required: false,
+ default: __('Label'),
+ },
labelsListTitle: {
type: String,
required: false,
@@ -97,7 +102,11 @@ export default {
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
- ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']),
+ ...mapGetters([
+ 'isDropdownVariantSidebar',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
dropdownButtonVisible() {
return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
},
@@ -116,6 +125,7 @@ export default {
allowLabelCreate: this.allowLabelCreate,
allowMultiselect: this.allowMultiselect,
allowScopedLabels: this.allowScopedLabels,
+ dropdownButtonText: this.dropdownButtonText,
selectedLabels: this.selectedLabels,
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
@@ -200,7 +210,10 @@ export default {
<template>
<div
class="labels-select-wrapper position-relative"
- :class="{ 'is-standalone': isDropdownVariantStandalone }"
+ :class="{
+ 'is-standalone': isDropdownVariantStandalone,
+ 'is-embedded': isDropdownVariantEmbedded,
+ }"
>
<template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed
@@ -221,7 +234,7 @@ export default {
ref="dropdownContents"
/>
</template>
- <template v-if="isDropdownVariantStandalone">
+ <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
<dropdown-button v-show="dropdownButtonVisible" />
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
index c39222959a9..e035a866048 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
@@ -13,7 +13,7 @@ export const dropdownButtonText = (state, getters) => {
: state.selectedLabels;
if (!selectedLabels.length) {
- return __('Label');
+ return state.dropdownButtonText || __('Label');
} else if (selectedLabels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: selectedLabels[0].title,
@@ -44,5 +44,12 @@ export const isDropdownVariantSidebar = state => state.variant === DropdownVaria
*/
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;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
index 6a6c0b4c0ee..3f3358d4805 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
@@ -6,6 +6,7 @@ export default () => ({
labelsCreateTitle: '',
footerCreateLabelTitle: '',
footerManageLabelTitle: '',
+ dropdownButtonText: '',
// Paths
namespace: '',
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 595baeeb14f..bd35d3fead9 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -4,8 +4,11 @@ import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
+const MAX_SKELETON_LINES = 4;
+
export default {
name: 'UserPopover',
+ maxSkeletonLines: MAX_SKELETON_LINES,
components: {
Icon,
GlPopover,
@@ -22,11 +25,6 @@ export default {
required: true,
default: null,
},
- loaded: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
statusHtml() {
@@ -42,14 +40,8 @@ export default {
return '';
},
- nameIsLoading() {
- return !this.user.name;
- },
- workInformationIsLoading() {
- return !this.user.loaded && this.user.workInformation === null;
- },
- locationIsLoading() {
- return !this.user.loaded && this.user.location === null;
+ userIsLoading() {
+ return !this.user?.loaded;
},
},
};
@@ -58,54 +50,46 @@ export default {
<template>
<!-- 200ms delay so not every mouseover triggers Popover -->
<gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top">
- <div class="user-popover d-flex">
- <div class="p-1 flex-shrink-1">
- <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" />
+ <div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
+ <div class="gl-p-2 flex-shrink-1">
+ <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" />
</div>
- <div class="p-1 w-100">
- <h5 class="m-0">
- <span v-if="user.name">{{ user.name }}</span>
- <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
- </h5>
- <div class="text-secondary mb-2">
- <span v-if="user.username">@{{ user.username }}</span>
- <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
- </div>
- <div class="text-secondary">
- <div v-if="user.bio" class="d-flex mb-1">
- <icon name="profile" class="category-icon flex-shrink-0" />
- <span ref="bio" class="ml-1">{{ user.bio }}</span>
- </div>
- <div v-if="user.workInformation" class="d-flex mb-1">
- <icon
- v-show="!workInformationIsLoading"
- name="work"
- class="category-icon flex-shrink-0"
- />
- <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span>
- </div>
- <gl-skeleton-loading
- v-if="workInformationIsLoading"
- :lines="1"
- class="animation-container-small mb-1"
- />
- </div>
- <div class="js-location text-secondary d-flex">
- <icon
- v-show="!locationIsLoading && user.location"
- name="location"
- class="category-icon flex-shrink-0"
- />
- <span v-if="user.location" class="ml-1">{{ user.location }}</span>
+ <div class="gl-p-2 gl-w-full">
+ <template v-if="userIsLoading">
+ <!-- `gl-skeleton-loading` does not support equal length lines -->
+ <!-- This can be migrated to `gl-skeleton-loader` when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/872 is completed -->
<gl-skeleton-loading
- v-if="locationIsLoading"
+ v-for="n in $options.maxSkeletonLines"
+ :key="n"
:lines="1"
- class="animation-container-small mb-1"
+ class="animation-container-small gl-mb-2"
/>
- </div>
- <div v-if="statusHtml" class="js-user-status mt-2">
- <span v-html="statusHtml"></span>
- </div>
+ </template>
+ <template v-else>
+ <div class="gl-mb-3">
+ <h5 class="gl-m-0">
+ {{ user.name }}
+ </h5>
+ <span class="gl-text-gray-700">@{{ user.username }}</span>
+ </div>
+ <div class="gl-text-gray-700">
+ <div v-if="user.bio" class="gl-display-flex gl-mb-2">
+ <icon name="profile" class="gl-text-gray-600 gl-flex-shrink-0" />
+ <span ref="bio" class="ml-1" v-html="user.bioHtml"></span>
+ </div>
+ <div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
+ <icon name="work" class="gl-text-gray-600 gl-flex-shrink-0" />
+ <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
+ </div>
+ </div>
+ <div v-if="user.location" class="js-location gl-text-gray-700 gl-display-flex">
+ <icon name="location" class="gl-text-gray-600 flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.location }}</span>
+ </div>
+ <div v-if="statusHtml" class="js-user-status gl-mt-3">
+ <span v-html="statusHtml"></span>
+ </div>
+ </template>
</div>
</div>
</gl-popover>