diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
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> |