diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared')
20 files changed, 556 insertions, 106 deletions
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 3f282138bdf..944b9c0c083 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -162,7 +162,7 @@ export default { </template> <icon name="commit" class="commit-icon js-commit-icon" /> - <gl-link :href="commitUrl" class="commit-sha"> {{ shortSha }} </gl-link> + <gl-link :href="commitUrl" class="commit-sha mr-0"> {{ shortSha }} </gl-link> <div class="commit-title flex-truncate-parent"> <span v-if="title" class="flex-truncate-child"> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue index f085ef35ccc..2b5b2269ec8 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue @@ -40,12 +40,15 @@ export default { }, beforeDestroy() { document.body.removeEventListener('mouseup', this.stopDrag); - this.$refs.dragger.removeEventListener('mousedown', this.startDrag); + document.body.removeEventListener('touchend', this.stopDrag); + document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchmove', this.dragMove); }, methods: { dragMove(e) { if (!this.dragging) return; - const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left; + const moveX = e.pageX || e.touches[0].pageX; + const left = moveX - this.$refs.dragTrack.getBoundingClientRect().left; const dragTrackWidth = this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; @@ -60,11 +63,13 @@ export default { this.dragging = true; document.body.style.userSelect = 'none'; document.body.addEventListener('mousemove', this.dragMove); + document.body.addEventListener('touchmove', this.dragMove); }, stopDrag() { this.dragging = false; document.body.style.userSelect = ''; document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchmove', this.dragMove); }, prepareOnionSkin() { if (this.onionOldImgInfo && this.onionNewImgInfo) { @@ -82,6 +87,7 @@ export default { this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100; document.body.addEventListener('mouseup', this.stopDrag); + document.body.addEventListener('touchend', this.stopDrag); } }, onionNewImgLoaded(imgInfo) { @@ -102,7 +108,7 @@ export default { :style="{ width: onionMaxPixelWidth, height: onionMaxPixelHeight, - 'user-select': dragging === true ? 'none' : '', + 'user-select': dragging ? 'none' : null, }" class="onion-skin-frame" > @@ -140,7 +146,14 @@ export default { </div> <div class="controls"> <div class="transparent"></div> - <div ref="dragTrack" class="drag-track" @mousedown="startDrag" @mouseup="stopDrag"> + <div + ref="dragTrack" + class="drag-track" + @mousedown="startDrag" + @mouseup="stopDrag" + @touchstart="startDrag" + @touchend="stopDrag" + > <div ref="dragger" :style="{ left: onionDraggerPixelPos }" class="dragger"></div> </div> <div class="opaque"></div> diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue index 1c970b72a66..ad3b3b81ac5 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue @@ -46,6 +46,8 @@ export default { window.removeEventListener('resize', this.resizeThrottled, false); document.body.removeEventListener('mouseup', this.stopDrag); document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchend', this.stopDrag); + document.body.removeEventListener('touchmove', this.dragMove); }, mounted() { window.addEventListener('resize', this.resize, false); @@ -54,7 +56,8 @@ export default { dragMove(e) { if (!this.dragging) return; - let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left; + const moveX = e.pageX || e.touches[0].pageX; + let leftValue = moveX - this.$refs.swipeFrame.getBoundingClientRect().left; const spaceLeft = 20; const { clientWidth } = this.$refs.swipeFrame; if (leftValue <= 0) { @@ -68,13 +71,13 @@ export default { }, startDrag() { this.dragging = true; - document.body.style.userSelect = 'none'; document.body.addEventListener('mousemove', this.dragMove); + document.body.addEventListener('touchmove', this.dragMove); }, stopDrag() { this.dragging = false; - document.body.style.userSelect = ''; document.body.removeEventListener('mousemove', this.dragMove); + document.body.removeEventListener('touchmove', this.dragMove); }, prepareSwipe() { if (this.swipeOldImgInfo && this.swipeNewImgInfo) { @@ -85,6 +88,7 @@ export default { Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2; document.body.addEventListener('mouseup', this.stopDrag); + document.body.addEventListener('touchend', this.stopDrag); } }, swipeNewImgLoaded(imgInfo) { @@ -104,7 +108,13 @@ export default { <template> <div class="swipe view"> - <div ref="swipeFrame" class="swipe-frame"> + <div + ref="swipeFrame" + :style="{ + 'user-select': dragging ? 'none' : null, + }" + class="swipe-frame" + > <image-viewer key="swipeOldImg" ref="swipeOldImg" @@ -139,6 +149,8 @@ export default { class="swipe-bar" @mousedown="startDrag" @mouseup="stopDrag" + @touchstart="startDrag" + @touchend="stopDrag" > <span class="top-handle"></span> <span class="bottom-handle"></span> </span> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 0cbcdbf2eb4..1bfa91500cb 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -39,7 +39,7 @@ export default { }, data() { return { - mouseOver: false, + dropdownOpen: false, }; }, computed: { @@ -123,8 +123,8 @@ export default { return this.$router.currentRoute.path === `/project${this.file.url}`; }, - toggleHover(over) { - this.mouseOver = over; + toggleDropdown(val) { + this.dropdownOpen = val; }, }, }; @@ -140,8 +140,7 @@ export default { class="file-row" role="button" @click="clickFile" - @mouseover="toggleHover(true)" - @mouseout="toggleHover(false)" + @mouseleave="toggleDropdown(false)" > <div class="file-row-name-container"> <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> @@ -160,7 +159,8 @@ export default { :is="extraComponent" v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')" :file="file" - :mouse-over="mouseOver" + :dropdown-open="dropdownOpen" + @toggle="toggleDropdown($event)" /> </div> </div> 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 27cfa8abb24..d4d18614f93 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 @@ -1,15 +1,17 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; -import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; -import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; -import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; +import { sprintf } from '~/locale'; +import IssueMilestone from '../../components/issue/issue_milestone.vue'; +import IssueAssignees from '../../components/issue/issue_assignees.vue'; +import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; +import CiIcon from '../ci_icon.vue'; export default { name: 'IssueItem', components: { IssueMilestone, IssueAssignees, + CiIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -27,9 +29,9 @@ export default { return sprintf( '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>', { - state: this.isOpen ? __('Opened') : __('Closed'), - timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords, - timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp, + state: this.stateText, + timeInWords: this.stateTimeInWords, + timestamp: this.stateTimestamp, }, ); }, @@ -84,6 +86,11 @@ export default { {{ pathIdSeparator }}{{ itemId }} </div> <div class="item-meta-child d-flex align-items-center"> + <span v-if="hasPipeline" class="mr-ci-status pr-2"> + <a :href="pipelineStatus.details_path"> + <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" /> + </a> + </span> <issue-milestone v-if="hasMilestone" :milestone="milestone" diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js new file mode 100644 index 00000000000..d1aba99ac22 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js @@ -0,0 +1,20 @@ +/* eslint-disable import/prefer-default-export */ + +function trimFirstCharOfLineContent(text) { + if (!text) { + return text; + } + + return text.replace(/^( |\+|-)/, ''); +} + +function cleanSuggestionLine(line = {}) { + return { + ...line, + text: trimFirstCharOfLineContent(line.text), + }; +} + +export function selectDiffLines(lines) { + return lines.filter(line => line.type !== 'match').map(line => cleanSuggestionLine(line)); +} diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index eccf73e227c..0f3b3568414 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -76,6 +76,7 @@ export default { hasSuggestion: false, markdownPreviewLoading: false, previewMarkdown: false, + suggestions: this.note.suggestions || [], }; }, computed: { @@ -109,9 +110,6 @@ export default { } return lineNumber; }, - suggestions() { - return this.note.suggestions || []; - }, lineType() { return this.line ? this.line.type : ''; }, @@ -175,6 +173,7 @@ export default { this.referencedCommands = data.references.commands; this.referencedUsers = data.references.users; this.hasSuggestion = data.references.suggestions && data.references.suggestions.length; + this.suggestions = data.references.suggestions; } this.$nextTick() diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index cc6ecdb0395..a5a5b2ef415 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -38,7 +38,7 @@ export default { ].join('\n'); }, mdSuggestion() { - return ['```suggestion', `{text}`, '```'].join('\n'); + return ['```suggestion:-0+0', `{text}`, '```'].join('\n'); }, }, mounted() { 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 a351ca62c94..2eb4ec12a4a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -1,24 +1,14 @@ <script> import SuggestionDiffHeader from './suggestion_diff_header.vue'; +import SuggestionDiffRow from './suggestion_diff_row.vue'; +import { selectDiffLines } from '../lib/utils/diff_utils'; export default { components: { SuggestionDiffHeader, + SuggestionDiffRow, }, props: { - newLines: { - type: Array, - required: true, - }, - fromContent: { - type: String, - required: false, - default: '', - }, - fromLine: { - type: Number, - required: true, - }, suggestion: { type: Object, required: true, @@ -33,6 +23,11 @@ export default { required: true, }, }, + computed: { + lines() { + return selectDiffLines(this.suggestion.diff_lines); + }, + }, methods: { applySuggestion(callback) { this.$emit('apply', { suggestionId: this.suggestion.id, callback }); @@ -52,22 +47,11 @@ export default { /> <table class="mb-3 md-suggestion-diff js-syntax-highlight code"> <tbody> - <!-- Old Line --> - <tr class="line_holder old"> - <td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td> - <td class="diff-line-num new_line old"></td> - <td class="line_content old"> - <span>{{ fromContent }}</span> - </td> - </tr> - <!-- New Line(s) --> - <tr v-for="(line, key) of newLines" :key="key" class="line_holder new"> - <td class="diff-line-num old_line new"></td> - <td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td> - <td class="line_content new"> - <span>{{ line.content }}</span> - </td> - </tr> + <suggestion-diff-row + v-for="(line, index) of lines" + :key="`${index}-${line.text}`" + :line="line" + /> </tbody> </table> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue new file mode 100644 index 00000000000..cafd3a515ea --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -0,0 +1,32 @@ +<script> +export default { + name: 'SuggestionDiffRow', + props: { + line: { + type: Object, + required: true, + }, + }, + computed: { + lineType() { + return this.line.type; + }, + }, +}; +</script> + +<template> + <tr class="line_holder" :class="lineType"> + <td class="diff-line-num old_line" :class="lineType"> + {{ line.old_line }} + </td> + <td class="diff-line-num new_line" :class="lineType"> + {{ line.new_line }} + </td> + <td class="line_content" :class="lineType"> + <span v-if="line.text">{{ line.text }}</span> + <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE --> + <span v-else>​</span> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 177d78cb904..8d3705e1e4a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -6,16 +6,6 @@ import Flash from '~/flash'; export default { components: { SuggestionDiff }, props: { - fromLine: { - type: Number, - required: false, - default: 0, - }, - fromContent: { - type: String, - required: false, - default: '', - }, lineType: { type: String, required: false, @@ -71,41 +61,19 @@ export default { suggestionElements.forEach((suggestionEl, i) => { const suggestionParentEl = suggestionEl.parentElement; - const newLines = this.extractNewLines(suggestionParentEl); - const diffComponent = this.generateDiff(newLines, i); + const diffComponent = this.generateDiff(i); diffComponent.$mount(suggestionParentEl); }); this.isRendered = true; }, - extractNewLines(suggestionEl) { - // extracts the suggested lines from the markdown - // calculates a line number for each line - - const newLines = suggestionEl.querySelectorAll('.line'); - const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine; - const lines = []; - - newLines.forEach((line, i) => { - const content = `${line.innerText}\n`; - const lineNumber = fromLine + i; - lines.push({ content, lineNumber }); - }); - - return lines; - }, - generateDiff(newLines, suggestionIndex) { - // generates the diff <suggestion-diff /> component - // all `suggestion` markdown will be swapped out by this component - + generateDiff(suggestionIndex) { const { suggestions, disabled, helpPagePath } = this; const suggestion = suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; - const fromContent = suggestion.from_content || this.fromContent; - const fromLine = suggestion.from_line || this.fromLine; const SuggestionDiffComponent = Vue.extend(SuggestionDiff); const suggestionDiff = new SuggestionDiffComponent({ - propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath }, + propsData: { disabled, suggestion, helpPagePath }, }); suggestionDiff.$on('apply', ({ suggestionId, callback }) => { 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 new file mode 100644 index 00000000000..071bae7f665 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -0,0 +1,74 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue'; +import highlight from '~/lib/utils/highlight'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import _ from 'underscore'; + +export default { + name: 'ProjectListItem', + components: { + Icon, + ProjectAvatar, + GlButton, + }, + props: { + project: { + type: Object, + required: true, + validator: p => _.isFinite(p.id) && _.isString(p.name) && _.isString(p.name_with_namespace), + }, + selected: { + type: Boolean, + required: true, + }, + matcher: { + type: String, + required: false, + default: '', + }, + }, + computed: { + truncatedNamespace() { + return truncateNamespace(this.project.name_with_namespace); + }, + highlightedProjectName() { + return highlight(this.project.name, this.matcher); + }, + }, + methods: { + onClick() { + this.$emit('click'); + }, + }, +}; +</script> +<template> + <gl-button + class="d-flex align-items-center btn pt-1 pb-1 border-0 project-list-item" + @click="onClick" + > + <icon + class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon" + :class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }" + name="mobile-issue-close" + /> + <project-avatar class="flex-shrink-0 js-project-avatar" :project="project" :size="32" /> + <div class="d-flex flex-wrap project-namespace-name-container"> + <div + v-if="truncatedNamespace" + :title="project.name_with_namespace" + class="text-secondary text-truncate js-project-namespace" + > + {{ truncatedNamespace }} + <span v-if="truncatedNamespace" class="text-secondary">/ </span> + </div> + <div + :title="project.name" + class="js-project-name text-truncate" + v-html="highlightedProjectName" + ></div> + </div> + </gl-button> +</template> 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 new file mode 100644 index 00000000000..596fd48f96a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -0,0 +1,103 @@ +<script> +import _ from 'underscore'; +import { GlLoadingIcon } from '@gitlab/ui'; +import ProjectListItem from './project_list_item.vue'; + +const SEARCH_INPUT_TIMEOUT_MS = 500; + +export default { + name: 'ProjectSelector', + components: { + GlLoadingIcon, + ProjectListItem, + }, + props: { + projectSearchResults: { + type: Array, + required: true, + }, + selectedProjects: { + type: Array, + required: true, + }, + showNoResultsMessage: { + type: Boolean, + required: false, + default: false, + }, + showMinimumSearchQueryMessage: { + type: Boolean, + required: false, + default: false, + }, + showLoadingIndicator: { + type: Boolean, + required: false, + default: false, + }, + showSearchErrorMessage: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + searchQuery: '', + }; + }, + methods: { + projectClicked(project) { + this.$emit('projectClicked', project); + }, + isSelected(project) { + return Boolean(_.findWhere(this.selectedProjects, { id: project.id })); + }, + focusSearchInput() { + this.$refs.searchInput.focus(); + }, + onInput: _.debounce(function debouncedOnInput() { + this.$emit('searched', this.searchQuery); + }, SEARCH_INPUT_TIMEOUT_MS), + }, +}; +</script> +<template> + <div> + <input + ref="searchInput" + v-model="searchQuery" + :placeholder="__('Search your projects')" + type="search" + class="form-control mb-3 js-project-selector-input" + autofocus + @input="onInput" + /> + <div class="d-flex flex-column"> + <gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" /> + <div v-if="!showLoadingIndicator" class="d-flex flex-column"> + <project-list-item + v-for="project in projectSearchResults" + :key="project.id" + :selected="isSelected(project)" + :project="project" + :matcher="searchQuery" + class="js-project-list-item" + @click="projectClicked(project)" + /> + </div> + <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message"> + {{ __('Sorry, no projects matched your search') }} + </div> + <div + v-if="showMinimumSearchQueryMessage" + class="text-muted ml-2 js-minimum-search-query-message" + > + {{ __('Enter at least three characters to search') }} + </div> + <div v-if="showSearchErrorMessage" class="text-danger ml-2 js-search-error-message"> + {{ __('Something went wrong, unable to search projects') }} + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue new file mode 100644 index 00000000000..1f3d248e991 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue @@ -0,0 +1,40 @@ +<script> +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import $ from 'jquery'; + +export default { + data() { + return { + width: 0, + height: 0, + }; + }, + beforeDestroy() { + this.contentResizeHandler.off('content.resize', this.debouncedResize); + window.removeEventListener('resize', this.debouncedResize); + }, + created() { + this.debouncedResize = debounceByAnimationFrame(this.onResize); + + // Handle when we explicictly trigger a custom resize event + this.contentResizeHandler = $(document).on('content.resize', this.debouncedResize); + + // Handle window resize + window.addEventListener('resize', this.debouncedResize); + }, + methods: { + onResize() { + // Slot dimensions + const { clientWidth, clientHeight } = this.$refs.chartWrapper; + this.width = clientWidth; + this.height = clientHeight; + }, + }, +}; +</script> + +<template> + <div ref="chartWrapper"> + <slot :width="width" :height="height"> </slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index f66e81b1e08..9c258c4651f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -75,6 +75,16 @@ export default { required: false, default: false, }, + enableScopedLabels: { + type: Boolean, + require: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + require: false, + default: '#', + }, }, computed: { hiddenInputName() { @@ -123,7 +133,12 @@ export default { @onValueClick="handleCollapsedValueClick" /> <dropdown-title :can-edit="canEdit" /> - <dropdown-value :labels="context.labels" :label-filter-base-path="labelFilterBasePath"> + <dropdown-value + :labels="context.labels" + :label-filter-base-path="labelFilterBasePath" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + :enable-scoped-labels="enableScopedLabels" + > <slot></slot> </dropdown-value> <div v-if="canEdit" class="selectbox js-selectbox" style="display: none;"> @@ -142,6 +157,8 @@ export default { :namespace="namespace" :labels="context.labels" :show-extra-options="!showCreate" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + :enable-scoped-labels="enableScopedLabels" /> <div class="dropdown-menu dropdown-select dropdown-menu-paging 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 498b507d11d..1eed8907bb7 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 @@ -31,6 +31,16 @@ export default { type: Boolean, required: true, }, + enableScopedLabels: { + type: Boolean, + require: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + require: false, + default: '#', + }, }, computed: { dropdownToggleText() { @@ -61,6 +71,8 @@ export default { :data-labels="labelsPath" :data-namespace-path="namespace" :data-show-any="showExtraOptions" + :data-scoped-labels="enableScopedLabels" + :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink" type="button" class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal" data-toggle="dropdown" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue index 6faf3fafad1..ddc488adbcb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue @@ -1,9 +1,11 @@ <script> -import tooltip from '~/vue_shared/directives/tooltip'; +import DropdownValueScopedLabel from './dropdown_value_scoped_label.vue'; +import DropdownValueRegularLabel from './dropdown_value_regular_label.vue'; export default { - directives: { - tooltip, + components: { + DropdownValueScopedLabel, + DropdownValueRegularLabel, }, props: { labels: { @@ -14,6 +16,16 @@ export default { type: String, required: true, }, + enableScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + required: false, + default: '#', + }, }, computed: { isEmpty() { @@ -30,6 +42,12 @@ export default { backgroundColor: label.color, }; }, + scopedLabelsDescription({ description = '' }) { + return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`; + }, + showScopedLabels({ title = '' }) { + return this.enableScopedLabels && title.indexOf('::') !== -1; + }, }, }; </script> @@ -44,17 +62,24 @@ export default { <span v-if="isEmpty" class="text-secondary"> <slot>{{ __('None') }}</slot> </span> - <a v-for="label in labels" v-else :key="label.id" :href="labelFilterUrl(label)"> - <span - v-tooltip - :style="labelStyle(label)" - :title="label.description" - class="badge color-label" - data-placement="bottom" - data-container="body" - > - {{ label.title }} - </span> - </a> + + <template v-for="label in labels" v-else> + <dropdown-value-scoped-label + v-if="showScopedLabels(label)" + :key="label.id" + :label="label" + :label-filter-url="labelFilterUrl(label)" + :label-style="labelStyle(label)" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + /> + + <dropdown-value-regular-label + v-else + :key="label.id" + :label="label" + :label-filter-url="labelFilterUrl(label)" + :label-style="labelStyle(label)" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue new file mode 100644 index 00000000000..282b181f11e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue @@ -0,0 +1,35 @@ +<script> +import { GlLink, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlTooltip, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + labelStyle: { + type: Object, + required: true, + }, + labelFilterUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <a ref="regularLabelRef" :href="labelFilterUrl"> + <span :style="labelStyle" class="badge color-label"> + {{ label.title }} + </span> + <gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport"> + {{ label.description }} + </gl-tooltip> + </a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue new file mode 100644 index 00000000000..ad5a86de166 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue @@ -0,0 +1,47 @@ +<script> +import { GlLink, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlTooltip, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + labelStyle: { + type: Object, + required: true, + }, + scopedLabelsDocumentationLink: { + type: String, + required: true, + }, + labelFilterUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span class="d-inline-block position-relative scoped-label-wrapper"> + <a :href="labelFilterUrl"> + <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label"> + {{ label.title }} + </span> + <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport"> + <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span + ><br /> + {{ label.description }} + </gl-tooltip> + </a> + + <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label" + ><i class="fa fa-question-circle" :style="labelStyle"></i + ></gl-link> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js index 455ae832234..8e0e4baa75a 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import { sprintf, __ } from '~/locale'; import { formatDate } from '~/lib/utils/datetime_utility'; import tooltip from '~/vue_shared/directives/tooltip'; import icon from '~/vue_shared/components/icon.vue'; @@ -58,6 +59,11 @@ const mixins = { required: false, default: '', }, + mergedAt: { + type: String, + required: false, + default: '', + }, milestone: { type: Object, required: false, @@ -83,6 +89,16 @@ const mixins = { required: false, default: false, }, + isMergeRequest: { + type: Boolean, + required: false, + default: false, + }, + pipelineStatus: { + type: Object, + required: false, + default: () => ({}), + }, }, components: { icon, @@ -95,12 +111,18 @@ const mixins = { hasState() { return this.state && this.state.length > 0; }, + hasPipeline() { + return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length; + }, isOpen() { return this.state === 'opened'; }, isClosed() { return this.state === 'closed'; }, + isMerged() { + return this.state === 'merged'; + }, hasTitle() { return this.title.length > 0; }, @@ -108,9 +130,17 @@ const mixins = { return !_.isEmpty(this.milestone); }, iconName() { + if (this.isMergeRequest && this.isMerged) { + return 'merge'; + } + return this.isOpen ? 'issue-open-m' : 'issue-close'; }, iconClass() { + if (this.isMergeRequest && this.isClosed) { + return 'merge-request-status closed issue-token-state-icon-closed'; + } + return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed'; }, computedLinkElementType() { @@ -131,12 +161,44 @@ const mixins = { createdAtTimestamp() { return this.createdAt ? formatDate(new Date(this.createdAt)) : ''; }, + mergedAtTimestamp() { + return this.mergedAt ? formatDate(new Date(this.mergedAt)) : ''; + }, + mergedAtInWords() { + return this.mergedAt ? this.timeFormated(this.mergedAt) : ''; + }, closedAtInWords() { return this.closedAt ? this.timeFormated(this.closedAt) : ''; }, closedAtTimestamp() { return this.closedAt ? formatDate(new Date(this.closedAt)) : ''; }, + stateText() { + if (this.isMerged) { + return __('Merged'); + } + + return this.isOpen ? __('Opened') : __('Closed'); + }, + stateTimeInWords() { + if (this.isMerged) { + return this.mergedAtInWords; + } + + return this.isOpen ? this.createdAtInWords : this.closedAtInWords; + }, + stateTimestamp() { + if (this.isMerged) { + return this.mergedAtTimestamp; + } + + return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp; + }, + pipelineStatusTooltip() { + return this.hasPipeline + ? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label }) + : ''; + }, }, methods: { onRemoveRequest() { |